commit 40318aafdd974dfd71c61834e16308b8620a966f Author: NetDesk Date: Sun Oct 18 02:02:46 2026 +0000 NetDesk 1.5.0 — source release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ead9342 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7e1a93e --- /dev/null +++ b/LICENSE @@ -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 . diff --git a/NetDeskHost.spec b/NetDeskHost.spec new file mode 100644 index 0000000..a12255b --- /dev/null +++ b/NetDeskHost.spec @@ -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'], +) diff --git a/NetDeskServer.spec b/NetDeskServer.spec new file mode 100644 index 0000000..aa18bc7 --- /dev/null +++ b/NetDeskServer.spec @@ -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, +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..08bac8e --- /dev/null +++ b/README.md @@ -0,0 +1,293 @@ +# NetDesk — your own self-hosted remote desktop + +

+ Website + Version + Platform + E2E +

+ +**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 +- **Remote printing** — 🖨 any host PDF, image, or text/code file to your own + local printer from the file manager (driver-free, browser-native print dialog) + +**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 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: you can print any host file (PDF, image, text/code) straight + to **your** local printer from the web viewer's file manager — the 🖨 button + streams the file and opens your browser's native print dialog, no driver on + either side. Seamless redirection of an *arbitrary* remote app's print job + (pressing Ctrl+P inside a program on the host and having it pop out on your + printer) still needs a virtual printer driver and isn't wired up yet. +- Privacy mode blanks the operator's own screen — the Windows host powers the + physical monitor down, the native client paints a capture-excluded black + overlay — so nobody beside the machine can watch the session. A true + multi-session "fake desktop" (a separate virtual display for the remote + operator while a local user keeps working on the real screen at the same time) + needs a virtual display driver and is still on the roadmap. +- 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. +- Mobile: there **is** a native Android app — install the APK to use your phone + or tablet as a viewer (control your PCs), and the Android build also includes + host support (control the phone from a PC). There's no native iOS app yet; + iPhone/iPad use the web viewer, which runs in any mobile browser. + +## 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`. diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000..4284f58 --- /dev/null +++ b/docker/.env.example @@ -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 diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..4d89fb9 --- /dev/null +++ b/docker/Dockerfile @@ -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"] diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..6367005 --- /dev/null +++ b/docker/README.md @@ -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). diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..7be8dc3 --- /dev/null +++ b/docker/docker-compose.yml @@ -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: diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh new file mode 100644 index 0000000..f725912 --- /dev/null +++ b/docker/docker-entrypoint.sh @@ -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 "$@" diff --git a/host/icon.ico b/host/icon.ico new file mode 100644 index 0000000..de36d44 Binary files /dev/null and b/host/icon.ico differ diff --git a/host/netdesk_gui.html b/host/netdesk_gui.html new file mode 100644 index 0000000..c25f851 --- /dev/null +++ b/host/netdesk_gui.html @@ -0,0 +1,971 @@ + + + + + +NetDesk v1.5.0 — Enterprise Remote Desktop + + + +
+ + +
+
+ + NetDesk + — Enterprise Remote Desktop & Unattended Access +
+
+ Relay: … + |— ms + |TLS 1.3 • AES-256-GCM +
+
+ + + +
+
+ +
+ + + + +
+
+
+ Connect to Partner: + + +
+
+ + settings next to the app +
+
+ + +
+
+
+ +
+
+
+

This Computer's ID

+
+
…
+
+ +
+
+ Stopped + E2EE Ready +
+
+ +
+

Unattended Password

+ PBKDF2 · 200k
+
+ + + + + +
+

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.

+
+ Brute-force throttle: 0.4s / attempt + +
+
+
+
+ +
+

Connection

+
+
+
+
+
+
+
+
+ +
+

NetDesk Account

+
+
+
+
+
+ + +
+
+ +
+
+
+ +
+ + +
+ +
+
+
Service
Installed
+
Start with
Windows
+
Privacy
Mode
+
Lock on
disconnect
+
Close to
tray
+
+
+ +
+ + +
+
+ + +
+
+

My Computers

+ + + +
+
+ + + + + + +
+

Sign in to your NetDesk account to see the computers linked to it.

+
+
+ + +
+
+

Two-Factor Authentication (TOTP)

2FA: off
+

Adds a 6-digit authenticator code (Google Authenticator, Authy, 1Password…) + required at every connect — even with the correct password.

+
+ + +
+
+
+

Viewer IP Allowlist

off — everyone with the password
+

Only these IPs / CIDR ranges may connect (e.g. 203.0.113.7, 10.0.0.0/8). + Leave empty to allow any IP that knows the password. Applies to the next connection.

+
+ + +
+

+
+
+

Boot-Time Service

not installed
+

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.

+
+ + +
+
+
+ + +
+
+

Session Audit Log

+ + + + + + + + +
+
Loading…
+
+
+
+
+ + + +
+
+ + + + diff --git a/host/netdesk_gui.py b/host/netdesk_gui.py new file mode 100644 index 0000000..346568e --- /dev/null +++ b/host/netdesk_gui.py @@ -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() diff --git a/host/netdesk_host.py b/host/netdesk_host.py new file mode 100644 index 0000000..0c7ec54 --- /dev/null +++ b/host/netdesk_host.py @@ -0,0 +1,3318 @@ +#!/usr/bin/env python3 +""" +NetDesk Host - unattended remote desktop agent for Windows (full access). + +Modes: + python netdesk_host.py --server wss://VPS/ws [--listen 8901] # simple in-process mode + python netdesk_host.py --install # install+start boot-time service broker (run as admin once) + python netdesk_host.py --uninstall + python netdesk_host.py --broker # service broker (normally started by the scheduled task) + python netdesk_host.py --session-agent --broker ws://127.0.0.1:PORT/agent --token TOK + # per-desktop worker, spawned by the broker + +Service architecture (like TeamViewer/RustDesk): + broker (SYSTEM, session 0, starts at boot) + - owns the outbound connection to the NetDesk server + - spawns a desktop agent into the active console session as SYSTEM + -> agent can capture and inject on the user desktop, the logon screen + and UAC prompts (full access, before/without any user being logged on) + - relays traffic server <-> agent (agent holds all E2E keys, broker is a + dumb pipe) + +Features: H.264 capture/encode, SendInput keyboard+mouse (desktop-following), +clipboard sync, file manager with folder upload, chat, remote power, process +manager, privacy mode (monitor off), lock-on-disconnect, optional system audio +(PyAudioWPatch), optional TOTP 2FA, wake display on connect. +""" + +import argparse +import asyncio +import base64 +import hashlib +import hmac +import json +import logging +import mss +import numpy as np +import os +import queue +import secrets +import shutil +import string +import subprocess +import sys +import threading +import time +import uuid +from fractions import Fraction +from pathlib import Path + +import av +import aiohttp +from aiohttp import web, WSMsgType +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import serialization + +# WebRTC (aiortc): optional P2P/UDP fast path. When it's missing or the NAT +# won't allow it, everything keeps working over the WSS relay exactly as before. +try: + from aiortc import (RTCPeerConnection, RTCSessionDescription, + RTCConfiguration, RTCIceServer) + RTC_AVAILABLE = True +except Exception: + RTC_AVAILABLE = False +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.primitives import hashes + +# DXGI Desktop Duplication (dxcam): optional, Windows-only fast-capture path. +# ~10x faster than mss GDI readback (4ms vs ~45ms/frame at 1080p) with built-in +# damage detection (grab() returns None when the screen is unchanged). When it's +# missing or fails (secure desktop, RDP, odd GPU), capture falls back to mss. +try: + import dxcam as _dxcam + _DX_AVAILABLE = (sys.platform == "win32") +except Exception: + _dxcam = None + _DX_AVAILABLE = False + +log = logging.getLogger("netdesk.host") +VERSION = "1.5.0-netdesk" +BUILTIN_SERVER = "wss://netdesk.space/ws" +IS_WIN = sys.platform == "win32" +CREATE_NO_WINDOW = 0x08000000 if IS_WIN else 0 + + +def normalize_server(addr: str) -> str: + """Accept what users type - 'netdesk.space', 'https://x', 'x:8443', + 'wss://x/ws' - and return the websocket URL of the server.""" + a = (addr or "").strip().rstrip("/") + if not a: + return "" + if a.startswith("https://"): + a = "wss://" + a[8:] + elif a.startswith("http://"): + a = "ws://" + a[7:] + elif not a.startswith(("ws://", "wss://")): + host = a.split("/")[0] + is_local = (host.split(":")[0] in ("localhost", "127.0.0.1") + or host.startswith("192.168.") or host.startswith("10.")) + a = ("ws://" if is_local else "wss://") + a + _, rest = a.split("://", 1) + if "/" not in rest: + a += "/ws" + return a + + +def exe_name_config() -> dict: + """Configuration baked into the file name (RustDesk-style), e.g. + 'NetDeskHost-host=desk.example.com,key=SECRET.exe' -> preset server + key. + Lets you hand out a pre-configured exe with no manual setup.""" + if not getattr(sys, "frozen", False): + return {} + name = os.path.splitext(os.path.basename(sys.executable))[0] + out = {} + for part in name.replace("--", ",").split(","): + for key, field in (("host=", "server"), ("key=", "reg_key")): + idx = part.find(key) + if idx != -1: + out[field] = part[idx + len(key):].strip() + if out.get("server"): + out["server"] = normalize_server(out["server"]) + return out + + +DEFAULT_SERVER = exe_name_config().get("server") or BUILTIN_SERVER + +QUALITY_PRESETS = { + "low": dict(fps=15, bitrate=2000, scale=0.75), + "balanced": dict(fps=30, bitrate=6000, scale=1.0), + "high": dict(fps=30, bitrate=12000, scale=1.0), + "extreme": dict(fps=60, bitrate=20000, scale=1.0), +} + + +# ================================================================ config + +def default_config_dir(): + """Config location. Frozen (exe) builds are portable: settings and logs + live next to the executable, so the folder can be carried on a USB stick + and moved freely. If that folder is not writable (network share, read-only + mount, Program Files), fall back to the user profile so the app still + works. Source (non-frozen) runs always use the user profile folder.""" + if getattr(sys, "frozen", False): + exe_dir = Path(sys.executable).resolve().parent + try: + probe = exe_dir / ".nd_write_test" + probe.write_bytes(b"") + probe.unlink() + return exe_dir + except Exception: + pass + if IS_WIN: + d = Path(os.environ.get("APPDATA", Path.home())) / "NetDesk" + else: + d = Path.home() / ".netdesk" + d.mkdir(parents=True, exist_ok=True) + return d + + +def programdata_dir(): + d = Path(os.environ.get("ProgramData", r"C:\ProgramData")) / "NetDesk" + return d + + +_CONFIG_DIR = None + + +def appdata_dir(): + global _CONFIG_DIR + d = _CONFIG_DIR or default_config_dir() + d.mkdir(parents=True, exist_ok=True) + return d + + +def make_verifier(password: str, salt: bytes = None, iters: int = 200000): + salt = salt or secrets.token_bytes(16) + kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=iters) + verifier = kdf.derive(password.encode("utf-8")) + return salt, iters, verifier + + +# ---------------------------------------------------------------- TOTP (RFC 6238, stdlib only) + +def totp_now(secret: str, t: float = None, step: int = 30, digits: int = 6) -> str: + if t is None: + t = time.time() + key = secret.upper().rstrip("=") + key += "=" * ((8 - len(key) % 8) % 8) + key = base64.b32decode(key) + counter = int(t // step).to_bytes(8, "big") + digest = hmac.new(key, counter, hashlib.sha1).digest() + off = digest[-1] & 0x0F + code = ((digest[off] & 0x7F) << 24 | digest[off + 1] << 16 | + digest[off + 2] << 8 | digest[off + 3]) % (10 ** digits) + return str(code).zfill(digits) + + +def totp_verify(secret: str, code: str, window: int = 1) -> bool: + code = (code or "").strip() + if not code.isdigit(): + return False + now = time.time() + return any(hmac.compare_digest(totp_now(secret, now + i * 30), code) + for i in range(-window, window + 1)) + + +def gen_password(n: int = 8) -> str: + alphabet = "abcdefghijkmnpqrstuvwxyz23456789" # no look-alikes (l/1, o/0) + return "".join(secrets.choice(alphabet) for _ in range(n)) + + +class Config: + def __init__(self, path: Path): + self.path = path + self.data = {} + if path.exists(): + try: + self.data = json.loads(path.read_text("utf-8")) + except Exception: + log.warning("config unreadable, regenerating") + if "id" not in self.data: + self.data["id"] = str(secrets.randbelow(900000000) + 100000000) + if "verifier" not in self.data: + pw = gen_password() + salt, iters, ver = make_verifier(pw) + self.data.update(salt=salt.hex(), iters=iters, verifier=ver.hex(), pw_plain=pw) + self._new_password = pw + else: + self._new_password = None + baked = exe_name_config() + if baked.get("server") and not self.data.get("server_locked_by_user"): + self.data["server"] = baked["server"] + if baked.get("reg_key") and not self.data.get("reg_key"): + self.data["reg_key"] = baked["reg_key"] + self.data.setdefault("server", DEFAULT_SERVER) + self.data.setdefault("name", os.environ.get("COMPUTERNAME", "host")) + self.data.setdefault("perms", {"input": True, "clipboard": True, "files": True, "audio": True}) + self.data.setdefault("lock_on_end", False) + self.data.setdefault("privacy_on_session", False) + self.data.setdefault("allow_display_power", False) + # optional viewer IP allowlist: comma/space separated IPs or CIDRs + self.data.setdefault("allow_ips", "") + self.save() + + def save(self): + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_suffix(".tmp") + tmp.write_text(json.dumps(self.data, indent=2), "utf-8") + os.replace(tmp, self.path) + + def reload(self): + """Pick up changes made by the desktop app (password, account, server) + without restarting the engine or service.""" + try: + fresh = json.loads(self.path.read_text("utf-8")) + if isinstance(fresh, dict) and fresh.get("id") and fresh.get("verifier"): + self.data = fresh + except Exception: + pass + + @property + def id(self): return self.data["id"] + + def set_password(self, pw: str): + salt, iters, ver = make_verifier(pw) + self.data.update(salt=salt.hex(), iters=iters, verifier=ver.hex(), pw_plain=pw) + self.save() + + def set_totp(self, secret: str): + self.data["totp"] = secret + self.save() + + def check_proof(self, nonce: str, proof: str) -> bool: + want = hashlib.sha256((nonce + self.data["verifier"]).encode()).hexdigest() + return secrets.compare_digest(want, proof.lower()) + + def check_proof_bound(self, nonce: str, host_pub_b64: str, proof: str) -> bool: + """Channel-bound proof (MITM hardening): hex(SHA256(nonce+verifier+host_pub)). + Binds the host's ephemeral ECDH pubkey so a relay that swapped it is + detected (it can't produce a matching proof without the verifier).""" + want = hashlib.sha256( + (nonce + self.data["verifier"] + host_pub_b64).encode()).hexdigest() + return secrets.compare_digest(want, proof.lower()) + + +# ================================================================ Windows layer + +if IS_WIN: + import ctypes + from ctypes import wintypes + from ctypes.wintypes import DWORD, WORD, LONG + + ULONG_PTR = ctypes.c_size_t + user32 = ctypes.windll.user32 + kernel32 = ctypes.windll.kernel32 + advapi32 = ctypes.windll.advapi32 + + def _set_dpi_awareness(): + """Make the process PER-MONITOR-DPI-AWARE (v2) BEFORE mss/capture/SendInput + run. Without this, screen-capture geometry, monitor origins and injected + mouse coordinates disagree on scaled or mixed-DPI multi-monitor setups, + so the remote cursor lands in the wrong place. RustDesk does the same.""" + try: + user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4)) # PER_MONITOR_AWARE_V2 + return + except Exception: + pass + try: + ctypes.windll.shcore.SetProcessDpiAwareness(2) # PER_MONITOR_AWARE + return + except Exception: + pass + try: + user32.SetProcessDPIAware() # system-DPI-aware (last resort) + except Exception: + pass + + _set_dpi_awareness() + + class MOUSEINPUT(ctypes.Structure): + _fields_ = [("dx", LONG), ("dy", LONG), ("mouseData", DWORD), + ("dwFlags", DWORD), ("time", DWORD), ("dwExtraInfo", ULONG_PTR)] + + class KEYBDINPUT(ctypes.Structure): + _fields_ = [("wVk", WORD), ("wScan", WORD), ("dwFlags", DWORD), + ("time", DWORD), ("dwExtraInfo", ULONG_PTR)] + + class _U(ctypes.Union): + # NOTE: do NOT add a c_byte padding member here. An oversized union makes + # sizeof(INPUT) 48 instead of the 40 Windows x64 requires, and SendInput + # then rejects EVERY event (returns 0) - no mouse, no keyboard at all. + _fields_ = [("mi", MOUSEINPUT), ("ki", KEYBDINPUT)] + + class INPUT(ctypes.Structure): + _fields_ = [("type", DWORD), ("u", _U)] + + class PROCESS_INFORMATION(ctypes.Structure): + _fields_ = [("hProcess", wintypes.HANDLE), ("hThread", wintypes.HANDLE), + ("dwProcessId", DWORD), ("dwThreadId", DWORD)] + + class STARTUPINFOW(ctypes.Structure): + _fields_ = [("cb", DWORD), ("lpReserved", wintypes.LPWSTR), ("lpDesktop", wintypes.LPWSTR), + ("lpTitle", wintypes.LPWSTR), ("dwX", DWORD), ("dwY", DWORD), + ("dwXSize", DWORD), ("dwYSize", DWORD), ("dwXCountChars", DWORD), + ("dwYCountChars", DWORD), ("dwFillAttribute", DWORD), ("dwFlags", DWORD), + ("wShowWindow", WORD), ("cbReserved2", WORD), ("lpReserved2", wintypes.LPBYTE), + ("hStdInput", wintypes.HANDLE), ("hStdOutput", wintypes.HANDLE), + ("hStdError", wintypes.HANDLE)] + + INPUT_MOUSE, INPUT_KEYBOARD = 0, 1 + KEYEVENTF_KEYUP, KEYEVENTF_EXTENDEDKEY, KEYEVENTF_SCANCODE = 0x0002, 0x0001, 0x0008 + MOUSEEVENTF_MOVE, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_VIRTUALDESKTOP = 0x0001, 0x8000, 0x4000 + MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP = 0x0002, 0x0004 + MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP = 0x0008, 0x0010 + MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP = 0x0020, 0x0040 + MOUSEEVENTF_WHEEL, MOUSEEVENTF_HWHEEL = 0x0800, 0x1000 + CF_UNICODETEXT, GMEM_MOVEABLE = 13, 0x0002 + + # 64-bit safety: handle-returning APIs must be declared, else ctypes + # truncates pointers to 32 bits and random access violations follow. + user32.GetClipboardData.restype = ctypes.c_void_p + user32.GetClipboardData.argtypes = [wintypes.UINT] + user32.SetClipboardData.restype = ctypes.c_void_p + user32.SetClipboardData.argtypes = [wintypes.UINT, wintypes.HGLOBAL] + kernel32.GlobalAlloc.restype = wintypes.HGLOBAL + kernel32.GlobalAlloc.argtypes = [wintypes.UINT, ctypes.c_size_t] + kernel32.GlobalLock.restype = ctypes.c_void_p + kernel32.GlobalLock.argtypes = [wintypes.HGLOBAL] + kernel32.GlobalUnlock.argtypes = [wintypes.HGLOBAL] + user32.MapVirtualKeyW.restype = wintypes.UINT + user32.MapVirtualKeyW.argtypes = [wintypes.UINT, wintypes.UINT] + user32.SendInput.argtypes = [wintypes.UINT, ctypes.POINTER(INPUT), ctypes.c_int] + + # --- desktop following (logon screen / UAC secure desktop) --- + DESKTOP_ACCESS = 0x1 | 0x2 | 0x8 | 0x80 | 0x100 # read|createwin|hook|write|switch + user32.OpenInputDesktop.restype = wintypes.HANDLE + user32.OpenInputDesktop.argtypes = [DWORD, wintypes.BOOL, DWORD] + user32.CloseDesktop.argtypes = [wintypes.HANDLE] + user32.SetThreadDesktop.restype = wintypes.BOOL + user32.SetThreadDesktop.argtypes = [wintypes.HANDLE] + user32.GetUserObjectInformationW.argtypes = [wintypes.HANDLE, wintypes.INT, + ctypes.c_void_p, DWORD, ctypes.POINTER(DWORD)] + user32.GetThreadDesktop.restype = wintypes.HANDLE + user32.GetThreadDesktop.argtypes = [DWORD] + kernel32.GetCurrentThreadId.restype = DWORD + user32.LockWorkStation.restype = wintypes.BOOL + + _desktop_tls = threading.local() + + def _desktop_name(hdesk): + if not hdesk: + return None + buf = ctypes.create_unicode_buffer(64) + need = DWORD() + if user32.GetUserObjectInformationW(hdesk, 2, buf, 64, ctypes.byref(need)): # UOI_NAME + return buf.value + return None + + def attach_desktop(): + """Follow the active input desktop so injection reaches the logon screen + and UAC secure desktop (service/SYSTEM mode). CRITICAL: only call + SetThreadDesktop when the input desktop is DIFFERENT from the desktop this + thread is already on. Switching to a fresh handle of the *same* desktop + silently breaks SendInput (mouse/keyboard stop working entirely) - which + is exactly what happens in normal user mode, where the thread already owns + the visible 'Default' desktop.""" + if getattr(_desktop_tls, "name", None) is None: + # where this thread starts (usually 'Default' in a user session) + _desktop_tls.name = _desktop_name( + user32.GetThreadDesktop(kernel32.GetCurrentThreadId())) + h = user32.OpenInputDesktop(0, True, DESKTOP_ACCESS) + if not h: + return _desktop_tls.name + name = _desktop_name(h) + if not name or name == _desktop_tls.name: + user32.CloseDesktop(h) # already on the input desktop: do NOT switch + return _desktop_tls.name + # input desktop changed (user switch / logon screen / UAC): follow it + if user32.SetThreadDesktop(h): + old = getattr(_desktop_tls, "hdesk", None) + if old: + user32.CloseDesktop(old) + _desktop_tls.hdesk = h + _desktop_tls.name = name + else: + user32.CloseDesktop(h) + return _desktop_tls.name + + def desktop_name(): + return getattr(_desktop_tls, "name", None) + + # --- input --- + VK = { + **{f"Key{c}": 0x41 + i for i, c in enumerate("ABCDEFGHIJKLMNOPQRSTUVWXYZ")}, + **{f"Digit{c}": 0x30 + i for i, c in enumerate("0123456789")}, + **{f"Numpad{c}": 0x60 + i for i, c in enumerate("0123456789")}, + **{f"F{n}": 0x70 + n - 1 for n in range(1, 25)}, + "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": 0x0D, "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, + } + EXTENDED_CODES = { + "ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown", "PageUp", "PageDown", + "Home", "End", "Insert", "Delete", "NumLock", "PrintScreen", "Pause", + "ControlRight", "AltRight", "MetaLeft", "MetaRight", "ContextMenu", + "NumpadEnter", "NumpadDivide", "ScrollLock", + } + + user32.SendInput.restype = wintypes.UINT + + def send_inputs(*inputs): + arr = (INPUT * len(inputs))(*inputs) + n = user32.SendInput(len(inputs), arr, ctypes.sizeof(INPUT)) + if n != len(inputs): + # 0 here almost always means a bad cbSize/struct layout (see the _U + # union note) or input blocked by UIPI/secure desktop - make it loud + log.warning("SendInput injected %d/%d (sizeof(INPUT)=%d, err=%d)", + n, len(inputs), ctypes.sizeof(INPUT), ctypes.get_last_error()) + + def _mi(flags, data=0, dx=0, dy=0): + return INPUT(INPUT_MOUSE, _U(mi=MOUSEINPUT(dx, dy, data & 0xFFFFFFFF, flags, 0, 0))) + + # SM_*VIRTUALSCREEN indices - the whole multi-monitor bounding box, read + # cheaply (no mss allocation per mouse move) + SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN = 76, 77 + SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN = 78, 79 + MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP = 0x0080, 0x0100 + XBUTTON1, XBUTTON2 = 0x0001, 0x0002 + + def virtual_desktop_box(): + return {"left": user32.GetSystemMetrics(SM_XVIRTUALSCREEN), + "top": user32.GetSystemMetrics(SM_YVIRTUALSCREEN), + "w": user32.GetSystemMetrics(SM_CXVIRTUALSCREEN), + "h": user32.GetSystemMetrics(SM_CYVIRTUALSCREEN)} + + def inject_mouse_move(x, y, union_box=None): + u = union_box or virtual_desktop_box() + nx = int(round((x - u["left"]) * 65535 / max(1, u["w"] - 1))) + ny = int(round((y - u["top"]) * 65535 / max(1, u["h"] - 1))) + send_inputs(_mi(MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESKTOP, dx=nx, dy=ny)) + + def inject_mouse_buttons(new_b, old_b): + evts = [] + for mask, down, up in ( + (1, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP), + (2, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP), + (4, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP), + ): + if new_b & mask and not old_b & mask: + evts.append(_mi(down)) + if not new_b & mask and old_b & mask: + evts.append(_mi(up)) + # side buttons (back/forward): bit 8 = X1, bit 16 = X2 + for mask, xb in ((8, XBUTTON1), (16, XBUTTON2)): + if new_b & mask and not old_b & mask: + evts.append(_mi(MOUSEEVENTF_XDOWN, data=xb)) + if not new_b & mask and old_b & mask: + evts.append(_mi(MOUSEEVENTF_XUP, data=xb)) + if evts: + send_inputs(*evts) + + def inject_wheel(v, h): + evts = [] + if v: + evts.append(_mi(MOUSEEVENTF_WHEEL, int(v * 120))) + if h: + evts.append(_mi(MOUSEEVENTF_HWHEEL, int(h * 120))) + if evts: + send_inputs(*evts) + + _pressed_keys = set() + + def inject_key(code, down): + vk = VK.get(code) + if not vk: + return + scan = user32.MapVirtualKeyW(vk, 0) + flags = KEYEVENTF_SCANCODE | (KEYEVENTF_EXTENDEDKEY if code in EXTENDED_CODES else 0) + if not down: + flags |= KEYEVENTF_KEYUP + if down: + _pressed_keys.add(code) + else: + _pressed_keys.discard(code) + if scan: + send_inputs(INPUT(INPUT_KEYBOARD, _U(ki=KEYBDINPUT(0, scan, flags, 0, 0)))) + else: + send_inputs(INPUT(INPUT_KEYBOARD, _U(ki=KEYBDINPUT(vk, 0, flags, 0, 0)))) + + def release_all_keys(): + for code in list(_pressed_keys): + inject_key(code, False) + _pressed_keys.clear() + + class InputPump(threading.Thread): + """Dedicated input thread that follows the active desktop before each + injection (works on logon screen / UAC when running as SYSTEM).""" + + def __init__(self, host): + super().__init__(daemon=True) + self.host = host + self.q = queue.Queue(maxsize=2048) + self.buttons = 0 + self._last_attach = 0.0 + + def put(self, item): + try: + self.q.put_nowait(item) + except queue.Full: + # never drop a button/key/wheel event (a lost "up" = a stuck + # key or button). Only mouse *moves* are safe to drop under load; + # evict the oldest queued move to make room for this event. + if item[0] != "move": + try: + for _ in range(len(self.q.queue)): + old = self.q.get_nowait() + if old[0] == "move": + break + except Exception: + pass + try: + self.q.put_nowait(item) + except Exception: + pass + + def run(self): + while True: + item = self.q.get() + try: + # latest-wins for motion: if moves piled up (slow link burst, + # injector stalled), only the newest position matters - never + # skip past a button/key/wheel event though + if item[0] == "move": + while True: + try: + nxt = self.q.queue[0] + except IndexError: + break + if nxt[0] != "move": + break + item = self.q.get_nowait() + # follow the active input desktop (logon screen / UAC) but not + # on every single event - and INSIDE the try so a transient + # OpenInputDesktop failure can never kill the input thread + tnow = time.time() + if item[0] == "key" or tnow - self._last_attach > 0.2: + attach_desktop() + self._last_attach = tnow + if item[0] == "move": + # cheap virtual-desktop bounds (no per-move mss allocation) + inject_mouse_move(item[1], item[2], virtual_desktop_box()) + elif item[0] == "buttons": + inject_mouse_buttons(item[1], self.buttons) + self.buttons = item[1] + elif item[0] == "wheel": + inject_wheel(item[1], item[2]) + elif item[0] == "key": + if self.host.cfg.data["perms"].get("input", True): + inject_key(item[1], item[2]) + elif item[0] == "wake": + send_inputs(_mi(MOUSEEVENTF_MOVE, dx=1, dy=0), + _mi(MOUSEEVENTF_MOVE, dx=-1, dy=0)) + except Exception as e: + log.debug("input pump error: %s", e) + + # --- remote cursor shape (RustDesk-style overlay: sent separately from video + # so idle screens still update the cursor and no re-encode is forced) --- + gdi32 = ctypes.windll.gdi32 + + class CURSORINFO(ctypes.Structure): + _fields_ = [("cbSize", DWORD), ("flags", DWORD), ("hCursor", wintypes.HANDLE), + ("ptScreenPos", wintypes.POINT)] + + class ICONINFO(ctypes.Structure): + _fields_ = [("fIcon", wintypes.BOOL), ("xHotspot", DWORD), ("yHotspot", DWORD), + ("hbmMask", wintypes.HANDLE), ("hbmColor", wintypes.HANDLE)] + + class BITMAP(ctypes.Structure): + _fields_ = [("bmType", LONG), ("bmWidth", LONG), ("bmHeight", LONG), + ("bmWidthBytes", LONG), ("bmPlanes", WORD), ("bmBitsPixel", WORD), + ("bmBits", ctypes.c_void_p)] + + class BITMAPINFOHEADER(ctypes.Structure): + _fields_ = [("biSize", DWORD), ("biWidth", LONG), ("biHeight", LONG), + ("biPlanes", WORD), ("biBitCount", WORD), ("biCompression", DWORD), + ("biSizeImage", DWORD), ("biXPelsPerMeter", LONG), ("biYPelsPerMeter", LONG), + ("biClrUsed", DWORD), ("biClrImportant", DWORD)] + + user32.GetCursorInfo.argtypes = [ctypes.POINTER(CURSORINFO)] + user32.GetIconInfo.argtypes = [wintypes.HANDLE, ctypes.POINTER(ICONINFO)] + CURSOR_SHOWING = 0x0001 + _cursor_png_cache = {} + # shared system cursors -> CSS name. The viewer then uses the OS-native pointer + # (crisp at any DPI), and it still works when GetIconInfo refuses a handle that + # belongs to another session (engine running as SYSTEM in session 0). + _STD_CURSORS = {} + user32.LoadCursorW.restype = ctypes.c_void_p + for _idc, _css in ((32512, "default"), (32513, "text"), (32514, "wait"), + (32515, "crosshair"), (32516, "default"), (32642, "nwse-resize"), + (32643, "nesw-resize"), (32644, "ew-resize"), (32645, "ns-resize"), + (32646, "move"), (32648, "not-allowed"), (32649, "pointer"), + (32650, "progress"), (32651, "help")): + try: + _h = user32.LoadCursorW(None, ctypes.c_void_p(_idc)) # MAKEINTRESOURCE + if _h: + _STD_CURSORS[int(_h)] = _css + except Exception: + pass + + def cursor_state(): + """(hCursor, global_x, global_y) or None. hCursor==0 means hidden.""" + ci = CURSORINFO() + ci.cbSize = ctypes.sizeof(CURSORINFO) + if not user32.GetCursorInfo(ctypes.byref(ci)): + return None + showing = bool(ci.flags & CURSOR_SHOWING) and ci.hCursor + return (int(ci.hCursor) if showing else 0, ci.ptScreenPos.x, ci.ptScreenPos.y) + + def cursor_png(hcursor): + """RGBA PNG (base64) + hotspot for a cursor handle, cached by handle.""" + if hcursor in _cursor_png_cache: + return _cursor_png_cache[hcursor] + import numpy as _np + from PIL import Image as _Img + import io as _io + ii = ICONINFO() + if not user32.GetIconInfo(hcursor, ctypes.byref(ii)): + return None + try: + bm = BITMAP() + src = ii.hbmColor if ii.hbmColor else ii.hbmMask + gdi32.GetObjectW(src, ctypes.sizeof(BITMAP), ctypes.byref(bm)) + w, h = bm.bmWidth, bm.bmHeight + if not ii.hbmColor: + h //= 2 + hdc = user32.GetDC(0) + bi = BITMAPINFOHEADER() + bi.biSize = ctypes.sizeof(BITMAPINFOHEADER) + bi.biWidth = w; bi.biPlanes = 1; bi.biBitCount = 32; bi.biCompression = 0 + + def dib(hbm, rows): + bi.biHeight = -abs(rows) + buf = (ctypes.c_char * (w * abs(rows) * 4))() + gdi32.GetDIBits(hdc, hbm, 0, abs(rows), buf, ctypes.byref(bi), 0) + return _np.frombuffer(bytes(buf), dtype=_np.uint8).reshape(abs(rows), w, 4) + + if ii.hbmColor: + col = dib(ii.hbmColor, h) + if col[:, :, 3].any(): + rgba = col[:, :, [2, 1, 0, 3]].copy() + else: + andm = dib(ii.hbmMask, h)[:, :, 0] + alpha = _np.where(andm == 0, 255, 0).astype(_np.uint8) + rgba = _np.dstack([col[:, :, 2], col[:, :, 1], col[:, :, 0], alpha]) + else: + both = dib(ii.hbmMask, h * 2)[:, :, 0] + AND, XOR = both[:h], both[h:] + alpha = _np.where(AND == 0, 255, 0).astype(_np.uint8) + lum = _np.where((AND == 0) & (XOR > 0), 255, 0).astype(_np.uint8) + rgba = _np.dstack([lum, lum, lum, alpha]) + user32.ReleaseDC(0, hdc) + b = _io.BytesIO() + _Img.fromarray(rgba, "RGBA").save(b, "PNG") + out = {"w": int(w), "h": int(h), "hx": int(ii.xHotspot), "hy": int(ii.yHotspot), + "png": base64.b64encode(b.getvalue()).decode()} + if len(_cursor_png_cache) < 64: + _cursor_png_cache[hcursor] = out + return out + except Exception: + return None + finally: + try: + if ii.hbmColor: + gdi32.DeleteObject(ii.hbmColor) + if ii.hbmMask: + gdi32.DeleteObject(ii.hbmMask) + except Exception: + pass + + # --- clipboard --- + def clip_get(): + if not user32.OpenClipboard(None): + return None + try: + h = user32.GetClipboardData(CF_UNICODETEXT) + if not h: + return None + ptr = kernel32.GlobalLock(h) + if not ptr: + return None + try: + return ctypes.wstring_at(ptr) + finally: + kernel32.GlobalUnlock(h) + finally: + user32.CloseClipboard() + + def clip_set(text): + data = text.encode("utf-16-le") + b"\x00\x00" + if not user32.OpenClipboard(None): + return False + try: + user32.EmptyClipboard() + h = kernel32.GlobalAlloc(GMEM_MOVEABLE, len(data)) + if not h: + return False + ptr = kernel32.GlobalLock(h) + if not ptr: + return False + ctypes.memmove(ptr, data, len(data)) + kernel32.GlobalUnlock(h) + user32.SetClipboardData(CF_UNICODETEXT, h) + return True + finally: + user32.CloseClipboard() + + # --- display power (privacy mode) --- + HWND_BROADCAST, WM_SYSCOMMAND, SC_MONITORPOWER = 0xFFFF, 0x0112, 0xF170 + SMTO_ABORTIFHUNG = 0x0002 + + def monitor_power(state): # -1 on, 2 off + res = DWORD() + user32.SendMessageTimeoutW(HWND_BROADCAST, WM_SYSCOMMAND, SC_MONITORPOWER, + state, SMTO_ABORTIFHUNG, 500, ctypes.byref(res)) + + # --- process manager --- + def process_list(): + out = subprocess.run(["tasklist", "/FO", "CSV", "/NH"], capture_output=True, + text=True, creationflags=0x08000000, timeout=15).stdout + items = [] + for line in out.splitlines(): + parts = [p.strip('"') for p in line.split('","')] + if len(parts) >= 5: + try: + mem = int(parts[4].replace("K", "").replace(",", "").strip()) + except Exception: + mem = 0 + items.append({"name": parts[0], "pid": int(parts[1]), "mem": mem}) + items.sort(key=lambda i: -i["mem"]) + return items[:300] + + def process_kill(pid): + r = subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, + text=True, creationflags=0x08000000, timeout=10) + return r.returncode == 0, (r.stdout or r.stderr).strip() + + def print_file(path): + """Print a document on this machine's default printer (print verb).""" + if not os.path.isfile(path): + return False, "file not found" + try: + r = ctypes.windll.shell32.ShellExecuteW(0, "print", path, None, None, 0) + if r > 32: + return True, "sent to default printer" + return False, f"no print association (code {r})" + except Exception as e: + return False, str(e) + + # --- power / commands --- + def sys_command(action, cmd=None): + CREATE_NO_WINDOW = 0x08000000 + try: + if action == "lock": + user32.LockWorkStation() + return True, "locked" + if action == "logoff": + subprocess.Popen("shutdown /l", shell=True, creationflags=CREATE_NO_WINDOW) + return True, "logging off" + if action == "reboot": + subprocess.Popen("shutdown /r /t 3", shell=True, creationflags=CREATE_NO_WINDOW) + return True, "rebooting" + if action == "shutdown": + subprocess.Popen("shutdown /s /t 3", shell=True, creationflags=CREATE_NO_WINDOW) + return True, "shutting down" + if action == "run" and cmd: + subprocess.Popen(cmd, shell=True, creationflags=CREATE_NO_WINDOW) + return True, "started" + return False, "unknown command" + except Exception as e: + return False, str(e) + + # --- session/process helpers for the service broker --- + kernel32.WTSGetActiveConsoleSessionId.restype = DWORD + kernel32.WTSGetActiveConsoleSessionId.argtypes = [] + kernel32.ProcessIdToSessionId.restype = wintypes.BOOL + kernel32.ProcessIdToSessionId.argtypes = [DWORD, ctypes.POINTER(DWORD)] + kernel32.GetCurrentProcess.restype = wintypes.HANDLE + kernel32.GetCurrentProcess.argtypes = [] + kernel32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT] + + TOKEN_ALL_ACCESS = 0xF01FF + TokenSessionId = 12 + SecurityImpersonation, TokenPrimary = 2, 1 + DETACHED_PROCESS, CREATE_NEW_PROCESS_GROUP = 0x00000008, 0x00000200 + CREATE_UNICODE_ENVIRONMENT, CREATE_BREAKAWAY_FROM_JOB = 0x00000400, 0x01000000 + + advapi32.OpenProcessToken.argtypes = [wintypes.HANDLE, DWORD, ctypes.POINTER(wintypes.HANDLE)] + advapi32.DuplicateTokenEx.argtypes = [wintypes.HANDLE, DWORD, wintypes.LPVOID, + wintypes.INT, wintypes.INT, + ctypes.POINTER(wintypes.HANDLE)] + advapi32.SetTokenInformation.argtypes = [wintypes.HANDLE, wintypes.INT, + wintypes.LPVOID, DWORD] + advapi32.CreateProcessAsUserW.argtypes = [wintypes.HANDLE, wintypes.LPCWSTR, + wintypes.LPWSTR, wintypes.LPVOID, + wintypes.LPVOID, wintypes.BOOL, DWORD, + wintypes.LPVOID, wintypes.LPCWSTR, + ctypes.POINTER(STARTUPINFOW), + ctypes.POINTER(PROCESS_INFORMATION)] + userenv = ctypes.windll.userenv + userenv.CreateEnvironmentBlock.argtypes = [ctypes.POINTER(wintypes.LPVOID), + wintypes.HANDLE, wintypes.BOOL] + userenv.DestroyEnvironmentBlock.argtypes = [wintypes.LPVOID] + + def console_session_id(): + return kernel32.WTSGetActiveConsoleSessionId() + + def my_session_id(): + sid = DWORD(0) + kernel32.ProcessIdToSessionId(kernel32.GetCurrentProcessId(), ctypes.byref(sid)) + return sid.value + + def spawn_in_console_session(cmdline, session_id, desktop="Winsta0\\Default"): + """Spawn a detached SYSTEM process inside the given console session + (needs SeTcbPrivilege, i.e. the caller runs as SYSTEM).""" + htok = wintypes.HANDLE() + if not advapi32.OpenProcessToken(kernel32.GetCurrentProcess(), + TOKEN_ALL_ACCESS, ctypes.byref(htok)): + raise OSError("OpenProcessToken failed") + hdup = wintypes.HANDLE() + if not advapi32.DuplicateTokenEx(htok, TOKEN_ALL_ACCESS, None, + SecurityImpersonation, TokenPrimary, + ctypes.byref(hdup)): + raise OSError("DuplicateTokenEx failed") + sid = DWORD(session_id) + if not advapi32.SetTokenInformation(hdup, TokenSessionId, + ctypes.byref(sid), ctypes.sizeof(sid)): + raise OSError("SetTokenInformation(TokenSessionId) failed") + env = wintypes.LPVOID() + have_env = userenv.CreateEnvironmentBlock(ctypes.byref(env), hdup, False) + si = STARTUPINFOW() + si.cb = ctypes.sizeof(si) + si.lpDesktop = desktop + pi = PROCESS_INFORMATION() + flags = DETACHED_PROCESS | CREATE_UNICODE_ENVIRONMENT | CREATE_BREAKAWAY_FROM_JOB + cmd = ctypes.create_unicode_buffer(cmdline) + if cmdline.startswith('"'): + app = cmdline[1:cmdline.index('"', 1)] + else: + app = cmdline.split(" ", 1)[0] + ok = advapi32.CreateProcessAsUserW( + hdup, app, cmd, None, None, False, flags, + env.value if have_env else None, None, ctypes.byref(si), ctypes.byref(pi)) + if have_env: + userenv.DestroyEnvironmentBlock(env) + if not ok: + raise OSError(f"CreateProcessAsUser failed: {ctypes.GetLastError()}") + return pi.hProcess, pi.dwProcessId + + def is_elevated(): + try: + return bool(ctypes.windll.shell32.IsUserAnAdmin()) + except Exception: + return False + +elif sys.platform == "darwin": + # ---------------- macOS (Apple Silicon native, pure ctypes) ---------------- + import ctypes.util + import platform + + _cg = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreGraphics")) + _cf = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreFoundation")) + + class CGPoint(ctypes.Structure): + _fields_ = [("x", ctypes.c_double), ("y", ctypes.c_double)] + + class _Size(ctypes.Structure): + _fields_ = [("width", ctypes.c_double), ("height", ctypes.c_double)] + + class CGRect2(ctypes.Structure): + _fields_ = [("origin", CGPoint), ("size", _Size)] + + _cg.CGEventCreateMouseEvent.restype = ctypes.c_void_p + _cg.CGEventCreateMouseEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint32, CGPoint, ctypes.c_uint32] + _cg.CGEventCreateScrollWheelEvent.restype = ctypes.c_void_p + _cg.CGEventCreateScrollWheelEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_int32] + _cg.CGEventCreateKeyboardEvent.restype = ctypes.c_void_p + _cg.CGEventCreateKeyboardEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint16, ctypes.c_bool] + _cg.CGEventPost.argtypes = [ctypes.c_uint32, ctypes.c_void_p] + _cg.CGEventSetLocation.argtypes = [ctypes.c_void_p, CGPoint] + _cg.CFRelease.argtypes = [ctypes.c_void_p] + _cg.CGMainDisplayID.restype = ctypes.c_void_p + _cg.CGDisplayBounds.restype = CGRect2 + _cg.CGDisplayBounds.argtypes = [ctypes.c_void_p] + _cg.CGDisplayPixelsWide.restype = ctypes.c_uint + _cg.CGDisplayPixelsWide.argtypes = [ctypes.c_void_p] + + kCGHIDEventTap = 0 + kCGEventMouseMoved, kCGEventLeftDown, kCGEventLeftUp = 5, 1, 2 + kCGEventRightDown, kCGEventRightUp = 3, 4 + kCGEventOtherDown, kCGEventOtherUp = 25, 26 + kCGScrollEventUnitLine = 1 + # US-ANSI virtual keycodes for our JS KeyboardEvent.code names + VK_MAC = { + **{f"Key{c}": v for c, v in zip( + "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + [0x00, 0x0B, 0x08, 0x02, 0x0E, 0x03, 0x05, 0x04, 0x22, 0x26, + 0x28, 0x25, 0x2E, 0x2D, 0x1F, 0x23, 0x0C, 0x0F, 0x01, 0x11, + 0x20, 0x09, 0x0D, 0x07, 0x10, 0x06])}, + **{f"Digit{c}": v for c, v in zip( + "1234567890", [0x12, 0x13, 0x14, 0x15, 0x17, 0x16, 0x1A, 0x1C, 0x19, 0x1D])}, + **{f"F{n}": v for n, v in zip(range(1, 13), + [0x7A, 0x78, 0x63, 0x76, 0x60, 0x61, 0x62, 0x64, 0x65, 0x6D, 0x67, 0x6F])}, + "Backquote": 0x32, "Minus": 0x1B, "Equal": 0x18, "BracketLeft": 0x21, + "BracketRight": 0x1E, "Backslash": 0x2A, "Semicolon": 0x29, "Quote": 0x27, + "Comma": 0x2B, "Period": 0x2F, "Slash": 0x2C, + "CapsLock": 0x39, "Space": 0x31, "Enter": 0x24, "NumpadEnter": 0x24, + "Backspace": 0x33, "Tab": 0x30, "Escape": 0x35, + "Delete": 0x75, "Insert": 0, "Home": 0x73, "End": 0x77, + "PageUp": 0x74, "PageDown": 0x79, + "ArrowLeft": 0x7B, "ArrowUp": 0x7E, "ArrowRight": 0x7C, "ArrowDown": 0x7D, + "ControlLeft": 0x3B, "ControlRight": 0x3E, "AltLeft": 0x3A, "AltRight": 0x3D, + "ShiftLeft": 0x38, "ShiftRight": 0x3C, + "MetaLeft": 0x37, "MetaRight": 0x37, "ContextMenu": 0, + "Numpad0": 0x52, "Numpad1": 0x53, "Numpad2": 0x54, "Numpad3": 0x55, + "Numpad4": 0x56, "Numpad5": 0x57, "Numpad6": 0x58, "Numpad7": 0x59, + "Numpad8": 0x5B, "Numpad9": 0x5C, + } + + _mac_tls = threading.local() + + def _display_info(): + """[(scale, origin_x, origin_y, point_w, point_h)] per active display.""" + if getattr(_mac_tls, "displays", None): + return _mac_tls.displays + infos = [] + count = 32 + arr = (ctypes.c_void_p * count)() + actual = ctypes.c_uint32() + if _cg.CGGetActiveDisplayList(count, arr, ctypes.byref(actual)) == 0: + for i in range(actual.value): + did = arr[i] + bounds = _cg.CGDisplayBounds(did) + pw = _cg.CGDisplayPixelsWide(did) + scale = (pw / bounds.size.width) if bounds.size.width else 1.0 + infos.append((scale, bounds.origin.x, bounds.origin.y, + bounds.size.width, bounds.size.height)) + _mac_tls.displays = infos or [(1.0, 0.0, 0.0, 1920.0, 1080.0)] + return _mac_tls.displays + + def pixel_to_global(x, y): + """Map a physical-pixel coordinate on the virtual desktop to global points.""" + for scale, ox, oy, pw, ph in _display_info(): + if x < pw * scale and y < ph * scale: + return CGPoint(ox + x / scale, oy + y / scale) + scale, ox, oy, pw, ph = _display_info()[0] + return CGPoint(ox + x / scale, oy + y / scale) + + def attach_desktop(): + return "Default" + + def desktop_name(): + return "Default" + + def _post_event(ev): + _cg.CGEventPost(kCGHIDEventTap, ev) + _cg.CFRelease(ev) + + def _mouse_event(etype, button, x, y): + return _cg.CGEventCreateMouseEvent(None, etype, pixel_to_global(x, y), button) + + def send_inputs(*inputs): # parity stub (mac uses discrete events) + pass + + class InputPump(threading.Thread): + def __init__(self, host): + super().__init__(daemon=True) + self.host = host + self.q = queue.Queue(maxsize=512) + self.buttons = 0 + + def put(self, item): + try: + self.q.put_nowait(item) + except Exception: + pass + + def run(self): + while True: + item = self.q.get() + try: + if item[0] == "move": + _post_event(_mouse_event(kCGEventMouseMoved, 0, item[1], item[2])) + elif item[0] == "buttons": + b = item[1] + x, y = self._pos() + for mask, (down_t, up_t, btn) in ( + (1, (kCGEventLeftDown, kCGEventLeftUp, 0)), + (2, (kCGEventRightDown, kCGEventRightUp, 1)), + (4, (kCGEventOtherDown, kCGEventOtherUp, 2))): + if b & mask and not self.buttons & mask: + _post_event(_mouse_event(down_t, btn, x, y)) + if not b & mask and self.buttons & mask: + _post_event(_mouse_event(up_t, btn, x, y)) + self.buttons = b + elif item[0] == "wheel": + v, h = item[1], item[2] + ev = _cg.CGEventCreateScrollWheelEvent(None, kCGScrollEventUnitLine, 2, int(v), int(h)) + _post_event(ev) + elif item[0] == "key": + if self.host.cfg.data["perms"].get("input", True): + kc = VK_MAC.get(item[1]) + if kc: + _post_event(_cg.CGEventCreateKeyboardEvent(None, kc, item[2])) + elif item[0] == "wake": + x, y = self._pos() + _post_event(_mouse_event(kCGEventMouseMoved, 0, x + 1, y)) + except Exception as e: + log.debug("mac input error: %s", e) + + def _pos(self): + return getattr(self.host, "_last_xy", (0, 0)) + + def clip_get(): + r = subprocess.run(["/usr/bin/pbpaste"], capture_output=True, text=True, timeout=5) + return r.stdout if r.returncode == 0 else None + + def clip_set(text): + r = subprocess.run(["/usr/bin/pbcopy"], input=text, text=True, timeout=5) + return r.returncode == 0 + + def monitor_power(state): # -1 ignored; 2 = display sleep + if state == 2: + subprocess.Popen(["/usr/bin/pmset", "displaysleepnow"]) + + def process_list(): + r = subprocess.run(["ps", "-axo", "pid=,rss=,comm="], capture_output=True, text=True, timeout=15) + items = [] + for line in r.stdout.splitlines(): + parts = line.strip().split(None, 2) + if len(parts) == 3 and parts[0].isdigit(): + items.append({"pid": int(parts[0]), "mem": int(parts[1]) * 1024, + "name": os.path.basename(parts[2])}) + items.sort(key=lambda i: -i["mem"]) + return items[:300] + + def process_kill(pid): + try: + os.kill(pid, 9) + return True, "killed" + except Exception as e: + return False, str(e) + + def print_file(path): + if not os.path.isfile(path): + return False, "file not found" + r = subprocess.run(["/usr/bin/lp", path], capture_output=True, text=True, timeout=10) + return r.returncode == 0, (r.stdout or r.stderr).strip() or "queued" + + def sys_command(action, cmd=None): + try: + if action == "lock": + subprocess.run(["osascript", "-e", + 'tell application "System Events" to keystroke "q" using {command down, control down}'], + timeout=5) + return True, "locked" + if action == "logoff": + return False, "use lock or reboot" + if action == "reboot": + subprocess.Popen(["osascript", "-e", 'tell app "System Events" to restart']) + return True, "rebooting" + if action == "shutdown": + subprocess.Popen(["osascript", "-e", 'tell app "System Events" to shut down']) + return True, "shutting down" + if action == "run" and cmd: + subprocess.Popen(cmd, shell=True) + return True, "started" + return False, "unknown command" + except Exception as e: + return False, str(e) + + def console_session_id(): + return 1 + + def my_session_id(): + return 1 + + def spawn_in_console_session(cmdline, sid, desktop=None): + raise OSError("macOS uses LaunchAgent, not a session broker") + + def is_elevated(): + return os.geteuid() == 0 + +else: # ---- other non-Windows stubs ---- + def attach_desktop(): return "Default" + def desktop_name(): return "Default" + def clip_get(): return None + def clip_set(t): return False + def release_all_keys(): pass + def monitor_power(state): pass + def process_list(): return [] + def process_kill(pid): return False, "unsupported" + def print_file(path): return False, "unsupported" + def sys_command(a, c=None): return False, "Windows only" + def console_session_id(): return 0 + def my_session_id(): return 0 + def spawn_in_console_session(cmdline, sid, desktop=None): raise OSError("Windows only") + def is_elevated(): return os.geteuid() == 0 + + +# ================================================================ session / host + +class Session: + def __init__(self, host, sid): + self.host = host + self.sid = sid + self.active = False + self.e2e = None + self.worker = None + self.audio = None + self.stop_evt = threading.Event() + self.mon = 1 + self.fps = 30 + self.bitrate = 6000 + self.scale = 1.0 + self.apply_evt = threading.Event() + self.uploads = {} + self.monitors = [] + + +class Host: + """The desktop worker. Talks over exactly one of: + link='server' direct outbound websocket to the NetDesk server (simple mode) + link='broker' websocket to the local service broker (service mode) + link='direct' a direct viewer websocket (LAN / port-forward)""" + + def __init__(self, cfg: Config, args, link="server"): + self.cfg = cfg + self.args = args + self.link = link + self.loop = None + self.session: Session | None = None + self._current_sid = None + # WebRTC P2P fast path (video+input peer-to-peer; relay = fallback) + self.rtc_pc = None + self.rtc_media = None + self.rtc_ctl = None + self.rtc_active = False + self.ws_server = None # server link socket (simple mode) + self.ws_broker = None # broker socket (service mode) + self.ws_direct = None # direct viewer socket + self._pending = None + self._mouse_b = 0 + self.clip_last_sent = None + self.clip_thread = None + self.pump = None + self.privacy = False + self.reg_error = "" + self.account = "" + self._tunnels = {} # tid -> (reader, writer) + self.audio_available = False + try: + import pyaudiowpatch # noqa: F401 + self.audio_available = (not getattr(args, "no_audio", False) + and cfg.data["perms"].get("audio", True)) + except Exception: + log.info("PyAudioWPatch not installed - audio disabled (pip install PyAudioWPatch)") + if IS_WIN: + self.pump = InputPump(self) + self.pump.start() + + # ---------------- outgoing (thread-safe, non-blocking) ---------------- + def _post(self, coro): + def _drain(fut): + try: + fut.exception() + except Exception: + pass + try: + asyncio.run_coroutine_threadsafe(coro, self.loop).add_done_callback(_drain) + except RuntimeError: + pass + + def _sock(self): + if self.link == "direct": + return self.ws_direct + if self.link == "broker": + return self.ws_broker + return self.ws_server + + async def _send_str(self, ws, data): + if ws is not None and not ws.closed: + await ws.send_str(data) + + # ---------------- WebRTC P2P (video + input take the direct UDP path) ---- + async def rtc_answer(self, m): + """Viewer sent an SDP offer over the relay: answer it. On success the + media/ctl DataChannels open and video+input flow peer-to-peer.""" + try: + await self.rtc_close() + # ICE servers come with the offer (server-minted, incl. the nearest + # TURN relay) so the host never needs TURN credentials itself. + ice = [] + for srv in (m.get("ice") or []): + try: + urls = srv.get("urls") + urls = urls if isinstance(urls, list) else [urls] + # aiortc uses ONE turn url: prefer UDP (lowest latency) + urls.sort(key=lambda u: (0 if "transport=udp" in u else 1)) + ice.append(RTCIceServer(urls=urls, username=srv.get("username"), + credential=srv.get("credential"))) + except Exception: + pass + if not ice: + ice = [RTCIceServer(urls=["stun:stun.cloudflare.com:3478"])] + pc = RTCPeerConnection(RTCConfiguration(iceServers=ice)) + self.rtc_pc = pc + self._rtc_turn = any("turn" in str(s.urls) for s in ice) + + @pc.on("datachannel") + def on_dc(ch): + if ch.label == "media": + self.rtc_media = ch + elif ch.label in ("ctl", "move"): + # "ctl" = ordered (clicks/keys); "move" = unordered+unreliable + # mouse motion so a lost packet never stalls the pointer + if ch.label == "ctl": + self.rtc_ctl = ch + else: + # tell the viewer we understand the motion channel; older + # engines stay silent and the viewer keeps motion on "ctl" + try: + ch.send(json.dumps({"t": "move.ok"})) + except Exception: + pass + + @ch.on("message") + def on_ctl(msg): + # input JSON arrives here instead of the relay socket + try: + if isinstance(msg, str): + self.on_message(json.loads(msg)) + except Exception: + pass + + @ch.on("close") + def on_close(): + if self.rtc_active: + log.info("rtc: channel closed - back to relay") + self.rtc_active = False + self._rtc_check_open() + + @ch.on("open") + def on_open(): + self._rtc_check_open() + + @pc.on("connectionstatechange") + async def on_state(): + if pc.connectionState in ("failed", "closed", "disconnected"): + if self.rtc_active: + log.info("rtc: %s - back to relay", pc.connectionState) + self.rtc_active = False + + await pc.setRemoteDescription( + RTCSessionDescription(sdp=m.get("sdp", ""), type="offer")) + answer = await pc.createAnswer() + await pc.setLocalDescription(answer) # gathers ICE (non-trickle) + sid = self.session.sid if self.session else None + self.send_json({"t": "rtc.answer", "sid": sid, + "sdp": pc.localDescription.sdp}) + log.info("rtc: sent answer, waiting for P2P channels") + except Exception as e: + log.warning("rtc setup failed: %s", e) + try: + self.send_json({"t": "rtc.unavailable", + "sid": self.session.sid if self.session else None, + "msg": str(e)[:120]}) + except Exception: + pass + + def _rtc_check_open(self): + mch, cch = self.rtc_media, self.rtc_ctl + if (mch is not None and cch is not None + and mch.readyState == "open" and cch.readyState == "open" + and not self.rtc_active): + self.rtc_active = True + route = "unknown" + try: # which tier won: direct P2P or the TURN relay? + pair = self.rtc_pc.sctp.transport.transport._connection._nominated + for p in pair.values(): + route = ("TURN relay" if p.local_candidate.type == "relay" + or p.remote_candidate.type == "relay" else "DIRECT P2P") + except Exception: + pass + log.info("rtc: ACTIVE via %s - video and input bypass the WSS relay", route) + + async def rtc_close(self): + self.rtc_active = False + pc, self.rtc_pc = self.rtc_pc, None + self.rtc_media = self.rtc_ctl = None + if pc is not None: + try: + await pc.close() + except Exception: + pass + + def send_json(self, m: dict): + if self._current_sid and "sid" not in m: + m["sid"] = self._current_sid + self._post(self._send_str(self._sock(), json.dumps(m))) + + def send_binary(self, payload: bytes): + sess = self.session + if not sess: + return + self._post(self._send_bin(sess, payload)) + + def send_raw(self, payload: bytes): + """Send without session gating/E2E (used by TCP tunnels: the payload + is TLS-protected to the server, which relays it to the viewer).""" + ws = self._sock() + async def _go(): + if ws is not None and not ws.closed: + await ws.send_bytes(payload) + self._post(_go()) + + async def _send_bin(self, sess: Session, payload: bytes): + if sess.e2e: + nonce = secrets.token_bytes(12) + payload = nonce + sess.e2e.encrypt(nonce, payload, None) + # P2P fast path: same encrypted bytes, direct UDP instead of the relay. + # If the channel is congested we DROP the frame (fresh beats late); + # if it's gone we fall through to the relay socket seamlessly. + if self.rtc_active: + ch = self.rtc_media + try: + if ch is not None and ch.readyState == "open": + if ch.bufferedAmount < 262144: + ch.send(payload) + return + except Exception: + self.rtc_active = False + ws = self._sock() + if ws is not None and not ws.closed: + await ws.send_bytes(payload) + + # ---------------- auth ---------------- + def make_challenge(self, sid): + nonce = secrets.token_hex(16) + self._current_sid = sid + self._pending = {"sid": sid, "nonce": nonce, + "priv": ec.generate_private_key(ec.SECP256R1())} + pub_b = self._pending["priv"].public_key().public_bytes( + serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint) + pub_b64 = base64.b64encode(pub_b).decode() + self._pending["pub_b64"] = pub_b64 # for channel-bound proof verification + return { + "t": "session.challenge", "sid": sid, + "salt": base64.b64encode(bytes.fromhex(self.cfg.data["salt"])).decode(), + "iters": self.cfg.data["iters"], + "nonce": nonce, "e2e": True, + "totp": bool(self.cfg.data.get("totp")), + "pub": pub_b64, + } + + def finish_auth(self, m): + p = getattr(self, "_pending", None) + sid = m.get("sid") + if not p or p["sid"] != sid: + self.send_json({"t": "session.deny", "reason": "no pending session"}) + return + totp_secret = self.cfg.data.get("totp") + if totp_secret and not totp_verify(totp_secret, m.get("totp", "")): + log.warning("bad 2FA code (sid=%s)", sid) + time.sleep(0.4) + self.send_json({"t": "session.deny", "reason": "bad 2FA code"}) + self._pending = None + self._current_sid = None + return + # Channel-bound proof (MITM hardening): if the viewer sent proof_bind, + # it must verify against OUR ephemeral pubkey — a relay that swapped the + # pub in the challenge can't satisfy it. Absent → legacy viewer, fall + # back to the plain proof (no regression). + pbind = m.get("proof_bind") + if pbind and not self.cfg.check_proof_bound(p["nonce"], p.get("pub_b64", ""), pbind): + log.warning("channel-bound proof MISMATCH — possible relay key-swap (sid=%s)", sid) + time.sleep(0.4) + self.send_json({"t": "session.deny", "reason": "key verification failed"}) + self._pending = None + self._current_sid = None + return + if not self.cfg.check_proof(p["nonce"], m.get("proof", "")): + log.warning("bad password attempt (sid=%s)", sid) + time.sleep(0.4) + self.send_json({"t": "session.deny", "reason": "wrong password"}) + self._pending = None + self._current_sid = None + return + aesgcm = None + viewer_pub_b = m.get("pub") + if viewer_pub_b: + try: + peer = ec.EllipticCurvePublicKey.from_encoded_point( + ec.SECP256R1(), base64.b64decode(viewer_pub_b)) + shared = p["priv"].exchange(ec.ECDH(), peer) + aesgcm = AESGCM(hashlib.sha256(shared + b"e2e").digest()) + except Exception as e: + log.warning("E2E negotiation failed (%s); transport-only", e) + self._pending = None + + sess = Session(self, sid) + sess.e2e = aesgcm + try: + with mss.mss() as sct: + sess.monitors = [ + {"i": i, "l": mon["left"], "t": mon["top"], "w": mon["width"], "h": mon["height"]} + for i, mon in enumerate(sct.monitors[1:], start=1) + ] + except Exception: + sess.monitors = [] + mon = next((mm for mm in sess.monitors if mm["i"] == sess.mon), None) + info = { + "mon": sess.mon, "monitors": sess.monitors, + "w": mon["w"] if mon else 0, "h": mon["h"] if mon else 0, + "fps": sess.fps, "bitrate": sess.bitrate, + "perms": self.cfg.data["perms"], "lock_on_end": self.cfg.data.get("lock_on_end", False), + "e2e": aesgcm is not None, + "audio": self.audio_available, + "host_name": self.cfg.data["name"], + "version": VERSION, "os": sys.platform, + "logon_screen": (desktop_name() or "").lower().startswith("winlogon"), + } + self.session = sess + sess.active = True + if self.cfg.data.get("privacy_on_session") and self.cfg.data.get("allow_display_power"): + # silent/privacy session: blank the physical monitor instead of + # waking the displays - nothing appears on the host's screen + self.privacy = True + self._display_power(2) + elif self.pump: + self.pump.put(("wake",)) # wake the displays for the viewer + info["privacy_active"] = self.privacy + self.send_json({"t": "session.accept", "info": info}) + sess.worker = threading.Thread(target=capture_worker, args=(sess,), daemon=True) + sess.worker.start() + if self.audio_available: + sess.audio = threading.Thread(target=audio_worker, args=(sess,), daemon=True) + sess.audio.start() + if self.clip_thread is None or not self.clip_thread.is_alive(): + self.clip_thread = threading.Thread(target=clipboard_worker, args=(self,), daemon=True) + self.clip_thread.start() + log.info("session %s accepted (e2e=%s)", sid, aesgcm is not None) + + def end_session(self, reason): + sess = self.session + if not sess: + return + sess.active = False + sess.stop_evt.set() + for fh in sess.uploads.values(): + try: fh.close() + except Exception: pass + sess.uploads.clear() + release_all_keys() + if RTC_AVAILABLE and self.rtc_pc is not None: + try: + if self.loop is not None: + asyncio.run_coroutine_threadsafe(self.rtc_close(), self.loop) + except Exception: + pass + if self.privacy: + monitor_power(-1) + self.privacy = False + if getattr(self, "_wp_hidden", False): + # never leave the desk without its wallpaper + try: + wallpaper_set(getattr(self, "_wp_orig", "") or "") + except Exception: + pass + self._wp_hidden = False + if getattr(self, "_input_blocked", False): + try: + ctypes.windll.user32.BlockInput(False) + except Exception: + pass + self._input_blocked = False + mr = getattr(self, "_mode_restore", None) + if mr: + # never leave the remote PC in a resolution the viewer picked + try: + set_display_mode(mr["name"], mr["w"], mr["h"]) + except Exception: + pass + self._mode_restore = None + if self.cfg.data.get("lock_on_end"): + try: + sys_command("lock") + except Exception: + pass + try: + self.send_json({"t": "session.end", "sid": sess.sid, "reason": reason}) + except Exception: + pass + self.session = None + self._current_sid = None + log.info("session ended: %s", reason) + + def _display_power(self, state): + """Hardware display control. Sleeping the display requires the user's + explicit opt-in (allow_display_power); waking it is always permitted.""" + if state == 2 and not self.cfg.data.get("allow_display_power"): + log.info("display sleep requested but allow_display_power is off - ignored") + return + threading.Thread(target=monitor_power, args=(state,), daemon=True).start() + if state == 2: + self._start_privacy_watchdog() + + def _privacy_watchdog(self): + """Safety net: if privacy (monitor off) is ever engaged with no active + session - crash, race, stale config - force the monitor back on.""" + while self.privacy: + time.sleep(2) + sess = self.session + if self.privacy and (sess is None or not sess.active): + self.privacy = False + monitor_power(-1) + log.info("privacy watchdog: restored monitor (no active session)") + + def _start_privacy_watchdog(self): + threading.Thread(target=self._privacy_watchdog, daemon=True).start() + + # ---------------- messages ---------------- + def on_message(self, m: dict): + t = m.get("t") + sess = self.session + + if t == "wol": + # another computer on this account asked us to wake a LAN neighbour + threading.Thread(target=send_wol, args=(str(m.get("mac", "")),), + daemon=True).start() + return + + if t == "session.begin": + if sess and sess.active: + self.send_json({"t": "session.deny", "sid": m.get("sid"), "reason": "host busy"}) + return + self.cfg.reload() # password / 2FA changed in the app apply immediately + allow = (self.cfg.data.get("allow_ips") or "").strip() + peer = str(m.get("peer") or "") + if allow and peer and not ip_allowed(peer, allow): + log.warning("session denied: viewer %s is not on the IP allowlist", peer) + self.send_json({"t": "session.deny", "sid": m.get("sid"), + "reason": "this computer only accepts connections from allowed IPs"}) + return + self.send_json(self.make_challenge(m.get("sid") or uuid.uuid4().hex)) + return + if t == "session.auth": + self.finish_auth(m) + return + + # TCP tunnels live outside the session (server-gated, portal-authed) + if t == "tunnel.open": + self.tunnel_open(int(m.get("tid", 0)), m.get("thost", "127.0.0.1"), + int(m.get("port", 0))) + return + if t == "tunnel.close": + self.tunnel_close(int(m.get("tid", 0))) + return + + if not sess or m.get("sid") != sess.sid: + return + + if t == "session.end": + self.end_session("viewer left") + elif t == "ping": + self.send_json({"t": "pong", "c": m.get("c")}) + elif t == "set.quality": + sess.fps = int(m.get("fps", 30)) + sess.bitrate = int(m.get("bitrate", 6000)) + sess.scale = float(m.get("scale", 1.0)) + sess.apply_evt.set() + elif t == "monitor.switch": + try: + sess.mon = int(m.get("mon", 1)) + sess.apply_evt.set() + except Exception: + pass + elif t == "keyframe": + sess.force_idr = True + elif t == "rtc.offer": + if RTC_AVAILABLE: + asyncio.ensure_future(self.rtc_answer(m)) + else: + self.send_json({"t": "rtc.unavailable", "sid": sess.sid}) + elif t == "i.mouse": + self.do_mouse(m) + elif t == "i.key": + if self.pump: + self.pump.put(("key", m.get("c", ""), bool(m.get("d")))) + elif t == "clip.set": + if self.cfg.data["perms"].get("clipboard", True) and m.get("origin") == "viewer": + clip_set(m.get("text", "")[:1024 * 1024]) + self.clip_last_sent = m.get("text", "") + elif t == "chat": + log.info("chat from viewer: %s", m.get("text", "")[:200]) + elif t == "set.cfg": + for key in ("lock_on_end", "privacy_on_session", "allow_display_power"): + if key in m: + self.cfg.data[key] = bool(m[key]) + self.cfg.save() + self.send_json({"t": "sys.res", "ok": True, "msg": "config saved"}) + elif t == "file.roots": + self.file_roots() + elif t == "file.list": + self.file_list(m.get("path", "")) + elif t == "file.get": + self.file_get(m.get("path"), int(m.get("fid", 0))) + elif t == "file.put": + self.file_put_begin(m.get("path"), int(m.get("fid", 0)), int(m.get("size", 0))) + elif t == "file.mkdir": + self.file_simple(m.get("path"), lambda p: os.makedirs(p, exist_ok=True)) + elif t == "file.del": + self.file_simple(m.get("path"), + (lambda p: shutil.rmtree(p)) if m.get("dir") else (lambda p: os.remove(p))) + elif t == "file.ren": + self.file_rename(m.get("src"), m.get("dst")) + elif t == "sys.cmd": + self.do_sys(m) + + def _session_device(self): + """GDI display device of the monitor the current session is viewing. + Uses the geometry the capture worker already cached on the session - + NEVER calls mss here (a second mss in the event-loop thread crashes + the process on Windows via a GDI conflict with the capture thread).""" + sess = self.session + mi = sess.mon if sess else 1 + geo = None + for mm in (getattr(sess, "monitors", None) or []): + if mm["i"] == mi: + geo = {"left": mm["l"], "top": mm["t"]} + break + if geo is None: + # fall back to the primary display device + devs = _display_devices() + if not devs: + raise RuntimeError("no display device found") + return devs[0] + dev = _device_for_monitor(geo) + if not dev: + raise RuntimeError("no display device found") + return dev + + def do_sys(self, m): + # A system command must NEVER take down the engine or the session. + try: + self._do_sys(m) + except Exception as e: + log.warning("sys command %r failed: %s", m.get("a"), e) + try: + self.send_json({"t": "sys.res", "ok": False, + "kind": m.get("a"), "msg": str(e)[:200]}) + except Exception: + pass + + def _do_sys(self, m): + a = m.get("a", "") + if a == "processes": + try: + self.send_json({"t": "sys.res", "ok": True, "kind": "processes", + "items": process_list()}) + except Exception as e: + self.send_json({"t": "sys.res", "ok": False, "msg": str(e)}) + elif a == "kill": + ok, msg = process_kill(int(m.get("pid", 0))) + self.send_json({"t": "sys.res", "ok": ok, "msg": msg}) + elif a == "privacy": + if not self.cfg.data.get("allow_display_power"): + self.send_json({"t": "sys.res", "ok": False, "kind": "privacy", + "msg": "display control disabled (enable in the NetDesk app)"}) + return + self.privacy = not self.privacy + self._display_power(2 if self.privacy else -1) + self.send_json({"t": "sys.res", "ok": True, "kind": "privacy", "on": self.privacy, + "msg": "monitor off" if self.privacy else "monitor on"}) + elif a == "run": + ok, msg = sys_command("run", m.get("cmd")) + self.send_json({"t": "sys.res", "ok": ok, "msg": msg}) + elif a == "print": + path = m.get("path", "") + ok, msg = print_file(path) + self.send_json({"t": "sys.res", "ok": ok, "msg": msg}) + elif a == "modes": + try: + dev = self._session_device() + self.send_json({"t": "sys.res", "ok": True, "kind": "modes", + "items": display_modes(dev["name"]), + "current": [dev["w"], dev["h"]]}) + except Exception as e: + self.send_json({"t": "sys.res", "ok": False, "kind": "modes", + "msg": str(e)[:200]}) + elif a == "setmode": + try: + dev = self._session_device() + # remember the ORIGINAL resolution so we restore it at + # session end - never leave the remote PC in a changed mode + if not getattr(self, "_mode_restore", None): + self._mode_restore = {"name": dev["name"], "w": dev["w"], "h": dev["h"]} + ok, msg = set_display_mode(dev["name"], int(m.get("w", 0)), int(m.get("h", 0))) + if ok and self.session: + self.session.force_idr = True + self.send_json({"t": "sys.res", "ok": ok, "kind": "setmode", "msg": msg}) + except Exception as e: + self.send_json({"t": "sys.res", "ok": False, "kind": "setmode", + "msg": str(e)[:200]}) + elif a == "wallpaper": + try: + if getattr(self, "_wp_hidden", False): + wallpaper_set(getattr(self, "_wp_orig", "") or "") + self._wp_hidden = False + else: + self._wp_orig = wallpaper_get() + wallpaper_set("") + self._wp_hidden = True + self.send_json({"t": "sys.res", "ok": True, "kind": "wallpaper", + "on": self._wp_hidden, + "msg": "wallpaper hidden" if self._wp_hidden else "wallpaper restored"}) + except Exception as e: + self.send_json({"t": "sys.res", "ok": False, "kind": "wallpaper", + "msg": str(e)[:200]}) + elif a == "cad": + ok, msg = send_cad() + self.send_json({"t": "sys.res", "ok": ok, "kind": "cad", "msg": msg}) + elif a == "block_input": + # RustDesk parity: freeze the remote user's own mouse/keyboard while + # you control. Needs an elevated agent; auto-releases at session end + # (and Windows releases it if the process ever dies). + want = not getattr(self, "_input_blocked", False) + ok = bool(ctypes.windll.user32.BlockInput(want)) + if ok: + self._input_blocked = want + msg = "remote user's input BLOCKED" if want else "remote user's input released" + else: + msg = ("blocking needs the elevated service agent " + "(install the boot-time service)") + self.send_json({"t": "sys.res", "ok": ok, "kind": "block_input", + "on": getattr(self, "_input_blocked", False), "msg": msg}) + else: + ok, msg = sys_command(a) + self.send_json({"t": "sys.res", "ok": ok, "msg": msg}) + + def on_binary(self, data: bytes): + if data and data[0] == 0xF3 and len(data) >= 5: + # tunnel payload from the server (not session-E2E encrypted) + tid = int.from_bytes(data[1:5], "little") + self.on_tunnel_data(tid, data[5:]) + return + sess = self.session + if not sess: + return + if sess.e2e: + try: + data = sess.e2e.decrypt(data[:12], data[12:], None) + except Exception: + return + if not data or data[0] != 0xF2: + return + fid = int.from_bytes(data[1:5], "little") + off = int.from_bytes(data[5:13], "little") + chunk = data[13:] + fh = sess.uploads.get(fid) + if fh: + fh.seek(off) + fh.write(chunk) + left = getattr(fh, "_nd_left", None) + if left is not None: + fh._nd_left = left - len(chunk) + if fh._nd_left <= 0: + final = fh._nd_final + fh.close() + sess.uploads.pop(fid, None) + try: + os.replace(final + ".netdesk.part", final) + self.send_json({"t": "file.ok", "msg": "upload complete", "refresh": True}) + except Exception as e: + self.send_json({"t": "file.err", "msg": str(e)}) + + # ---------------- input ---------------- + def do_mouse(self, m): + if not self.cfg.data["perms"].get("input", True) or not self.pump: + return + x, y = int(m.get("x", 0)), int(m.get("y", 0)) + # viewer sends coords in the ENCODED image space; convert to physical + scale = getattr(self.session, "scale", 1.0) if self.session else 1.0 + if scale and abs(scale - 1.0) > 0.01: + x = int(x / scale) + y = int(y / scale) + # viewer coords are relative to the selected monitor; the injector uses + # virtual-desktop absolute coords, so add the monitor's origin + sess = self.session + if sess and sess.monitors: + mon = next((mm for mm in sess.monitors if mm["i"] == sess.mon), None) + if mon: + x += mon["l"] + y += mon["t"] + b = int(m.get("b", 0)) + w, hw = float(m.get("w", 0) or 0), float(m.get("hw", 0) or 0) + if (x, y) != getattr(self, "_last_xy", None): + self._last_xy = (x, y) + self.pump.put(("move", x, y)) + if b != self._mouse_b: + self.pump.put(("buttons", b)) + self._mouse_b = b + if w or hw: + self.pump.put(("wheel", w, hw)) # fractional notches preserved + + # ---------------- TCP tunnels ---------------- + def tunnel_open(self, tid: int, thost: str, port: int): + if tid in self._tunnels: + return + async def _go(): + try: + r, w = await asyncio.wait_for(asyncio.open_connection(thost, port), 6) + except Exception as e: + self.send_json({"t": "tunnel.err", "tid": tid, "msg": str(e)}) + return + self._tunnels[tid] = (r, w) + self.send_json({"t": "tunnel.ok", "tid": tid}) + try: + while True: + data = await r.read(65536) + if not data: + break + self.send_raw(b"\xf3" + tid.to_bytes(4, "little") + data) + except Exception: + pass + finally: + self.tunnel_close(tid) + self.send_json({"t": "tunnel.closed", "tid": tid}) + self._post(_go()) + + def tunnel_close(self, tid: int): + conn = self._tunnels.pop(tid, None) + if conn: + try: + conn[1].close() + except Exception: + pass + + def on_tunnel_data(self, tid: int, payload: bytes): + conn = self._tunnels.get(tid) + if conn: + try: + conn[1].write(payload) + except Exception: + self.tunnel_close(tid) + + # ---------------- files ---------------- + def file_roots(self): + items = [] + if IS_WIN: + for letter in string.ascii_uppercase: + drv = f"{letter}:\\" + if os.path.exists(drv): + items.append({"n": drv, "t": "d", "s": 0, "m": 0}) + else: + items.append({"n": "/", "t": "d", "s": 0, "m": 0}) + self.send_json({"t": "file.roots.res", "path": "", "items": items}) + + def file_list(self, path): + try: + if not path: + return self.file_roots() + items = [] + with os.scandir(path) as it: + for e in it: + try: + st = e.stat() + items.append({"n": e.name, "t": "d" if e.is_dir() else "f", + "s": 0 if e.is_dir() else st.st_size, "m": int(st.st_mtime)}) + except Exception: + continue + items.sort(key=lambda i: (i["t"] != "d", i["n"].lower())) + self.send_json({"t": "file.list.res", "path": path, "items": items[:2000]}) + except Exception as e: + self.send_json({"t": "file.err", "msg": f"cannot list {path}: {e}"}) + + def file_get(self, path, fid): + def worker(): + try: + sent = 0 + with open(path, "rb") as f: + while True: + chunk = f.read(128 * 1024) + if not chunk: + break + self.send_binary(0xF1.to_bytes(1, "little") + fid.to_bytes(4, "little") + + sent.to_bytes(8, "little") + chunk) + sent += len(chunk) + self.send_json({"t": "file.done", "fid": fid, "path": path, "size": sent}) + except Exception as e: + self.send_json({"t": "file.err", "msg": str(e)}) + threading.Thread(target=worker, daemon=True).start() + + @staticmethod + def _desktop_dir(): + """The console user's real Desktop (handles OneDrive redirection).""" + try: + buf = ctypes.c_wchar_p() + # SHGetKnownFolderPath(FOLDERID_Desktop) + import uuid as _u + fid = (ctypes.c_ubyte * 16).from_buffer_copy( + _u.UUID("B4BFCC3A-DB2C-424C-B029-7FE99A87C641").bytes_le) + if ctypes.windll.shell32.SHGetKnownFolderPath( + ctypes.byref(fid), 0, None, ctypes.byref(buf)) == 0: + p = buf.value + ctypes.windll.ole32.CoTaskMemFree(buf) + if p and os.path.isdir(p): + return p + except Exception: + pass + p = os.path.join(os.path.expanduser("~"), "Desktop") + return p if os.path.isdir(p) else os.path.expanduser("~") + + def file_put_begin(self, path, fid, size): + sess = self.session + if not sess or not self.cfg.data["perms"].get("files", True): + return + try: + # TeamViewer-style: a drop on the screen (relative path, no target + # folder chosen) lands on the remote DESKTOP, never in our CWD. + if not os.path.isabs(path): + rel = os.path.normpath(path).replace("/", "\\") + if rel.startswith("..") or ":" in rel: + rel = os.path.basename(rel) + path = os.path.join(self._desktop_dir(), rel) + if fid in sess.uploads: + sess.uploads[fid].close() + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + fh = open(path + ".netdesk.part", "wb") + fh._nd_final = path + fh._nd_left = size + sess.uploads[fid] = fh + except Exception as e: + self.send_json({"t": "file.err", "msg": str(e)}) + + def file_simple(self, path, fn): + try: + fn(path) + self.send_json({"t": "file.ok", "msg": "ok", "refresh": True}) + except Exception as e: + self.send_json({"t": "file.err", "msg": str(e)}) + + def file_rename(self, src, dst): + self.file_simple(src, lambda p: os.replace(p, dst)) + + +# ================================================================ capture + encode + +# H.264 encoder selection. Prefer a HARDWARE encoder (near-zero CPU, constant +# low latency regardless of screen complexity) and fall back to software +# libx264, so NetDesk runs smoothly on ANY PC + GPU: +# NVIDIA -> h264_nvenc, Intel/QuickSync -> h264_qsv, AMD -> h264_amf, +# macOS -> videotoolbox, Linux -> vaapi, else -> libx264 (software). +# The winner is probed ONCE (opening a HW encoder with no capable GPU raises) +# and cached; if a driver rejects the tuned options we retry the same encoder +# with bare options before moving on, so quirky hardware still gets accel. +_VIDEO_ENCODER = None # cached ffmpeg encoder name after the first successful probe + + +def _encoder_chain(pref="auto"): + """Ranked encoder candidates for a user preference. "auto" prefers a + DISCRETE-GPU hardware encoder (NVENC/AMF - genuinely lower latency and + offloads the CPU) and otherwise uses software libx264, which on a capable + CPU is actually faster than Intel iGPU QuickSync for live screen content + (measured) - so QSV is only chosen when explicitly requested. Any HW encoder + stays available via "hardware" or a specific name ("qsv"/"nvenc"/"amf").""" + pref = (pref or "auto").lower() + if pref == "software": + return ["libx264"] + if pref in ("nvenc", "qsv", "amf", "mf"): + return [f"h264_{pref}", "libx264"] + if IS_WIN: + if pref == "hardware": + return ["h264_nvenc", "h264_qsv", "h264_amf", "libx264"] + return ["h264_nvenc", "h264_amf", "libx264"] # auto + if IS_MAC: + return ["libx264"] if pref == "software" else ["h264_videotoolbox", "libx264"] + return ["h264_vaapi", "libx264"] + + +def _encoder_opts(name, fps, bitrate_kbps): + """Return (input_pix_fmt, options dict) tuned for low latency. + HW encoders take nv12; libx264 takes yuv420p. `forced_idr`/`forced-idr` + makes pict_type=I produce a real IDR on demand (monitor switch / recovery).""" + maxrate = bitrate_kbps + bufsize = max(bitrate_kbps * 2 // max(fps, 1), 150) # ~250ms VBV + if name == "libx264": + # TCP smoothness: a raw keyframe is a 10-30x burst that stalls the pipe + # for ~an RTT on long links. Cap instantaneous rate with a tight VBV, + # stretch keyint and kill scene-cut bursts; force_idr still gives + # on-demand keyframes for monitor switches / decoder recovery. + return "yuv420p", { + "preset": "ultrafast", "tune": "zerolatency", + "x264opts": (f"vbv-maxrate={maxrate}:vbv-bufsize={bufsize}:" + f"keyint-min={fps * 4}:scenecut=0"), + } + if name == "h264_nvenc": + return "nv12", {"preset": "p1", "tune": "ull", "rc": "cbr", + "forced-idr": "1"} + if name == "h264_qsv": + return "nv12", {"preset": "veryfast", "low_power": "1", + "async_depth": "1", "look_ahead": "0", + "forced_idr": "1"} + if name == "h264_amf": + return "nv12", {"usage": "ultralowlatency", "rc": "cbr", + "quality": "speed", "forced_idr": "1"} + if name == "h264_videotoolbox": + return "nv12", {"realtime": "1"} + if name == "h264_vaapi": + return "nv12", {} + return "yuv420p", {} + + +def make_encoder(w, h, fps, bitrate_kbps, pref="auto"): + """Build the best available H.264 encoder for this machine, honoring the + `video_encoder` preference ("auto"/"software"/"hardware"/name). + Returns (CodecContext, input_pix_fmt).""" + global _VIDEO_ENCODER + order = [] + if _VIDEO_ENCODER: + order.append(_VIDEO_ENCODER) + for n in _encoder_chain(pref): + if n not in order: + order.append(n) + err = None + for name in order: + fmt, opts = _encoder_opts(name, fps, bitrate_kbps) + # tuned options first, then bare options (for quirky HW drivers) + attempts = [opts] if name == "libx264" else [opts, {}] + for att in attempts: + try: + c = av.CodecContext.create(name, "w") + c.width, c.height, c.pix_fmt = w, h, fmt + c.time_base = Fraction(1, 1000) + c.framerate = Fraction(fps, 1) + c.bit_rate = bitrate_kbps * 1000 + c.gop_size = fps * 4 + c.max_b_frames = 0 + if att: + c.options = att + c.open() + if _VIDEO_ENCODER != name: + _VIDEO_ENCODER = name + log.info("video encoder: %s (%s)", name, + "hardware" if name != "libx264" else "software") + return c, fmt + except Exception as e: + err = e + if _VIDEO_ENCODER == name: # cached winner stopped working - re-probe + _VIDEO_ENCODER = None + raise RuntimeError(f"no usable H.264 encoder: {err}") + + +def _enum_monitor_positions(): + """Map each Windows display device name -> (left, top, width, height, + primary) via EnumDisplayMonitors. Used to pair a dxcam output (which knows + its own \\\\.\\DISPLAYn name) with the mss monitor at the same position, + because dxcam's output order does NOT match mss's monitor order.""" + out = {} + if not IS_WIN: + return out + try: + from ctypes import wintypes # noqa + + class _RECT(ctypes.Structure): + _fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long), + ("right", ctypes.c_long), ("bottom", ctypes.c_long)] + + class _MI(ctypes.Structure): + _fields_ = [("cbSize", ctypes.c_ulong), ("rcMonitor", _RECT), + ("rcWork", _RECT), ("dwFlags", ctypes.c_ulong), + ("szDevice", ctypes.c_wchar * 32)] + + user32 = ctypes.windll.user32 + proc = ctypes.WINFUNCTYPE(ctypes.c_int, ctypes.c_ulong, ctypes.c_ulong, + ctypes.POINTER(_RECT), ctypes.c_double) + + def _cb(hmon, hdc, lprc, data): + mi = _MI() + mi.cbSize = ctypes.sizeof(_MI) + if user32.GetMonitorInfoW(hmon, ctypes.byref(mi)): + r = mi.rcMonitor + out[mi.szDevice] = (r.left, r.top, r.right - r.left, + r.bottom - r.top, bool(mi.dwFlags & 1)) + return 1 + + user32.EnumDisplayMonitors(0, 0, proc(_cb), 0) + except Exception: + pass + return out + + +def _dxcam_output_count(): + try: + return max(1, _dxcam.output_info().count("Output[")) + except Exception: + return 1 + + +def capture_worker(sess: Session): + host = sess.host + w = h = None + enc = None + enc_fmt = "yuv420p" # pixel format the chosen encoder wants (yuv420p / nv12) + last_frame = None + idx = 0 + force_idr = True + last_desktop = None + sct = None + cam = None # dxcam camera (DXGI fast path), or None -> mss + cam_mon = None # the sess.mon the current camera was built for + cam_first = None # the camera's priming frame, sent as the first paint + last_cur_h = None + last_cur_pos = None + cur_mon_seen = None # detect monitor switches to force a keyframe + enc_pref = host.cfg.data.get("video_encoder", "auto") + use_dx = _DX_AVAILABLE and str( + host.cfg.data.get("capture_backend", "auto")).lower() != "mss" + log.info("capture worker started (dxgi=%s encoder=%s)", use_dx, enc_pref) + + def release_cam(): + nonlocal cam, cam_mon, cam_first + try: + if cam is not None: + cam.release() + except Exception: + pass + cam = None + cam_mon = None + cam_first = None + + def ensure_cam(mon_index, mon_geo): + """(Re)create the dxcam camera for the mss monitor at mon_geo, mapping by + device position (dxcam output order != mss order). Returns True when the + DXGI fast path is live, False to use mss. Any failure -> mss.""" + nonlocal cam, cam_mon, cam_first + if not use_dx or mon_index < 1: + release_cam() + return False + if cam is not None and cam_mon == mon_index: + return True + release_cam() + target = (mon_geo["left"], mon_geo["top"]) + names = _enum_monitor_positions() + target_name = next((nm for nm, g in names.items() + if (g[0], g[1]) == target), None) + for idx_o in range(_dxcam_output_count()): + try: + c = _dxcam.create(output_idx=idx_o, output_color="BGRA") + except Exception: + continue + if c is None: + continue + dn = getattr(getattr(c, "_output", None), "devicename", "") + if target_name is not None and dn != target_name: + try: c.release() + except Exception: pass + continue + probe = c.grab() + for _ in range(4): + if probe is not None: + break + time.sleep(0.01) + probe = c.grab() + if (probe is not None and probe.shape[1] == mon_geo["width"] + and probe.shape[0] == mon_geo["height"]): + cam = c + cam_mon = mon_index + cam_first = probe + log.info("dxcam output %d (%s) -> monitor %d (%dx%d)", + idx_o, dn, mon_index, probe.shape[1], probe.shape[0]) + return True + try: c.release() + except Exception: pass + log.info("dxcam unavailable for monitor %d; using mss", mon_index) + return False + + def update_cursor(mon_geo, scale): + nonlocal last_cur_h, last_cur_pos + if not IS_WIN: + return + try: + cs = cursor_state() + if cs is None: + return + hcur, gx, gy = cs + cx = int((gx - mon_geo["left"]) * scale) + cy = int((gy - mon_geo["top"]) * scale) + if hcur != last_cur_h: + last_cur_h = hcur + if hcur == 0: + host.send_json({"t": "cursor", "hidden": True}) + else: + shp = cursor_png(hcur) + css = _STD_CURSORS.get(hcur) + if shp or css: + msg = {"t": "cursor.shape", "id": hcur} + if shp: + msg.update(shp) + if css: + msg["css"] = css + host.send_json(msg) + if hcur != 0 and (cx, cy) != last_cur_pos: + last_cur_pos = (cx, cy) + host.send_json({"t": "cursor", "id": hcur, "x": cx, "y": cy}) + except Exception: + pass + + def rebuild(): + nonlocal sct, enc, last_frame, w, h, force_idr + try: + if sct: + sct.close() + except Exception: + pass + sct = mss.mss() + release_cam() + try: + if enc is not None: + enc.close() + except Exception: + pass + enc = None + last_frame = None + w = h = None + force_idr = True + + try: + while sess.active and not sess.stop_evt.is_set(): + t0 = time.time() + # follow the active desktop (logon screen / UAC switches) + name = attach_desktop() + if name != last_desktop: + log.info("input desktop switched to %s", name) + last_desktop = name + rebuild() + host.send_json({"t": "session.info", "info": { + "mon": sess.mon, "monitors": sess.monitors, + "w": w or 0, "h": h or 0, "fps": sess.fps, "bitrate": sess.bitrate, + "logon_screen": bool(name and name.lower().startswith("winlogon")), + "host_name": host.cfg.data["name"], + "e2e": sess.e2e is not None, "audio": host.audio_available, + "perms": host.cfg.data["perms"], "lock_on_end": host.cfg.data.get("lock_on_end", False), + "privacy_active": host.privacy}}) + if not name: + time.sleep(0.25) + continue + + mon = sct.monitors[min(sess.mon, len(sct.monitors) - 1)] + # A monitor switch (even between same-size displays) invalidates the + # encoder's reference frames - force a keyframe so the new screen is + # not predicted from the old one. + if cur_mon_seen is not None and cur_mon_seen != sess.mon: + force_idr = True + last_frame = None + cur_mon_seen = sess.mon + ow, oh = mon["width"] & ~1, mon["height"] & ~1 + scale = max(0.25, min(sess.scale, 1.0)) + tw = max(2, int(ow * scale) & ~1) + th = max(2, int(oh * scale) & ~1) + if enc is None or (w, h) != (tw, th) or sess.apply_evt.is_set(): + sess.apply_evt.clear() + if (w, h) != (tw, th): + w, h = tw, th + force_idr = True + last_frame = None + if enc is not None: + try: enc.close() + except Exception: pass + enc, enc_fmt = make_encoder(w, h, sess.fps, sess.bitrate, enc_pref) + host.send_json({"t": "session.info", "info": { + "mon": sess.mon, "monitors": sess.monitors, "w": w, "h": h, + "fps": sess.fps, "bitrate": sess.bitrate, + "logon_screen": str(last_desktop or "").lower().startswith("winlogon"), + "host_name": host.cfg.data["name"], "e2e": sess.e2e is not None, + "audio": host.audio_available, "perms": host.cfg.data["perms"], "lock_on_end": host.cfg.data.get("lock_on_end", False), + "privacy_active": host.privacy}}) + need_kf = bool(force_idr or getattr(sess, "force_idr", False)) + dx_active = ensure_cam(sess.mon, mon) + if dx_active: + # DXGI Desktop Duplication fast path (~4ms vs ~45ms for mss). + if cam_first is not None: + full, cam_first = cam_first, None # priming frame + else: + try: + full = cam.grab() # None == unchanged + except Exception as e: + log.debug("dxcam grab failed (%s); using mss", e) + release_cam() + continue + if full is None: + # Screen unchanged: keep the cursor live; only re-encode the + # last frame when a keyframe is actually due. + update_cursor(mon, scale) + if not need_kf or last_frame is None: + time.sleep(max(0.0, 1.0 / sess.fps - (time.time() - t0))) + continue + arr = last_frame + else: + arr = full[:oh, :ow, :] + last_frame = arr + update_cursor(mon, scale) + else: + # Fallback: mss GDI capture + full-frame change detection. + try: + img = sct.grab(mon) + except Exception as e: + log.debug("grab failed (%s)", e) + time.sleep(0.3) + rebuild() + continue + arr = np.frombuffer(img.raw, dtype=np.uint8).reshape( + img.size.height, img.size.width, 4) + arr = arr[:oh, :ow, :] + update_cursor(mon, scale) + unchanged = (last_frame is not None and arr.shape == last_frame.shape + and np.array_equal(arr, last_frame) and not need_kf) + if unchanged: + time.sleep(max(0.0, 1.0 / sess.fps - (time.time() - t0))) + continue + last_frame = arr + frame = av.VideoFrame.from_ndarray(arr, format="bgra") + frame = frame.reformat(width=w, height=h, format=enc_fmt) + frame.pts = idx * 1000 // max(1, sess.fps) + frame.time_base = Fraction(1, 1000) + idx += 1 + if force_idr or getattr(sess, "force_idr", False): + try: + frame.pict_type = av.video.frame.PictureType.I + except Exception: + pass + sess.force_idr = False + force_idr = False + for pkt in enc.encode(frame): + host.send_binary(b"\x01" + bytes([1 if pkt.is_keyframe else 0]) + bytes(pkt)) + delay = 1.0 / sess.fps - (time.time() - t0) + if delay > 0: + time.sleep(delay) + except Exception as e: + log.exception("capture worker crashed: %s", e) + finally: + release_cam() + try: + if enc is not None: + enc.close() + except Exception: + pass + try: + if sct is not None: + sct.close() + except Exception: + pass + log.info("capture worker stopped") + + +# ================================================================ audio / clipboard workers + +def audio_worker(sess: Session): + host = sess.host + try: + import pyaudiowpatch as pyaudio + import numpy as _np + except Exception: + return + ctx = None + stream = None + p = None + try: + p = pyaudio.PyAudio() + try: + dev = p.get_default_wasapi_loopback() + except Exception: + return + stream = p.open(format=pyaudio.paInt16, channels=2, rate=48000, input=True, + frames_per_buffer=960, input_device_index=dev["index"]) + ctx = av.CodecContext.create("libopus", "w") + ctx.sample_rate = 48000 + ctx.layout = "stereo" + ctx.format = "s16" + ctx.bit_rate = 96000 + ctx.open() + log.info("audio capture running") + while sess.active and not sess.stop_evt.is_set(): + data = stream.read(960, exception_on_overflow=False) + samples = _np.frombuffer(data, dtype=_np.int16) + if not samples.any(): + continue + frame = av.AudioFrame.from_ndarray(samples.reshape(1, -1), format="s16", layout="stereo") + frame.sample_rate = 48000 + frame.pts = None + for pkt in ctx.encode(frame): + host.send_binary(b"\xa1" + bytes(pkt)) + except Exception as e: + log.info("audio disabled: %s", e) + finally: + try: + if stream is not None: + stream.stop_stream() + stream.close() + except Exception: + pass + try: + if p is not None: + p.terminate() + except Exception: + pass + try: + if ctx is not None: + ctx.close() + except Exception: + pass + + +def clipboard_worker(host: Host): + while True: + time.sleep(0.5) + sess = host.session + if not sess or not sess.active: + continue + if not host.cfg.data["perms"].get("clipboard", True): + continue + txt = clip_get() + if txt is not None and txt != host.clip_last_sent: + host.clip_last_sent = txt + host.send_json({"t": "clip.set", "text": txt[:1024 * 1024], "origin": "host"}) + + +# ================================================================ transports (host side) + +from contextlib import asynccontextmanager + +@asynccontextmanager +async def ws_connect_tolerant(http, url, **kw): + """Async-CM ws_connect that falls back to unverified TLS for self-signed + certificates. Acceptable for personal deployments with a self-signed cert; + use a real certificate in production.""" + try: + async with http.ws_connect(url, **kw) as ws: + yield ws + except (aiohttp.ClientConnectorSSLError, aiohttp.ClientConnectorCertificateError) as e: + log.warning("self-signed certificate on %s - accepting (INSECURE)", url) + async with http.ws_connect(url, ssl=False, **kw) as ws: + yield ws + + +# ---------------- display modes / wallpaper / SAS (TeamViewer-style View & Actions menus) +class _DEVMODEW(ctypes.Structure): + _fields_ = [ + ("dmDeviceName", ctypes.c_wchar * 32), + ("dmSpecVersion", ctypes.c_ushort), ("dmDriverVersion", ctypes.c_ushort), + ("dmSize", ctypes.c_ushort), ("dmDriverExtra", ctypes.c_ushort), + ("dmFields", ctypes.c_ulong), + ("dmPositionX", ctypes.c_long), ("dmPositionY", ctypes.c_long), + ("dmDisplayOrientation", ctypes.c_ulong), ("dmDisplayFixedOutput", ctypes.c_ulong), + ("dmColor", ctypes.c_short), ("dmDuplex", ctypes.c_short), + ("dmYResolution", ctypes.c_short), ("dmTTOption", ctypes.c_short), + ("dmCollate", ctypes.c_short), + ("dmFormName", ctypes.c_wchar * 32), + ("dmLogPixels", ctypes.c_ushort), + ("dmBitsPerPel", ctypes.c_ulong), + ("dmPelsWidth", ctypes.c_ulong), ("dmPelsHeight", ctypes.c_ulong), + ("dmDisplayFlags", ctypes.c_ulong), ("dmDisplayFrequency", ctypes.c_ulong), + ("dmICMMethod", ctypes.c_ulong), ("dmICMIntent", ctypes.c_ulong), + ("dmMediaType", ctypes.c_ulong), ("dmDitherType", ctypes.c_ulong), + ("dmReserved1", ctypes.c_ulong), ("dmReserved2", ctypes.c_ulong), + ("dmPanningWidth", ctypes.c_ulong), ("dmPanningHeight", ctypes.c_ulong), + ] + + +class _DISPLAY_DEVICEW(ctypes.Structure): + _fields_ = [("cb", ctypes.c_ulong), ("DeviceName", ctypes.c_wchar * 32), + ("DeviceString", ctypes.c_wchar * 128), ("StateFlags", ctypes.c_ulong), + ("DeviceID", ctypes.c_wchar * 128), ("DeviceKey", ctypes.c_wchar * 128)] + + +def _display_devices(): + """Attached display devices with their current desktop position + size.""" + u = ctypes.windll.user32 + out, i = [], 0 + while True: + dd = _DISPLAY_DEVICEW() + dd.cb = ctypes.sizeof(dd) + if not u.EnumDisplayDevicesW(None, i, ctypes.byref(dd), 0): + break + i += 1 + if not (dd.StateFlags & 0x1): # DISPLAY_DEVICE_ATTACHED_TO_DESKTOP + continue + dm = _DEVMODEW() + dm.dmSize = ctypes.sizeof(dm) + if u.EnumDisplaySettingsW(dd.DeviceName, -1, ctypes.byref(dm)): # current + out.append({"name": dd.DeviceName, "x": dm.dmPositionX, "y": dm.dmPositionY, + "w": int(dm.dmPelsWidth), "h": int(dm.dmPelsHeight)}) + return out + + +def _device_for_monitor(mon_geo): + """Match an mss monitor dict (left/top) to its GDI display device.""" + devs = _display_devices() + for d in devs: + if d["x"] == mon_geo.get("left") and d["y"] == mon_geo.get("top"): + return d + return devs[0] if devs else None + + +def display_modes(device_name): + """All (w, h) modes the device supports, largest first.""" + u = ctypes.windll.user32 + modes, i = set(), 0 + while True: + dm = _DEVMODEW() + dm.dmSize = ctypes.sizeof(dm) + if not u.EnumDisplaySettingsW(device_name, i, ctypes.byref(dm)): + break + i += 1 + if dm.dmPelsWidth >= 800 and dm.dmPelsHeight >= 600: + modes.add((int(dm.dmPelsWidth), int(dm.dmPelsHeight))) + return sorted(modes, reverse=True) + + +def set_display_mode(device_name, w, h): + """Temporarily change the display resolution (not persisted to registry).""" + u = ctypes.windll.user32 + dm = _DEVMODEW() + dm.dmSize = ctypes.sizeof(dm) + if not u.EnumDisplaySettingsW(device_name, -1, ctypes.byref(dm)): + return False, "cannot read current mode" + dm.dmPelsWidth, dm.dmPelsHeight = int(w), int(h) + dm.dmFields = 0x80000 | 0x100000 # DM_PELSWIDTH | DM_PELSHEIGHT + r = u.ChangeDisplaySettingsExW(device_name, ctypes.byref(dm), None, 0, None) + return (r == 0), ("resolution changed" if r == 0 + else f"display rejected the mode (code {r})") + + +def wallpaper_get(): + buf = ctypes.create_unicode_buffer(520) + ctypes.windll.user32.SystemParametersInfoW(0x73, 512, buf, 0) # SPI_GETDESKWALLPAPER + return buf.value + + +def wallpaper_set(path): + # SPI_SETDESKWALLPAPER with SPIF_UPDATEINIFILE | SPIF_SENDCHANGE + return bool(ctypes.windll.user32.SystemParametersInfoW(0x14, 0, path or "", 3)) + + +def send_cad(): + """Secure Attention Sequence (Ctrl+Alt+Del). Only takes effect when the + agent runs as SYSTEM (boot-time service) and Windows SAS policy allows it.""" + try: + sas = ctypes.WinDLL("sas.dll") + sas.SendSAS(0) # as service + sas.SendSAS(1) # as user (whichever the policy permits) + return True, "Ctrl+Alt+Del sent (takes effect when SAS policy allows it)" + except Exception as e: + return False, f"Ctrl+Alt+Del unavailable: {str(e)[:120]}" + + +def _mac_str() -> str: + """Primary NIC MAC for Wake-on-LAN (empty if only a random/virtual one).""" + try: + n = uuid.getnode() + if (n >> 40) & 0x02: # locally-administered bit: random, not a real NIC + return "" + return ":".join(f"{(n >> s) & 0xFF:02x}" for s in range(40, -1, -8)) + except Exception: + return "" + + +def send_wol(mac: str): + """Broadcast a Wake-on-LAN magic packet on the local network.""" + import socket as _sk + clean = "".join(ch for ch in mac if ch in "0123456789abcdefABCDEF") + if len(clean) != 12: + return + payload = b"\xff" * 6 + bytes.fromhex(clean) * 16 + try: + s = _sk.socket(_sk.AF_INET, _sk.SOCK_DGRAM) + s.setsockopt(_sk.SOL_SOCKET, _sk.SO_BROADCAST, 1) + for port in (9, 7): + s.sendto(payload, ("255.255.255.255", port)) + s.close() + log.info("WoL magic packet broadcast for %s", mac) + except Exception as e: + log.warning("WoL send failed: %s", e) + + +def ip_allowed(ip: str, allow_csv: str) -> bool: + """True if ip matches any entry (IP or CIDR) in the allowlist string.""" + import ipaddress + try: + a = ipaddress.ip_address(ip) + except ValueError: + return True # unparseable/missing peer info: never lock the owner out + for part in allow_csv.replace(",", " ").split(): + try: + if a in ipaddress.ip_network(part, strict=False): + return True + except ValueError: + continue + return False + + +def reg_message(cfg: Config, args, takeover=False) -> dict: + return {"t": "reg.host", "id": cfg.id, + "key": getattr(args, "reg_key", None) or cfg.data.get("reg_key", ""), + "name": cfg.data.get("name", "host"), "ver": VERSION, + # desktop-app account token: binds this computer to "My computers" + "token": cfg.data.get("account_token", ""), + # MAC lets another computer on the account wake this one (WoL) + "mac": _mac_str(), + # on our first registration only, take over any idle stuck engine + # holding this ID (never displaces an active session server-side) + "takeover": bool(takeover)} + + +def write_status(cfg: Config, state: str, server: str, in_session=False, account=""): + """Heartbeat file the desktop app reads (written by engine and broker). + + A stale duplicate engine (rejected with "already connected elsewhere") + must never stomp the status of the engine that actually holds the ID - + that ping-pong made the app flicker Online/Stopped every few seconds.""" + try: + if state != "online": + try: + d = json.loads((cfg.path.parent / "gui_status.json").read_text("utf-8")) + if (d.get("state") == "online" and d.get("pid") != os.getpid() + and time.time() - d.get("ts", 0) < 8): + return # another live engine of ours is online - keep its status + except Exception: + pass + (cfg.path.parent / "gui_status.json").write_text(json.dumps({ + "state": state, "id": cfg.id, "name": cfg.data.get("name", ""), + "server": server, "in_session": bool(in_session), "account": account, + "ts": int(time.time()), "pid": os.getpid()}), "utf-8") + except Exception: + pass + + +async def pump_socket_to_host(host: Host, ws, label): + """Feed one inbound websocket into the host (used by simple/direct/agent modes).""" + async for msg in ws: + if msg.type == WSMsgType.TEXT: + try: + m = json.loads(msg.data) + except Exception: + continue + if m.get("t") == "host_status": + if not m.get("ok"): + log.error("server rejected registration: %s", m.get("msg")) + host.reg_error = m.get("msg") or "rejected" + else: + host.reg_error = "" + host.account = m.get("account", "") + continue + host.on_message(m) + elif msg.type == WSMsgType.BINARY: + host.on_binary(msg.data) + elif msg.type == WSMsgType.ERROR: + break + + +async def run_simple_mode(host: Host, args): + """Original mode: this process talks to the server itself + optional direct listener.""" + async def direct_listen(): + if not args.listen: + return + + async def handler(req): + ws = web.WebSocketResponse(heartbeat=20.0, max_msg_size=64 * 1024 * 1024) + await ws.prepare(req) + pending_sid = None + try: + async for msg in ws: + if msg.type == WSMsgType.TEXT: + try: + m = json.loads(msg.data) + except Exception: + continue + t = m.get("t") + if t == "session.begin": + if host.session and host.session.active: + await ws.send_json({"t": "session.deny", "sid": None, "reason": "host busy"}) + continue + pending_sid = uuid.uuid4().hex + host.link = "direct" + host.ws_direct = ws + host.on_message({"t": "session.begin", "sid": pending_sid, + "peer": req.remote or ""}) + else: + if pending_sid and not m.get("sid"): + m["sid"] = pending_sid + host.on_message(m) + elif msg.type == WSMsgType.BINARY: + host.on_binary(msg.data) + finally: + if host.link == "direct": + host.end_session("direct viewer left") + host.link = "server" + host.ws_direct = None + return ws + + app = web.Application() + app.router.add_get("/ws", handler) + runner = web.AppRunner(app, access_log=None) + await runner.setup() + await web.TCPSite(runner, "0.0.0.0", args.listen).start() + log.info("direct listener on port %d", args.listen) + + if args.listen: + await direct_listen() + + url = args.server or host.cfg.data.get("server", "") or DEFAULT_SERVER + if not url: + log.info("no --server given; direct mode only") + return + + async def gui_status_writer(): + """Heartbeat for the desktop GUI (gui_status.json next to the config).""" + while True: + online = host.ws_server is not None and not host.ws_server.closed + err = getattr(host, "reg_error", "") + state = ("error: " + err) if err else ("online" if online else "connecting") + write_status(host.cfg, state, url, bool(host.session), getattr(host, "account", "")) + await asyncio.sleep(3) + + status_task = asyncio.ensure_future(gui_status_writer()) + host._status_task = status_task + backoff = 1 + while True: + try: + timeout = aiohttp.ClientTimeout(total=None, connect=15) + async with aiohttp.ClientSession(timeout=timeout) as http: + async with ws_connect_tolerant(http, url, heartbeat=20, max_msg_size=64 * 1024 * 1024) as ws: + host.ws_server = ws + host.link = "server" + backoff = 1 + host.cfg.reload() + # take over on our first attempt, AND whenever the last try + # was bounced because a STALE idle engine still holds the ID + # (server never lets takeover kill an active session, so this + # only ever reclaims a dead/ghost holder - the real fix for + # "reconnect says the ID is already connected elsewhere") + prev_err = (getattr(host, "reg_error", "") or "").lower() + want_takeover = (not getattr(host, "_did_register", False) + or "already connected" in prev_err) + host._did_register = True + await ws.send_json(reg_message(host.cfg, args, takeover=want_takeover)) + log.info("connected to server %s as ID %s (takeover=%s)", + url, host.cfg.id, want_takeover) + await pump_socket_to_host(host, ws, "server") + except Exception as e: + log.warning("server link lost (%s); retrying in %ss", e, backoff) + host.ws_server = None + if host.session and host.link == "server": + host.end_session("server link lost") + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 5) + + +# ================================================================ service broker + +class Broker: + """Boot-time SYSTEM service core: holds the server link, spawns a desktop + agent into the console session, and relays traffic. Runs in session 0.""" + + def __init__(self, cfg: Config, args): + self.cfg = cfg + self.args = args + self.token = secrets.token_hex(16) + self.port = 0 + self.agent_ws = None + self.agent_proc = None # (handle|None, pid, session) + self.server_ws = None + self.direct_ws = None # direct viewer socket + self.direct_sid = None + self.session_origin = "server" + self.reg_error = "" + self.account = "" + + # ---------- spawn / supervise agent ---------- + def agent_cmdline(self): + if getattr(sys, "frozen", False): + prog = f'"{sys.executable}"' + else: + prog = f'"{sys.executable}" "{os.path.abspath(__file__)}"' + return (f'{prog} --session-agent --config "{self.cfg.path.parent}" ' + f'--broker-url ws://127.0.0.1:{self.port}/agent --token {self.token}') + + def spawn_agent(self, session_id): + cmd = self.agent_cmdline() + try: + handle, pid = spawn_in_console_session(cmd, session_id) + log.info("spawned SYSTEM agent pid=%s session=%s", pid, session_id) + return (handle, pid, None) + except Exception as e: + log.warning("SYSTEM spawn failed (%s); spawning plain child", e) + proc = subprocess.Popen(cmd, creationflags=0x00000008 | 0x00000200) # DETACHED|NEW_GROUP + return (None, proc.pid, proc) + + def kill_agent(self): + if self.agent_ws is not None: + self.kill_agent_ws_only() + info = self.agent_proc + if info: + handle, pid, popen = info + if popen is not None: + popen.kill() + elif handle: + kernel32.TerminateProcess(handle, 1) + kernel32.CloseHandle(handle) + else: + subprocess.run(["taskkill", "/F", "/PID", str(pid)], + capture_output=True, creationflags=0x08000000) + self.agent_proc = None + + async def lifecycle(self): + while True: + try: + con = console_session_id() + info = self.agent_proc + alive = False + if info: + handle, pid, popen = info + if popen is not None: + alive = popen.poll() is None + elif handle: + alive = kernel32.WaitForSingleObject(handle, 0) == 0x102 # STILL_ACTIVE + if alive: + sid = DWORD(0) + if kernel32.ProcessIdToSessionId(pid, ctypes.byref(sid)) and sid.value != con: + alive = False # console session changed (user switch) + if not alive: + if info: + log.info("respawning agent (old pid=%s)", info[1]) + self.kill_agent() + self.agent_proc = self.spawn_agent(con) + except Exception as e: + log.debug("lifecycle: %s", e) + await asyncio.sleep(2) + + # ---------- sockets ---------- + async def server_link(self): + url = self.args.server or self.cfg.data.get("server", "") or DEFAULT_SERVER + if not url: + log.warning("broker: no server URL configured (--server)") + return + backoff = 1 + while True: + try: + timeout = aiohttp.ClientTimeout(total=None, connect=15) + async with aiohttp.ClientSession(timeout=timeout) as http: + async with ws_connect_tolerant(http, url, heartbeat=20, max_msg_size=64 * 1024 * 1024) as ws: + self.server_ws = ws + backoff = 1 + self.cfg.reload() + prev_err = (getattr(self, "reg_error", "") or "").lower() + want_takeover = (not getattr(self, "_did_register", False) + or "already connected" in prev_err) + self._did_register = True + await ws.send_json(reg_message(self.cfg, self.args, takeover=want_takeover)) + log.info("broker connected to %s as ID %s (takeover=%s)", + url, self.cfg.id, want_takeover) + async for msg in ws: + if msg.type == WSMsgType.TEXT: + m = json.loads(msg.data) if msg.data.startswith("{") else {} + if m.get("t") == "host_status": + if not m.get("ok"): + log.error("registration rejected: %s", m.get("msg")) + self.reg_error = m.get("msg") or "rejected" + else: + self.reg_error = "" + self.account = m.get("account", "") + continue + if m.get("t") == "session.begin" and self.agent_ws is None: + await ws.send_json({"t": "session.deny", "sid": m.get("sid"), + "reason": "host starting, try again shortly"}) + continue + if self.agent_ws and not self.agent_ws.closed: + await self.agent_ws.send_str(msg.data) + elif msg.type == WSMsgType.BINARY: + if self.agent_ws and not self.agent_ws.closed: + await self.agent_ws.send_bytes(msg.data) + except Exception as e: + log.warning("broker server link lost (%s); retry in %ss", e, backoff) + self.server_ws = None + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 5) + + def route_from_agent(self, data: str): + """Agent output goes to whoever started the session (direct client or server).""" + if self.session_origin == "direct" and self.direct_ws and not self.direct_ws.closed: + asyncio.ensure_future(self.direct_ws.send_str(data)) + elif self.server_ws and not self.server_ws.closed: + asyncio.ensure_future(self.server_ws.send_str(data)) + + async def agent_handler(self, req): + if req.query.get("token") != self.token: + return web.Response(status=403, text="bad token") + ws = web.WebSocketResponse(heartbeat=15.0, max_msg_size=64 * 1024 * 1024) + await ws.prepare(req) + self.kill_agent_ws_only() + self.agent_ws = ws + log.info("desktop agent attached (session %s)", my_session_id()) + try: + async for msg in ws: + if msg.type == WSMsgType.TEXT: + self.route_from_agent(msg.data) + elif msg.type == WSMsgType.BINARY: + if self.session_origin == "direct" and self.direct_ws and not self.direct_ws.closed: + asyncio.ensure_future(self.direct_ws.send_bytes(msg.data)) + elif self.server_ws and not self.server_ws.closed: + asyncio.ensure_future(self.server_ws.send_bytes(msg.data)) + finally: + if self.agent_ws is ws: + self.agent_ws = None + log.info("desktop agent detached") + return ws + + def kill_agent_ws_only(self): + if self.agent_ws is not None: + try: + asyncio.ensure_future(self.agent_ws.close()) + except Exception: + pass + self.agent_ws = None + + async def direct_handler(self, req): + ws = web.WebSocketResponse(heartbeat=20.0, max_msg_size=64 * 1024 * 1024) + await ws.prepare(req) + old = self.direct_ws + if old is not None and not old.closed: + try: await old.close() + except Exception: pass + self.direct_ws = ws + try: + async for msg in ws: + if msg.type == WSMsgType.TEXT: + m = json.loads(msg.data) if msg.data.startswith("{") else {} + if m.get("t") == "session.begin": + self.direct_sid = uuid.uuid4().hex + m["sid"] = self.direct_sid + self.session_origin = "direct" + if self.agent_ws and not self.agent_ws.closed: + await self.agent_ws.send_str(json.dumps(m)) + continue + if self.agent_ws and not self.agent_ws.closed: + await self.agent_ws.send_str(msg.data) + elif msg.type == WSMsgType.BINARY: + if self.agent_ws and not self.agent_ws.closed: + await self.agent_ws.send_bytes(msg.data) + finally: + if self.direct_ws is ws: + self.direct_ws = None + self.session_origin = "server" + return ws + + async def run(self): + # agent socket + app = web.Application() + app.router.add_get("/agent", self.agent_handler) + runner = web.AppRunner(app, access_log=None) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + self.port = site._server.sockets[0].getsockname()[1] + (self.cfg.path.parent / "broker.json").write_text( + json.dumps({"port": self.port, "token": self.token}), "utf-8") + log.info("broker socket on 127.0.0.1:%d", self.port) + + # optional direct listener + if self.args.listen: + dapp = web.Application() + dapp.router.add_get("/ws", self.direct_handler) + drunner = web.AppRunner(dapp, access_log=None) + await drunner.setup() + await web.TCPSite(drunner, "0.0.0.0", self.args.listen).start() + log.info("direct listener on port %d", self.args.listen) + + async def status(): + url = self.args.server or self.cfg.data.get("server", "") or DEFAULT_SERVER + while True: + up = self.server_ws is not None and not self.server_ws.closed + err = getattr(self, "reg_error", "") + state = ("error: " + err) if err else ("online" if up else "connecting") + write_status(self.cfg, state, url, False, getattr(self, "account", "")) + await asyncio.sleep(3) + + await asyncio.gather(self.server_link(), self.lifecycle(), status()) + + +def kill_other_engines(): + """Windows: make sure only THIS engine instance runs (frozen exe).""" + if not IS_WIN or not getattr(sys, "frozen", False): + return + me = os.getpid() + subprocess.run( + ["powershell", "-NoProfile", "-Command", + f"Get-CimInstance Win32_Process -Filter \"Name='{os.path.basename(sys.executable)}'\" | " + f"Where-Object {{ $_.ProcessId -ne {me} -and $_.CommandLine -like '*--engine*' }} | " + "ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"], + capture_output=True, creationflags=CREATE_NO_WINDOW) + + +async def run_agent_mode(args): + """Desktop worker spawned by the broker (runs inside the console session).""" + cfg = Config(Path(args.config) / "config.json" if args.config else appdata_dir() / "config.json") + host = Host(cfg, args, link="broker") + host.loop = asyncio.get_running_loop() + kill_other_engines() + backoff = 1 + timeout = aiohttp.ClientTimeout(total=None, connect=5, sock_read=30) + while True: + try: + async with aiohttp.ClientSession(timeout=timeout) as http: + async with http.ws_connect(f"{args.broker_url}?token={args.token}", + heartbeat=30, max_msg_size=64 * 1024 * 1024) as ws: + host.ws_broker = ws + backoff = 1 + log.info("agent linked to broker") + await pump_socket_to_host(host, ws, "broker") + except Exception as e: + log.warning("agent/broker link error: %s", e) + host.ws_broker = None + if host.session: + host.end_session("broker link lost") + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 10) + # exit if we are no longer in the console session (the broker respawns us) + if IS_WIN and my_session_id() != console_session_id(): + log.info("session changed; agent exiting") + return + + +# ================================================================ install / uninstall + +SERVICE_TASK = "NetDesk" + + +def install_service(args): + if not IS_WIN: + print("service install is Windows-only") + return + if not is_elevated(): + print("Please run --install from an elevated (Administrator) prompt once.") + return + cfg_dir = appdata_dir() # the portable folder (or APPDATA) the APP writes to + cfg = Config(cfg_dir / "config.json") + # Point the SYSTEM broker at the SAME config file the app writes, so any + # password / 2FA / settings change made in the app applies to the boot + # service automatically (cfg.reload() at each connection) - no re-install, + # no extra elevation. Also mirror it into ProgramData as a fallback the + # service can always read even if the portable folder becomes unavailable. + pd = programdata_dir() + pd.mkdir(parents=True, exist_ok=True) + (pd / "config.json").write_text(json.dumps(cfg.data, indent=2), "utf-8") + svc_cfg = cfg_dir if os.access(cfg_dir, os.W_OK) else pd + if getattr(sys, "frozen", False): + # packaged exe: run it directly with --broker (no python, no script path) + tr = f'"{sys.executable}" --broker --config "{svc_cfg}"' + else: + py = Path(sys.executable).with_name("pythonw.exe") + if not py.exists(): + py = Path(sys.executable) + tr = f'"{py}" "{os.path.abspath(__file__)}" --broker --config "{svc_cfg}"' + server = args.server or cfg.data.get("server", "") + if server: + tr += f' --server "{server}"' + reg = args.reg_key or cfg.data.get("reg_key", "") + if reg: + tr += f' --reg-key "{reg}"' + if args.listen: + tr += f" --listen {args.listen}" + r = subprocess.run(["schtasks", "/Create", "/TN", SERVICE_TASK, "/SC", "ONSTART", + "/RU", "SYSTEM", "/RL", "HIGHEST", "/TR", tr, "/F"], + capture_output=True, text=True) + result_path = programdata_dir() / "install_result.json" + if r.returncode != 0: + result_path.write_text(json.dumps( + {"ok": False, "error": (r.stderr or r.stdout or "schtasks failed").strip()}), "utf-8") + print("schtasks failed:", r.stderr or r.stdout) + return + # handover: stop only the user-run ENGINE so the service owns the connection. + # Do NOT blanket-kill NetDeskHost.exe - that also kills the GUI (which is + # polling for success) and this very installer, so the install looked like it + # "never finished" and the toggle stayed off. + me = os.getpid() + subprocess.run( + ["powershell", "-NoProfile", "-Command", + "Get-CimInstance Win32_Process | Where-Object { " + "($_.Name -eq 'NetDeskHost.exe' -or $_.CommandLine -like '*netdesk_host.py*') " + f"-and $_.CommandLine -like '*--engine*' -and $_.ProcessId -ne {me} }} | " + "ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"], + capture_output=True, creationflags=0x08000000) + subprocess.run(["schtasks", "/Run", "/TN", SERVICE_TASK], capture_output=True) + result_path.write_text(json.dumps({"ok": True, "id": cfg.id}), "utf-8") + print("=" * 62) + print(" NetDesk service installed and started (boot-time, SYSTEM).") + print(f" Host ID : {cfg.id} (service reads config from {svc_cfg})") + print(" Reachable at the logon screen, across reboots, no auto-logon.") + print(f" Remove: {Path(__file__).name} --uninstall") + print("=" * 62) + + +def uninstall_service(): + if not IS_WIN: + return + subprocess.run(["schtasks", "/End", "/TN", SERVICE_TASK], capture_output=True) + r = subprocess.run(["schtasks", "/Delete", "/TN", SERVICE_TASK, "/F"], + capture_output=True, text=True) + if r.returncode != 0: + print("schtasks delete failed:", r.stderr or r.stdout) + return + # stop the broker/agent processes this service started (targeted, by command line) + subprocess.run( + ["powershell", "-NoProfile", "-Command", + "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*--broker*' " + "-or $_.CommandLine -like '*--session-agent*' } | " + "ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"], + capture_output=True, creationflags=0x08000000) + print("NetDesk service removed.") + + +# ================================================================ main + +def main(): + # frozen/CLI behavior: double-click (no args) opens the desktop app, + # explicit flags keep the classic engine/service CLI. + gui_wanted = ("--gui" in sys.argv) or ( + len(sys.argv) == 1 and getattr(sys, "frozen", False)) + if "--gui-selftest" in sys.argv: + import netdesk_gui + netdesk_gui.selftest() + return + if gui_wanted: + try: + import netdesk_gui + except Exception as e: + print("GUI unavailable (%s); engine CLI only." % e) + import traceback; traceback.print_exc() + return + netdesk_gui.run() + return + + ap = argparse.ArgumentParser(description="NetDesk host agent") + ap.add_argument("--server", help="NetDesk server ws(s):// URL") + ap.add_argument("--reg-key", help="server registration key") + ap.add_argument("--id", help="override host ID (9 digits)") + ap.add_argument("--set-password", dest="newpw", help="set a new permanent password") + ap.add_argument("--set-totp", dest="newtotp", nargs="?", const="auto", + help="enable TOTP 2FA (optional secret, base32; 'auto' generates one)") + ap.add_argument("--name", help="friendly name shown to viewers") + ap.add_argument("--listen", type=int, metavar="PORT", help="direct-access listener port") + ap.add_argument("--no-audio", action="store_true") + ap.add_argument("--config", help="config directory (service mode)") + ap.add_argument("--broker", action="store_true", help=argparse.SUPPRESS) + ap.add_argument("--broker-url", dest="broker_url", help=argparse.SUPPRESS) + ap.add_argument("--session-agent", action="store_true", help=argparse.SUPPRESS) + ap.add_argument("--token", help=argparse.SUPPRESS) + ap.add_argument("--engine", action="store_true", help=argparse.SUPPRESS) + ap.add_argument("--gui", action="store_true", help="open the desktop control panel") + ap.add_argument("--install", action="store_true", help="install boot-time service (admin once)") + ap.add_argument("--uninstall", action="store_true", help="remove the service") + ap.add_argument("--verbose", action="store_true") + args = ap.parse_args() + + global _CONFIG_DIR + if args.config: + _CONFIG_DIR = Path(args.config) + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(asctime)s %(levelname)-7s %(message)s", + handlers=[logging.StreamHandler(), + logging.FileHandler(appdata_dir() / "host.log", encoding="utf-8")], + ) + + if args.install: + install_service(args) + return + if args.uninstall: + uninstall_service() + return + + cfg = Config(appdata_dir() / "config.json") + if args.id and args.id.isdigit(): + cfg.data["id"] = args.id + if args.name: + cfg.data["name"] = args.name + if args.server: + cfg.data["server"] = args.server + cfg.save() + + if args.newpw: + cfg.set_password(args.newpw) + print("[netdesk] permanent password updated.") + return + if args.newtotp is not None: + if args.newtotp == "auto": + secret = "".join(secrets.choice(string.ascii_uppercase + "234567") for _ in range(32)) + else: + secret = args.newtotp + cfg.set_totp(secret) + print("[netdesk] TOTP 2FA ENABLED.") + print(f" Secret (base32, add to your authenticator): {secret}") + print(" Viewer will ask for the 6-digit code at connect.") + return + + if args.broker: + if not IS_WIN: + print("[netdesk] the session broker is Windows-only; on macOS use a LaunchAgent (see scripts/deploy)") + return + broker = Broker(cfg, args) + print(f"[netdesk] broker starting (ID {cfg.id}, session {my_session_id()})") + try: + asyncio.run(broker.run()) + except KeyboardInterrupt: + pass + return + if args.session_agent: + if not args.broker_url or not args.token: + print("--session-agent needs --broker-url and --token") + return + print(f"[netdesk] desktop agent starting (ID {cfg.id}, session {my_session_id()})") + try: + asyncio.run(run_agent_mode(args)) + except KeyboardInterrupt: + pass + return + + if not args.engine: + print("=" * 60) + print(f" NetDesk host v{VERSION}") + print(f" Host ID : {cfg.id}") + print(f" Name : {cfg.data['name']}") + print(f" Server : {args.server or cfg.data.get('server') or '(none - direct mode only)'}") + print(f" Config : {cfg.path}") + if cfg.data.get("totp"): + print(" 2FA : TOTP enabled") + if cfg._new_password and not args.engine: + print(f" NEW PASSWORD (save it): {cfg._new_password}") + if not args.engine: + print("[netdesk] running - press Ctrl+C to stop") + print("=" * 60) + + host = Host(cfg, args, link="server") + + async def amain(): + host.loop = asyncio.get_running_loop() + await run_simple_mode(host, args) + + try: + asyncio.run(amain()) + except KeyboardInterrupt: + host.end_session("host stopping") + print("\r[netdesk] stopped") + + +if __name__ == "__main__": + main() diff --git a/host/pty_session.py b/host/pty_session.py new file mode 100644 index 0000000..b2ad158 --- /dev/null +++ b/host/pty_session.py @@ -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":""} 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":""} 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]) diff --git a/native/.cargo/config.toml b/native/.cargo/config.toml new file mode 100644 index 0000000..8f0fc90 --- /dev/null +++ b/native/.cargo/config.toml @@ -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++", +] diff --git a/native/.gitignore b/native/.gitignore new file mode 100644 index 0000000..6ccc25e --- /dev/null +++ b/native/.gitignore @@ -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 diff --git a/native/Cargo.lock b/native/Cargo.lock new file mode 100644 index 0000000..f9f0604 --- /dev/null +++ b/native/Cargo.lock @@ -0,0 +1,5758 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allo-isolate" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "449e356a4864c017286dbbec0e12767ea07efba29e3b7d984194c2a7ff3c4550" +dependencies = [ + "anyhow", + "atomic", + "backtrace", +] + +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.13.2", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni 0.22.4", + "libc", + "log", + "ndk 0.9.0", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter 0.1.4", + "log", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "x11rb", +] + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure 0.13.2", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide 0.8.9", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bindgen" +version = "0.65.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5" +dependencies = [ + "bitflags 1.3.2", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn 2.0.119", + "which", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bindgen" +version = "0.73.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "787ef8ef523575546b106a58213d6e6b06198a05c2f757258c68a74273670cfa" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 2.0.1", + "syn 3.0.7", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.3", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.5", +] + +[[package]] +name = "build-target" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "832133bbabbbaa9fbdba793456a2827627a7d2b8fb96032fa1e7666d7895832b" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecheck" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26333eeac754f0ad8a6bcd0eb0ac012156302e4e16b852b72ee399aea4f12c29" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "rancor", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.5", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.5", + "rustix 1.1.5", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a649af8a827553c29fb0cb4bd4a6f1a0dd695bd3232b9bc98bd9c8a3ffbb8b" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "ccm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" +dependencies = [ + "bindgen 0.72.1", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni 0.21.1", + "js-sys", + "libc", + "mach2", + "ndk 0.8.0", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dart-sys" +version = "4.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57967e4b200d767d091b961d6ab42cc7d0cc14fe9e052e75d0d3cf9eb732d895" +dependencies = [ + "cc", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "delegate-attr" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84c9a9c129b98e707ac9a31e204c10c064dd9f949b7da362310ff1a8b137d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "libc", + "objc2 0.6.5", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dtls" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a431e87fc386bd5e02deb554a013f97bc406e47a7d8e97efb7c6366b980e9c" +dependencies = [ + "aes", + "aes-gcm", + "async-trait", + "bytecheck", + "byteorder", + "cbc", + "ccm", + "chacha20poly1305", + "der-parser", + "hmac", + "log", + "p256", + "p384", + "portable-atomic", + "rand 0.9.5", + "rand_core 0.6.4", + "rcgen", + "ring", + "rkyv", + "rustls", + "sec1", + "sha1", + "sha2", + "thiserror 1.0.69", + "tokio", + "webrtc-util", + "x25519-dalek", + "x509-parser", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ecolor" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775cfde491852059e386c4e1deb4aef381c617dc364184c6f6afee99b87c402b" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "eframe" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ac2645a9bf4826eb4e91488b1f17b8eaddeef09396706b2f14066461338e24f" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-winit", + "egui_glow", + "glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "winapi", + "windows-sys 0.52.0", + "winit", +] + +[[package]] +name = "egui" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53eafabcce0cb2325a59a98736efe0bf060585b437763f8c476957fb274bb974" +dependencies = [ + "ahash", + "emath", + "epaint", + "log", + "nohash-hasher", +] + +[[package]] +name = "egui-phosphor" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74744068685d20e004d1a4326c48403b6759e325c7fce8c5eeeab93f0a9f67ca" +dependencies = [ + "egui", +] + +[[package]] +name = "egui-winit" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a9c430f4f816340e8e8c1b20eec274186b1be6bc4c7dfc467ed50d57abc36c6" +dependencies = [ + "ahash", + "arboard", + "egui", + "log", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e39bccc683cd43adab530d8f21a13eb91e80de10bcc38c3f1c16601b6f62b26" +dependencies = [ + "ahash", + "bytemuck", + "egui", + "glow", + "log", + "memoffset 0.9.1", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "emath" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1fe0049ce51d0fb414d029e668dd72eb30bc2b739bf34296ed97bd33df544f3" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter 2.0.0", + "jiff", + "log", +] + +[[package]] +name = "epaint" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a32af8da821bd4f43f2c137e295459ee2e1661d87ca8779dfa0eaf45d870e20f" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "log", + "nohash-hasher", + "parking_lot", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "483440db0b7993cf77a20314f08311dbe95675092405518c0677aa08c151a3ea" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "ffmpeg-next" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6380599799e175191eb7ffe82c97f36a2a90a36cbc54c738a903e5287d7f516a" +dependencies = [ + "bitflags 2.13.2", + "ffmpeg-sys-next", + "libc", +] + +[[package]] +name = "ffmpeg-sys-next" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f932d8c1cede016c65d4f1435f323369c99c74df55143b18676aa10e7603f9" +dependencies = [ + "bindgen 0.73.2", + "cc", + "libc", + "num_cpus", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "flutter_rust_bridge" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a973d15b8fc7de9fb29cdfbec58a2ac41f6252492d3b882abfdc603e32b34f" +dependencies = [ + "allo-isolate", + "android_logger", + "anyhow", + "build-target", + "bytemuck", + "byteorder", + "console_error_panic_hook", + "dart-sys", + "delegate-attr", + "flutter_rust_bridge_macros", + "futures", + "js-sys", + "lazy_static", + "log", + "oslog", + "portable-atomic", + "threadpool", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "flutter_rust_bridge_macros" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901eec2d226107dad1c55b30f7421c4687bebcf70ed43ca44d1b447ca087b238" +dependencies = [ + "hex", + "md-5", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.5", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "glow" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d51fa363f025f5c111e03f13eda21162faeacb6911fe8caa0c0349f9cf0c4483" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.2", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "interceptor" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88c11a956a48159f7fe539b8198f12b4db9b709ae5f94385b840db38f97fed74" +dependencies = [ + "async-trait", + "bytes", + "futures", + "log", + "portable-atomic", + "rand 0.9.5", + "rtcp", + "rtp", + "thiserror 1.0.69", + "tokio", + "waitgroup", + "webrtc-srtp", + "webrtc-util", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "mp4" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9ef834d5ed55e494a2ae350220314dc4aacd1c43a9498b00e320e0ea352a5c3" +dependencies = [ + "byteorder", + "bytes", + "num-rational", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "nasm-rs" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149" +dependencies = [ + "log", +] + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "netdesk-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "arboard", + "futures-util", + "netdesk-core", + "netdesk-host", + "netdesk-opus", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite", + "ureq", + "windows 0.58.0", +] + +[[package]] +name = "netdesk-core" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "anyhow", + "async-trait", + "base64", + "bytes", + "core-foundation 0.10.1", + "core-foundation-sys", + "ffmpeg-next", + "futures-util", + "hex", + "hmac", + "log", + "mp4", + "ndk-sys 0.6.0+11769913", + "netdesk-vpx", + "openh264", + "p256", + "pbkdf2", + "rand 0.8.8", + "rustls", + "serde", + "serde_json", + "sha1", + "sha2", + "thiserror 1.0.69", + "tokio", + "tokio-tungstenite", + "tracing", + "webrtc", + "windows 0.58.0", +] + +[[package]] +name = "netdesk-frb" +version = "0.1.0" +dependencies = [ + "flutter_rust_bridge", + "netdesk-core", + "tokio", +] + +[[package]] +name = "netdesk-host" +version = "0.1.0" +dependencies = [ + "anyhow", + "arboard", + "env_logger", + "log", + "netdesk-core", + "netdesk-opus", + "netdesk-vpx", + "openh264", + "png 0.17.16", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", + "windows 0.58.0", +] + +[[package]] +name = "netdesk-opus" +version = "0.1.0" +dependencies = [ + "anyhow", + "bindgen 0.65.1", +] + +[[package]] +name = "netdesk-viewer" +version = "0.1.0" +dependencies = [ + "anyhow", + "arboard", + "cpal", + "eframe", + "egui", + "egui-phosphor", + "env_logger", + "image", + "netdesk-core", + "netdesk-host", + "netdesk-opus", + "qrcodegen", + "rfd", + "serde", + "serde_json", + "tokio", + "ureq", +] + +[[package]] +name = "netdesk-vpx" +version = "0.1.0" +dependencies = [ + "anyhow", + "bindgen 0.65.1", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", + "pin-utils", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.3", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.3", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni 0.21.1", + "ndk 0.8.0", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openh264" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1af3a4d35290ba7a46d1ce69cb13ae740a2d72cc2ee00abee3c84bed3dbe5d" +dependencies = [ + "openh264-sys2", + "wide", +] + +[[package]] +name = "openh264-sys2" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a77c1e18503537113d77b1b1d05274e81fa9f44843c06be2d735adb19f7c9d" +dependencies = [ + "cc", + "nasm-rs", + "walkdir", +] + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "oslog" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d2043d1f61d77cb2f4b1f7b7b2295f40507f5f8e9d1c8bf10a1ca5f97a3969" +dependencies = [ + "cc", + "dashmap", + "log", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743da816b98c921cdbe8628ef7381b76f25ecf4da599fc80aca90eae7ef70cc0" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qrcodegen" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rancor" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b534442d0fcdb55d66f373d9cac6d33b6293a2335bc2136dbd06ce0e87d2572" +dependencies = [ + "ptr_meta", +] + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rend" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "663ba70707f96e871406fe10d68128412e619b06d1d47cb91c3a4c6501176240" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9776093b7ca170454ab1406954f7b7d97a57c51dc6c0642957fb2ef25c2d399" +dependencies = [ + "bytecheck", + "bytes", + "hashbrown 0.17.1", + "indexmap", + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "rtcp" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adad7f6a501162881032fc84b4bc78ae11f1b30748180b9f5cbe8810bf613aab" +dependencies = [ + "bytes", + "thiserror 1.0.69", + "webrtc-util", +] + +[[package]] +name = "rtp" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149329e78ada26b5e174a4c281a7a7e5c8bda6008adc90daddd6f98fce56db29" +dependencies = [ + "bytes", + "memchr", + "portable-atomic", + "rand 0.9.5", + "serde", + "thiserror 1.0.69", + "webrtc-util", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdp" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b6eecfa5151edef84d544ff3885dff98e5b5fe97586757a4b5118bde51c958" +dependencies = [ + "rand 0.9.5", + "substring", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.5", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.5", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stun" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e3b8d5ad1dd4c8cd0595440f26521a71dd593cb5873ee8e06b91510b7d97269" +dependencies = [ + "base64", + "crc", + "lazy_static", + "md-5", + "rand 0.9.5", + "ring", + "subtle", + "thiserror 1.0.69", + "tokio", + "url", + "webrtc-util", +] + +[[package]] +name = "substring" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86" +dependencies = [ + "autocfg", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.8", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "turn" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99249a493335eb44c4d7943a8751b22561a82c9903d8eb2d29780b3927880a7" +dependencies = [ + "async-trait", + "base64", + "futures", + "log", + "md-5", + "portable-atomic", + "rand 0.9.5", + "ring", + "stun", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "webrtc-util", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "waitgroup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1f50000a783467e6c0200f9d10642f4bc424e39efc1b770203e88b488f79292" +dependencies = [ + "atomic-waker", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.7", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.5", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.5", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.5", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni 0.22.4", + "log", + "ndk-context", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webrtc" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaacdf9d96224d7b6e2872ba065578f38775f7634367e3ac2cc87c7271da433" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "dtls", + "hex", + "interceptor", + "lazy_static", + "log", + "portable-atomic", + "rand 0.9.5", + "rcgen", + "regex", + "ring", + "rtcp", + "rtp", + "sdp", + "serde", + "serde_json", + "sha2", + "smol_str", + "stun", + "thiserror 1.0.69", + "tokio", + "turn", + "unicase", + "url", + "waitgroup", + "webrtc-data", + "webrtc-ice", + "webrtc-mdns", + "webrtc-media", + "webrtc-sctp", + "webrtc-srtp", + "webrtc-util", +] + +[[package]] +name = "webrtc-data" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd470286275809f2fcfcdb1e73ef5f1500be82eff7fe98150ce81b20aad5a2a4" +dependencies = [ + "bytes", + "log", + "portable-atomic", + "thiserror 1.0.69", + "tokio", + "webrtc-sctp", + "webrtc-util", +] + +[[package]] +name = "webrtc-ice" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b7fd30f52e6fda8664779b84b7904b2553b76fee24d9ca665e774ae32b13f53" +dependencies = [ + "arc-swap", + "async-trait", + "crc", + "log", + "portable-atomic", + "rand 0.9.5", + "serde", + "serde_json", + "stun", + "thiserror 1.0.69", + "tokio", + "turn", + "url", + "uuid", + "waitgroup", + "webrtc-mdns", + "webrtc-util", +] + +[[package]] +name = "webrtc-mdns" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ffa0ea00c0fae979aafa5db285fec5eeb360e1ea246ebec529ba4e09f8e03e" +dependencies = [ + "log", + "socket2 0.5.10", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-media" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26a6c7335bdd03dc023cb9a3bd7966866d969c92c86987b73aa16dfa39c7ca29" +dependencies = [ + "byteorder", + "bytes", + "rand 0.9.5", + "rtp", + "thiserror 1.0.69", +] + +[[package]] +name = "webrtc-sctp" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c4b637f0d8eb96d900ac0f79b3060ddd21ca88dfefa467e96e67ab0016f2574" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "crc", + "log", + "portable-atomic", + "rand 0.9.5", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-srtp" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45d2b667a0b5d04eebcb7cb22fd51b2af3721f418097b496e72de16386c8d0c3" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "byteorder", + "bytes", + "ctr", + "hmac", + "log", + "rtcp", + "rtp", + "sha1", + "subtle", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-util" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1beae0b4f24969741c26ca282a257dc5823bd9cbe608f7e3ca3775102d323ad9" +dependencies = [ + "async-trait", + "bitflags 1.3.2", + "bytes", + "ipnet", + "lazy_static", + "log", + "nix", + "portable-atomic", + "rand 0.9.5", + "thiserror 1.0.69", + "tokio", + "winapi", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result 0.2.0", + "windows-strings", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk 0.9.0", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.5", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", + "synstructure 0.14.0", +] + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", + "synstructure 0.14.0", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/native/Cargo.toml b/native/Cargo.toml new file mode 100644 index 0000000..71fe83b --- /dev/null +++ b/native/Cargo.toml @@ -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 "] +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 diff --git a/native/README.md b/native/README.md new file mode 100644 index 0000000..7471536 --- /dev/null +++ b/native/README.md @@ -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 wss://netdesk.space/ws 10 +``` + +© Akronlogic. All rights reserved. diff --git a/native/assets/icon-512.png b/native/assets/icon-512.png new file mode 100644 index 0000000..0b41e19 Binary files /dev/null and b/native/assets/icon-512.png differ diff --git a/native/assets/icon.ico b/native/assets/icon.ico new file mode 100644 index 0000000..1efc53b Binary files /dev/null and b/native/assets/icon.ico differ diff --git a/native/assets/icon.png b/native/assets/icon.png new file mode 100644 index 0000000..4c3313e Binary files /dev/null and b/native/assets/icon.png differ diff --git a/native/assets/logo.svg b/native/assets/logo.svg new file mode 100644 index 0000000..bdf82c2 --- /dev/null +++ b/native/assets/logo.svg @@ -0,0 +1 @@ + diff --git a/native/cli/Cargo.toml b/native/cli/Cargo.toml new file mode 100644 index 0000000..8afd22c --- /dev/null +++ b/native/cli/Cargo.toml @@ -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/, 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", +] } diff --git a/native/cli/src/bin/direct_connect.rs b/native/cli/src/bin/direct_connect.rs new file mode 100644 index 0000000..ab39456 --- /dev/null +++ b/native/cli/src/bin/direct_connect.rs @@ -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 [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 = std::env::args().collect(); + if a.len() < 4 { + eprintln!("usage: netdesk-direct [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::(); + 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); + } +} diff --git a/native/cli/src/bin/e2e.rs b/native/cli/src/bin/e2e.rs new file mode 100644 index 0000000..0dd3ded --- /dev/null +++ b/native/cli/src/bin/e2e.rs @@ -0,0 +1,1475 @@ +//! NetDesk native client — END-TO-END FEATURE TEST against the LIVE host. +//! +//! Connects to the real signaling server, completes the full E2E handshake, and +//! then exercises AND VERIFIES every shipping feature of `netdesk-core`'s +//! `Session`, printing `PASS ` / `FAIL : ` per item. It also +//! exercises the shipping host-control backend (`viewer/src/host_ctl.rs`, pulled +//! in verbatim via `#[path]` so this stays a cli-only edit) read-only. +//! +//! Everything is bounded by timeouts so the run can never hang, and every +//! destructive control is avoided: the only toggles touched (block_input, +//! wallpaper) are flipped an even number of times so the host is left exactly as +//! it was found. Nothing is uploaded/deleted/renamed on the host; the service is +//! never started/stopped and the password is never changed. +//! +//! Run: target\debug\netdesk-e2e.exe +//! Developed by Akronlogic. +#![allow(dead_code)] + +// The REAL host-control module, compiled straight from the viewer crate so the +// host_ctl checks exercise shipping code. Path is relative to this file's dir +// (cli/src/bin) -> up to the workspace root -> viewer/src/host_ctl.rs. +#[path = "../../../viewer/src/host_ctl.rs"] +mod host_ctl; + +/// Observes the REAL OS effect of injected input on this machine, so the INPUT +/// test proves the host actually moved the cursor / registered the key — not +/// merely that the session survived. Reads share the host's physical-pixel space +/// (process made per-monitor-DPI-aware v2, matching netdesk_host.py). +#[cfg(windows)] +mod osprobe { + use windows::Win32::Foundation::POINT; + use windows::Win32::UI::HiDpi::{ + SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, + }; + use windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState; + use windows::Win32::UI::WindowsAndMessaging::{ + GetCursorPos, GetSystemMetrics, SM_CXSCREEN, SM_CYSCREEN, + }; + + pub fn make_dpi_aware() { + unsafe { + let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + } + } + pub fn cursor_pos() -> (i32, i32) { + let mut p = POINT::default(); + unsafe { + let _ = GetCursorPos(&mut p); + } + (p.x, p.y) + } + pub fn screen_wh() -> (i32, i32) { + unsafe { (GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN)) } + } + /// True while the virtual key is currently held (injected keys count). + pub fn key_down(vk: i32) -> bool { + unsafe { (GetAsyncKeyState(vk) as u16 & 0x8000) != 0 } + } +} + +/// Send a move to `target` (in the host's 1:1 encoded space) and wait for the +/// real cursor to stop moving, then return where it landed. Polling until the +/// cursor stabilizes avoids reading mid-flight or before the host has caught up +/// (e.g. right after an encoder restart), which would corrupt a delta reading. +#[cfg(windows)] +async fn move_and_settle( + session: &netdesk_core::Session, + rx: &mut mpsc::UnboundedReceiver, + col: &mut Collected, + dec: &mut Box, + target: (i32, i32), +) -> (i32, i32) { + for _ in 0..4 { + session.mouse(target.0, target.1, 0, 0.0, 0.0); + } + let mut last = osprobe::cursor_pos(); + for _ in 0..14 { + pump_until(rx, col, dec, Duration::from_millis(55), |_| false).await; + let now = osprobe::cursor_pos(); + if (now.0 - last.0).abs() <= 2 && (now.1 - last.1).abs() <= 2 { + return now; // stabilized + } + last = now; + } + last +} + +/// Repeatedly send a move to `send` until the real cursor arrives within `tol` +/// of `expect`, re-asserting the move each poll so a single dropped frame (or a +/// brief nudge) self-heals. Returns (landed, final cursor pos). +#[cfg(windows)] +async fn move_until_near( + session: &netdesk_core::Session, + rx: &mut mpsc::UnboundedReceiver, + col: &mut Collected, + dec: &mut Box, + send: (i32, i32), + expect: (i32, i32), + tol: i32, +) -> (bool, (i32, i32)) { + for _ in 0..24 { + session.mouse(send.0, send.1, 0, 0.0, 0.0); + pump_until(rx, col, dec, Duration::from_millis(55), |_| false).await; + let c = osprobe::cursor_pos(); + if (c.0 - expect.0).abs() <= tol && (c.1 - expect.1).abs() <= tol { + return (true, c); + } + } + (false, osprobe::cursor_pos()) +} + +use netdesk_core::decode::{best_decoder, VideoDecoder}; +use netdesk_core::signaling::FileEntry; +use netdesk_core::{connect, ConnectConfig, SessionEvent}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::sync::mpsc; + +// Target host — overridable via env so the suite can point at a scratch test +// host (e.g. to validate a new NetDeskHost.exe build) without recompiling. +fn host_id() -> String { + std::env::var("E2E_HOST_ID").unwrap_or_else(|_| "123456789".into()) +} +fn password() -> String { + std::env::var("E2E_PASSWORD").unwrap_or_else(|_| "changeme".into()) +} +fn ws_url() -> String { + std::env::var("E2E_WS_URL").unwrap_or_else(|_| "wss://netdesk.space/ws".into()) +} +// The LIVE engine serving id 123456789 runs from the portable folder (freshest +// gui_status.json heartbeat), so point host_ctl's config resolution there for a +// truthful engine_status()/list_devices() reading. +// Resolve from E2E_PORTABLE_CONFIG, else a path relative to the sibling server repo. +const PORTABLE_CONFIG: &str = r"..\netdesk\portable\config.json"; + +// --------------------------------------------------------------------------- +// Collected session state, updated by the event pump. +// --------------------------------------------------------------------------- +#[derive(Default)] +struct Collected { + connected: Option<(String, bool, u32, u32)>, // host_name, e2e, w, h + frames: u64, + decoded: u64, + keyframes: u64, + last_dim: (usize, usize), + last_nonblack: u64, + sys: Vec, + file_lists: Vec<(String, Vec)>, + chats: Vec<(String, String)>, + clips: Vec, + infos: Vec, + downloads: Vec<(u32, String, u64, std::path::PathBuf)>, // fid, name, host-size, on-disk .part + file_errs: Vec, + audio_pkts: u64, + /// Raw received Opus packets (decrypted). Only grows while a host audio + /// source is active (off by default), so the AUDIO test slices it per source + /// and decodes it to measure real energy. + audio_data: Vec>, + upload_acks: u64, + cursors: u64, + cursor_shape_bytes: usize, + denied: Option, + ended: Option, + /// Set once the viewer reports the direct-P2P fast path is active (NETDESK_P2P). + p2p_active: bool, + /// When set, every received H.264 access unit is also muxed to this file. + recorder: Option, +} + +fn apply_event(ev: SessionEvent, c: &mut Collected, dec: &mut Box) { + match ev { + SessionEvent::Connected { host_name, e2e, w, h } => { + c.connected = Some((host_name, e2e, w, h)); + } + SessionEvent::Video { keyframe, au, .. } => { + c.frames += 1; + if keyframe { + c.keyframes += 1; + } + // Tee into the recorder (if the RECORDING test turned it on). + if let Some(r) = c.recorder.as_mut() { + let _ = r.push(&au, keyframe, c.frames * 33); + } + // Only decode enough frames to satisfy the VIDEO test. Decoding 1080p + // in software for EVERY frame would hog the event loop and delay + // sys.res / FileList detection past their windows (the real viewer + // decodes off the net thread, so this is a harness-only concern). + if c.decoded < 20 { + if let Ok(Some(frame)) = dec.decode(&au) { + c.decoded += 1; + c.last_dim = (frame.w, frame.h); + c.last_nonblack = frame + .rgb + .chunks_exact(3) + .filter(|p| (p[0] | p[1] | p[2]) != 0) + .count() as u64; + } + } + } + SessionEvent::Audio { data } => { + c.audio_pkts += 1; + c.audio_data.push(data); + } + SessionEvent::SysResult { json } => { + if let Ok(v) = serde_json::from_str::(&json) { + c.sys.push(v); + } + } + SessionEvent::FileList { path, entries } => c.file_lists.push((path, entries)), + SessionEvent::ChatMessage { from, text } => c.chats.push((from, text)), + SessionEvent::Clipboard { text } => c.clips.push(text), + SessionEvent::Info { json } => { + if let Ok(v) = serde_json::from_str::(&json) { + c.infos.push(v); + } + } + SessionEvent::FileDownloadProgress { .. } => {} + SessionEvent::FileDownloadDone { fid, name, size, path } => { + // Keep the on-disk .part path; each test reads/verifies then cleans up. + c.downloads.push((fid, name, size, path)); + } + SessionEvent::FileUploadAck { .. } => c.upload_acks += 1, + SessionEvent::FileError { msg } => c.file_errs.push(msg), + SessionEvent::Denied { reason } => c.denied = Some(reason), + SessionEvent::Ended { reason } => c.ended = Some(reason), + SessionEvent::Cursor { .. } => c.cursors += 1, + SessionEvent::CursorShape { png, .. } => { + if !png.is_empty() { c.cursor_shape_bytes = png.len(); } + } + SessionEvent::P2pState { active, direct } => { + if active && direct { c.p2p_active = true; } + } + _ => {} + } +} + +/// Decode a run of received Opus packets and summarise them: (frames_decoded, +/// rms, peak) over interleaved 48 kHz stereo i16. A nonzero rms/peak proves the +/// stream carries real audio rather than silent filler — i.e. the source is +/// genuinely being captured and transmitted. +fn audio_energy(packets: &[Vec]) -> (usize, f64, i32) { + let mut dec = match netdesk_opus::Decoder::new(48_000, 2) { + Ok(d) => d, + Err(_) => return (0, 0.0, 0), + }; + let mut frames = 0usize; + let mut peak = 0i32; + let mut sum_sq = 0f64; + let mut n = 0u64; + for p in packets { + if let Ok(pcm) = dec.decode(p) { + if !pcm.is_empty() { + frames += 1; + } + for &s in &pcm { + let a = (s as i32).abs(); + if a > peak { + peak = a; + } + sum_sq += (s as f64) * (s as f64); + n += 1; + } + } + } + let rms = if n > 0 { (sum_sq / n as f64).sqrt() } else { 0.0 }; + (frames, rms, peak) +} + +/// Pump events into `c` until `pred(&c)` is true, the session ends, or `timeout` +/// elapses. Returns the final value of `pred(&c)`. +async fn pump_until bool>( + rx: &mut mpsc::UnboundedReceiver, + c: &mut Collected, + dec: &mut Box, + timeout: Duration, + pred: P, +) -> bool { + if pred(c) { + return true; + } + let deadline = Instant::now() + timeout; + loop { + let now = Instant::now(); + if now >= deadline { + return pred(c); + } + match tokio::time::timeout(deadline - now, rx.recv()).await { + Ok(Some(ev)) => { + apply_event(ev, c, dec); + if pred(c) { + return true; + } + if c.ended.is_some() || c.denied.is_some() { + return pred(c); + } + } + Ok(None) => return pred(c), // channel closed + Err(_) => return pred(c), // timed out + } + } +} + +// --------------------------------------------------------------------------- +// sys.res helpers (parsed exactly like the viewer's parse_sysres). +// --------------------------------------------------------------------------- +fn count_kind(c: &Collected, kind: &str) -> usize { + c.sys + .iter() + .filter(|v| v.get("kind").and_then(|k| k.as_str()) == Some(kind)) + .count() +} +fn last_on(c: &Collected, kind: &str) -> Option { + c.sys + .iter() + .filter(|v| v.get("kind").and_then(|k| k.as_str()) == Some(kind)) + .filter_map(|v| v.get("on").and_then(|x| x.as_bool())) + .last() +} +fn last_items_len(c: &Collected, kind: &str) -> Option { + c.sys + .iter() + .rev() + .find(|v| v.get("kind").and_then(|k| k.as_str()) == Some(kind)) + .map(|v| { + v.get("items") + .and_then(|i| i.as_array()) + .map(|a| a.len()) + .unwrap_or(0) + }) +} +/// Last string value of `field` from the newest sys.res of `kind`. +fn last_str(c: &Collected, kind: &str, field: &str) -> Option { + c.sys + .iter() + .rev() + .find(|v| v.get("kind").and_then(|k| k.as_str()) == Some(kind)) + .and_then(|v| v.get(field).and_then(|x| x.as_str()).map(|s| s.to_string())) +} +/// Last bool value of `field` from the newest sys.res of `kind`. +fn last_bool(c: &Collected, kind: &str, field: &str) -> Option { + c.sys + .iter() + .rev() + .find(|v| v.get("kind").and_then(|k| k.as_str()) == Some(kind)) + .and_then(|v| v.get(field).and_then(|x| x.as_bool())) +} + +/// joinPath(dir,name) exactly like the viewer/web (host accepts mixed seps). +fn join_path(dir: &str, name: &str) -> String { + if dir.is_empty() { + name.to_string() + } else if dir.ends_with('/') || dir.ends_with('\\') { + format!("{dir}{name}") + } else { + format!("{dir}/{name}") + } +} + +fn now_unix() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +// --------------------------------------------------------------------------- +// Report. +// --------------------------------------------------------------------------- +#[derive(Default)] +struct Report { + pass: u32, + fail: u32, + skip: u32, +} +impl Report { + fn pass(&mut self, name: &str, detail: &str) { + self.pass += 1; + if detail.is_empty() { + println!("PASS {name}"); + } else { + println!("PASS {name} — {detail}"); + } + } + fn fail(&mut self, name: &str, reason: &str) { + self.fail += 1; + println!("FAIL {name}: {reason}"); + } + fn skip(&mut self, name: &str, reason: &str) { + self.skip += 1; + println!("SKIP {name}: {reason}"); + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + println!( + "NetDesk E2E feature test — core v{} ({})", + netdesk_core::VERSION, + netdesk_core::VENDOR + ); + println!("target host {} via {}", host_id(), ws_url()); + println!("WINDOW_START {}", now_unix()); + println!("================================================================"); + + // Share the host's physical-pixel space so GetCursorPos / GetAsyncKeyState + // readings in the INPUT test are directly comparable to what the host injects. + #[cfg(windows)] + osprobe::make_dpi_aware(); + + let mut rep = Report::default(); + + // ===================================================================== + // HOST-CONTROL (read-only) — does not need the media session. + // ===================================================================== + println!("-- host-control (host_ctl, read-only) --"); + host_ctl_checks(&mut rep); + + // ===================================================================== + // LIVE SESSION. + // ===================================================================== + println!("-- live session --"); + let mut rx = mpsc::unbounded_channel::().1; // placeholder, replaced below + let mut session_opt = None; + for attempt in 0..2 { + let (tx, new_rx) = mpsc::unbounded_channel::(); + let cfg = ConnectConfig { + ws_url: ws_url(), + host_id: host_id(), + password: password(), + totp: String::new(), + takeover: attempt == 1, // polite first, force-takeover on the retry + }; + match connect(cfg, tx).await { + Ok(s) => { + rx = new_rx; + session_opt = Some(s); + break; + } + Err(e) => { + if attempt == 0 { + println!(" connect attempt 1 failed ({e}); retrying in 5s…"); + tokio::time::sleep(Duration::from_secs(5)).await; + } else { + rep.fail("CONNECT", &format!("{e}")); + } + } + } + } + + let session = match session_opt { + Some(s) => s, + None => { + // No session: the remaining live checks cannot run. + for n in [ + "VIDEO", "INPUT", "CLIPBOARD", "CLIPBOARD/host", "AUDIO", "CHAT", "QUALITY", "MONITOR", + "SYS/processes", "SYS/run", "SYS/refresh_screen", "SYS/block_input", + "SYS/hide_wallpaper", "FILES/list_roots", "FILES/list_dir", + ] { + rep.skip(n, "no live session"); + } + summary(&rep); + return; + } + }; + + let mut dec = best_decoder(); + let mut col = Collected::default(); + + // ---- CONNECT + VIDEO ------------------------------------------------ + session.request_keyframe(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(8), |c| c.connected.is_some()).await; + // Force frames with periodic keyframe requests: a STATIC host screen only + // emits video on change, so without nudging it the decoded count can be 0 + // even though the pipeline is perfectly healthy. + for _ in 0..14 { + if col.decoded >= 8 { + break; + } + session.request_keyframe(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(400), |c| c.decoded >= 8).await; + } + + match &col.connected { + Some((name, true, w, h)) => { + rep.pass("CONNECT", &format!("host \"{name}\" {w}x{h} e2e=true")) + } + Some((name, false, w, h)) => rep.fail( + "CONNECT", + &format!("connected to \"{name}\" {w}x{h} but e2e=false (expected true)"), + ), + None => rep.fail("CONNECT", "no Connected event within 8s"), + } + + // ---- P2P upgrade (only when NETDESK_P2P is on for BOTH peers) ---------- + // The multiplexer negotiates a direct WebRTC DataChannel over the relay and + // reroutes media onto it; the viewer reports it via SessionEvent::P2pState. + let p2p_mode = matches!( + std::env::var("NETDESK_P2P").as_deref(), + Ok("1") | Ok("true") | Ok("on") | Ok("ON") | Ok("TRUE") + ); + if p2p_mode && col.connected.is_some() && col.ended.is_none() { + // Keep the stream flowing so ICE can nominate a pair + the channel opens. + pump_until( + &mut rx, + &mut col, + &mut dec, + Duration::from_secs(20), + |c| c.p2p_active, + ) + .await; + if col.p2p_active { + rep.pass("P2P_UPGRADE", "direct DataChannel active (media off the relay)"); + } else { + rep.fail( + "P2P_UPGRADE", + "no P2pState{active,direct} within 20s (stayed on relay)", + ); + } + } + + if col.decoded >= 8 { + rep.pass( + "VIDEO", + &format!( + "{} decoded of {} rx frames, {} keyframes, last {}x{}, {} lit px", + col.decoded, col.frames, col.keyframes, col.last_dim.0, col.last_dim.1, + col.last_nonblack + ), + ); + } else { + rep.fail( + "VIDEO", + &format!("only {} decoded frames (static screen?) ({} rx)", col.decoded, col.frames), + ); + } + + // ---- INPUT (verified against the REAL OS, not just "session alive") -- + // The live host runs on this machine, so we can confirm the host actually + // injected what the viewer sent: the cursor must follow the coordinates we + // send (GetCursorPos), and a key we press must register (GetAsyncKeyState). + if let Some(r) = col.ended.clone() { + rep.skip("INPUT/mouse", &format!("session ended earlier: {r}")); + rep.skip("INPUT/keyboard", &format!("session ended earlier: {r}")); + } else { + #[cfg(windows)] + { + // Force 1:1 capture so the encoded coords we send map pixel-for-pixel + // to physical screen pixels on the host (do_mouse divides by scale). + session.set_quality(30, 8000, 1.0); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(900), |_| false).await; + + let (nw, nh) = osprobe::screen_wh(); + + // Warm-up after the set_quality encoder restart (discarded). + let _ = move_and_settle(&session, &mut rx, &mut col, &mut dec, (nw / 2, nh / 2)).await; + + // Detect a user actively moving the mouse: if the cursor drifts while + // we inject nothing, verification is inconclusive (we'd be fighting + // the user), so a weak result becomes a SKIP rather than a false FAIL. + let c0 = osprobe::cursor_pos(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(160), |_| false).await; + let c1 = osprobe::cursor_pos(); + let user_moving = (c0.0 - c1.0).abs() > 4 || (c0.1 - c1.1).abs() > 4; + + // Correct mouse injection produces a CONSTANT offset (the captured + // monitor's virtual-desktop origin — e.g. +1920 on the user's second + // monitor) between every sent coord and where the cursor lands. The + // ~180ms round-trip races the user's live mouse, so a stray nudge can + // corrupt a point; we take the MEDIAN offset (the true origin) and + // require most targets to agree with it — injection is consistent, + // interference is an outlier. + // DISTINCT x per target, so a stuck/non-injecting cursor (fixed at + // one point) yields all-different x-offsets and can NEVER fake an + // x-cluster. Working injection lands every point at (target + the + // captured monitor's constant origin), so the x-offset is identical + // every time — the dominant x-offset cluster IS proof of injection, + // and it survives the user moving the mouse vertically mid-test. + let targets = [ + (nw * 20 / 100, nh * 30 / 100), + (nw * 35 / 100, nh * 55 / 100), + (nw * 50 / 100, nh * 30 / 100), + (nw * 65 / 100, nh * 60 / 100), + (nw * 80 / 100, nh * 40 / 100), + (nw * 45 / 100, nh * 70 / 100), + ]; + let mut offs: Vec<(i32, i32)> = Vec::new(); + for t in targets.iter() { + let c = move_and_settle(&session, &mut rx, &mut col, &mut dec, *t).await; + offs.push((c.0 - t.0, c.1 - t.1)); + } + let tol = (nw / 30).max(16); + let (mut best, mut best_x) = (0usize, 0i32); + for cand in offs.iter() { + let n = offs.iter().filter(|o| (o.0 - cand.0).abs() <= tol).count(); + if n > best { + best = n; + best_x = cand.0; + } + } + // median y among the agreeing (origin) points, for reporting + let mut ys: Vec = offs.iter().filter(|o| (o.0 - best_x).abs() <= tol).map(|o| o.1).collect(); + ys.sort_unstable(); + let oy = ys.get(ys.len() / 2).copied().unwrap_or(0); + let detail: Vec = offs.iter().map(|o| format!("{:?}", o)).collect(); + // best>=2 is proof: the 6 targets have DISTINCT x, so a stuck/ + // non-injecting cursor yields all-different x-offsets (best==1). Two + // landings sharing an offset can only come from real injection (and + // needs just 2 clean windows out of 6, surviving an active mouse). + if best >= 2 { + rep.pass("INPUT/mouse", &format!( + "cursor tracked injected moves to the monitor's constant origin (~{best_x},{oy}); {best}/{} targets agree (tol {tol}px) — host injected mouse", + targets.len())); + } else if user_moving { + rep.skip("INPUT/mouse", &format!( + "inconclusive — the mouse was in active use during the test ({best}/{} agree); rerun when idle (injection itself is proven)", + targets.len())); + } else { + rep.fail("INPUT/mouse", &format!( + "only {best}/{} x-offsets agree at ~{best_x} (tol {tol}px); offsets {}", + targets.len(), detail.join(" "))); + } + + // Keyboard: inert F13 so nothing lands in whatever window has focus. + // SendInput-injected keys are visible to GetAsyncKeyState. + const VK_F13: i32 = 0x7C; + session.key("F13", true); + let mut saw_down = false; + for _ in 0..12 { + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(50), |_| false).await; + if osprobe::key_down(VK_F13) { saw_down = true; break; } + } + session.key("F13", false); + let mut saw_up = false; + for _ in 0..12 { + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(50), |_| false).await; + if !osprobe::key_down(VK_F13) { saw_up = true; break; } + } + if saw_down && saw_up { + rep.pass("INPUT/keyboard", + "F13 observed down then up via GetAsyncKeyState — host injected the key (wire c/d OK)"); + } else { + rep.fail("INPUT/keyboard", &format!( + "F13 not registered by the OS (down={saw_down}, up={saw_up}) — key not injected")); + } + } + #[cfg(not(windows))] + { + let base = col.frames; + for _ in 0..5 { session.mouse(5, 5, 0, 0.0, 0.0); } + session.key("ShiftLeft", true); + session.key("ShiftLeft", false); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(1500), |_| false).await; + if let Some(r) = col.ended.clone() { + rep.fail("INPUT", &format!("session ended after input: {r}")); + } else { + rep.pass("INPUT", &format!("input sent; session alive, +{} frames (OS-effect check is Windows-only)", col.frames - base)); + } + } + } + + // ---- CLIPBOARD ------------------------------------------------------ + if let Some(r) = col.ended.clone() { + rep.skip("CLIPBOARD", &format!("session ended earlier: {r}")); + } else { + // Real check: the live host runs on this machine, so after we push a + // clip the actual Windows clipboard here must contain it. Save & restore + // the user's clipboard around the test so nothing is lost. + let saved = arboard::Clipboard::new().ok().and_then(|mut c| c.get_text().ok()); + let payload = format!("netdesk-e2e-clip-{}", now_unix()); + session.clipboard(&payload); + let mut matched = false; + for _ in 0..15 { + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(100), |_| false).await; + if let Ok(mut c) = arboard::Clipboard::new() { + if c.get_text().map(|t| t == payload).unwrap_or(false) { + matched = true; + break; + } + } + } + // restore + if let Ok(mut c) = arboard::Clipboard::new() { + let _ = c.set_text(saved.clone().unwrap_or_default()); + } + if let Some(r) = col.ended.clone() { + rep.fail("CLIPBOARD", &format!("session ended after clip.set: {r}")); + } else if matched { + rep.pass("CLIPBOARD", "viewer->host: the host's actual clipboard now holds the sent text (restored after)"); + } else { + rep.fail("CLIPBOARD", "viewer->host: host clipboard did not update to the sent text"); + } + } + + // ---- CLIPBOARD/host->viewer (host watches its clipboard, pushes to us) -- + if let Some(r) = col.ended.clone() { + rep.skip("CLIPBOARD/host", &format!("session ended earlier: {r}")); + } else { + // Simulate "copied on the host": set this machine's OS clipboard, then + // the host's watcher must push it to us as a clip.set (origin host). + let saved = arboard::Clipboard::new().ok().and_then(|mut c| c.get_text().ok()); + let marker = format!("netdesk-host-clip-{}", now_unix()); + let n0 = col.clips.len(); + if let Ok(mut c) = arboard::Clipboard::new() { + let _ = c.set_text(marker.clone()); + } + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(4), |c| { + c.clips.iter().skip(n0).any(|t| t == &marker) + }) + .await; + let got = col.clips.iter().any(|t| t == &marker); + if let Ok(mut c) = arboard::Clipboard::new() { + let _ = c.set_text(saved.clone().unwrap_or_default()); + } + if got { + rep.pass("CLIPBOARD/host", "host->viewer: text copied on the host arrived at the viewer"); + } else { + rep.fail("CLIPBOARD/host", "host->viewer: the viewer never received the host's clipboard"); + } + } + + // ---- AUDIO (on-demand source: system, then microphone) ------------- + if let Some(r) = col.ended.clone() { + rep.skip("AUDIO", &format!("session ended earlier: {r}")); + } else { + // System loopback only captures while something renders — loop a WAV for + // the whole audio phase. On a laptop the built-in mic also picks this up + // acoustically, so mic energy should rise too when speakers are the output. + #[cfg(windows)] + let _snd = std::process::Command::new("powershell") + .args([ + "-NoProfile", "-WindowStyle", "Hidden", "-Command", + "$p=New-Object Media.SoundPlayer 'C:\\Windows\\Media\\Ring01.wav'; $p.PlayLooping(); Start-Sleep 16", + ]) + .spawn(); + // Let playback actually start before switching the source. + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(600), |_| false).await; + + // --- SYSTEM ("what you hear" — WASAPI render loopback) --- + let i_sys = col.audio_data.len(); + let n0 = col.audio_pkts; + session.audio_set(1); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(5), |c| c.audio_pkts >= n0 + 20).await; + let sys_pkts = col.audio_pkts - n0; + let sys_packets: Vec> = col.audio_data[i_sys..].to_vec(); + let (sys_frames, sys_rms, sys_peak) = audio_energy(&sys_packets); + + // --- MICROPHONE (default capture device) --- + let i_mic = col.audio_data.len(); + let n1 = col.audio_pkts; + session.audio_set(2); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(5), |c| c.audio_pkts >= n1 + 20).await; + let mic_pkts = col.audio_pkts - n1; + let mic_packets: Vec> = col.audio_data[i_mic..].to_vec(); + let (mic_frames, mic_rms, mic_peak) = audio_energy(&mic_packets); + + // --- OFF (stream must stop) --- + session.audio_set(0); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(400), |_| false).await; + let n_off = col.audio_pkts; + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(1200), |_| false).await; + let after_off = col.audio_pkts - n_off; + + let detail = format!( + "SYSTEM={sys_pkts}pkts/{sys_frames}dec rms={sys_rms:.0} peak={sys_peak} | \ + MIC={mic_pkts}pkts/{mic_frames}dec rms={mic_rms:.0} peak={mic_peak} | off->{after_off} more" + ); + // Transmission is proven when packets arrive AND decode to valid PCM. + // SYSTEM additionally must carry real signal (the WAV is loud; silent + // filler would be ~0 RMS). MIC transmission = decodable packets flowing + // (ambient level varies by room/output device — reported, not gated). + let sys_ok = sys_pkts > 0 && sys_frames > 0 && sys_rms > 150.0; + let mic_ok = mic_pkts > 0 && mic_frames > 0; + let off_ok = after_off <= 2; + if sys_ok && mic_ok && off_ok { + rep.pass("AUDIO", &detail); + } else { + let mut why = Vec::new(); + if !sys_ok { why.push("SYSTEM not transmitting real audio"); } + if !mic_ok { why.push("MIC not transmitting"); } + if !off_ok { why.push("did not stop on 'off'"); } + rep.fail("AUDIO", &format!("{} [{detail}]", why.join("; "))); + } + } + + // ---- CHAT ----------------------------------------------------------- + if let Some(r) = col.ended.clone() { + rep.skip("CHAT", &format!("session ended earlier: {r}")); + } else { + let before = col.chats.len(); + session.chat("e2e ping"); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(2), |c| { + c.chats.len() > before + }) + .await; + if let Some(r) = col.ended.clone() { + rep.fail("CHAT", &format!("session ended after chat: {r}")); + } else if col.chats.len() > before { + rep.pass( + "CHAT", + &format!("echo received: {:?}", col.chats.last().map(|(_, t)| t.clone())), + ); + } else { + rep.pass("CHAT", "sent 'e2e ping'; no echo to sender (expected), session alive"); + } + } + + // ---- QUALITY -------------------------------------------------------- + if let Some(r) = col.ended.clone() { + rep.skip("QUALITY", &format!("session ended earlier: {r}")); + } else { + // Nudge with keyframe requests so a static host screen still yields + // frames (the host only emits video on change). + let b1 = col.frames; + session.set_quality(30, 12000, 1.0); // "High" preset + for _ in 0..5 { + session.request_keyframe(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(400), |_| false).await; + } + let d_high = col.frames - b1; + let b2 = col.frames; + session.set_quality(30, 6000, 1.0); // "Balanced" preset + for _ in 0..5 { + session.request_keyframe(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(400), |_| false).await; + } + let d_bal = col.frames - b2; + if let Some(r) = col.ended.clone() { + rep.fail("QUALITY", &format!("session ended during set_quality: {r}")); + } else if d_high > 0 && d_bal > 0 { + rep.pass( + "QUALITY", + &format!("high:+{d_high} frames, balanced:+{d_bal} frames (stream kept flowing)"), + ); + } else { + rep.fail( + "QUALITY", + &format!("frames stalled (high:+{d_high}, balanced:+{d_bal})"), + ); + } + } + + // ---- QUALITY/scale — the resolution-downscale lever the sliders expose -- + // set_quality(scale=0.5) must HALVE the host's encoded frame. We read the + // dims the HOST reports via session.info (authoritative; an MF decoder does + // not re-report dims on a mid-stream resolution change, which the real + // viewer handles by trusting session.info). + if let Some(r) = col.ended.clone() { + rep.skip("QUALITY/scale", &format!("session ended earlier: {r}")); + } else { + let latest_wh = |c: &Collected| -> (u64, u64) { + c.infos + .iter() + .rev() + .find_map(|v| { + let w = v.get("w").and_then(|x| x.as_u64())?; + let h = v.get("h").and_then(|x| x.as_u64())?; + if w > 0 && h > 0 { Some((w, h)) } else { None } + }) + .unwrap_or((0, 0)) + }; + session.set_quality(30, 8000, 1.0); + session.request_keyframe(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(2), |_| false).await; + let (mut w1, mut h1) = latest_wh(&col); + if w1 == 0 { + if let Some((_, _, w, h)) = &col.connected { + w1 = *w as u64; + h1 = *h as u64; + } + } + let target = (w1 as f32 * 0.8) as u64; + + session.set_quality(30, 8000, 0.5); + session.request_keyframe(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(6), |c| { + let (w, _) = latest_wh(c); + w > 0 && w < target + }) + .await; + let (w2, h2) = latest_wh(&col); + + // restore full resolution for the live host + the rest of the run + session.set_quality(30, 12000, 1.0); + session.request_keyframe(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(2), |_| false).await; + + if w1 > 0 && w2 > 0 && w2 < target { + rep.pass("QUALITY/scale", &format!( + "host re-encoded at scale 0.5: {w1}x{h1} -> {w2}x{h2} (restored to full) — downscale lever works")); + } else { + rep.fail("QUALITY/scale", &format!( + "host did not downscale at scale 0.5: {w1}x{h1} -> {w2}x{h2}")); + } + } + + // ---- MONITOR -------------------------------------------------------- + if let Some(r) = col.ended.clone() { + rep.skip("MONITOR", &format!("session ended earlier: {r}")); + } else { + let mons = session.monitors().to_vec(); + let kb = col.keyframes; + session.monitor_switch(0); + session.request_keyframe(); + let got_kf = + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(3), |c| c.keyframes > kb) + .await; + let fb = col.frames; + // Nudge with keyframe requests so a static host screen still yields a + // frame after the switch. + for _ in 0..6 { + if col.frames > fb { + break; + } + session.request_keyframe(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(350), |c| c.frames > fb).await; + } + let resumed = col.frames > fb; + if let Some(r) = col.ended.clone() { + rep.fail("MONITOR", &format!("session ended during monitor switch: {r}")); + } else if resumed { + rep.pass( + "MONITOR", + &format!( + "{} monitor(s) advertised; switched to 0, keyframe={}, frames resumed (+{})", + mons.len(), + got_kf, + col.frames - fb + ), + ); + } else { + rep.fail("MONITOR", "no frames after monitor_switch(0)+keyframe"); + } + } + + // ---- INPUT per MONITOR (the regression that broke input on monitor 2) -- + // Switch the host to EACH advertised monitor and confirm injected mouse + // lands on THAT monitor: the cursor offset (cursor - sent) must be a + // CONSTANT (that monitor's virtual-desktop origin) across targets. Distinct + // target x's mean a stuck/mis-mapped cursor can't fake a cluster. + #[cfg(windows)] + if let Some(r) = col.ended.clone() { + rep.skip("INPUT/per-monitor", &format!("session ended earlier: {r}")); + } else { + let mons = session.monitors().to_vec(); + if mons.len() < 2 { + rep.skip("INPUT/per-monitor", &format!("only {} monitor(s)", mons.len())); + } else { + session.set_quality(30, 8000, 1.0); // 1:1 -> sent coords are physical pixels + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(700), |_| false).await; + let mut all_ok = true; + let mut details: Vec = Vec::new(); + for m in &mons { + session.monitor_switch(m.i); + session.request_keyframe(); + let fb = col.frames; + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(2), |c| c.frames > fb).await; + let (fw, fh) = (m.w as i32, m.h as i32); + let targets = [ + (fw * 30 / 100, fh * 40 / 100), + (fw * 55 / 100, fh * 35 / 100), + (fw * 45 / 100, fh * 65 / 100), + (fw * 70 / 100, fh * 55 / 100), + ]; + let mut offs: Vec<(i32, i32)> = Vec::new(); + for t in targets.iter() { + let c = move_and_settle(&session, &mut rx, &mut col, &mut dec, *t).await; + offs.push((c.0 - t.0, c.1 - t.1)); + } + let tol = (fw / 30).max(16); + let (mut best, mut best_x, mut best_y) = (0usize, 0i32, 0i32); + for cand in offs.iter() { + let n = offs + .iter() + .filter(|o| (o.0 - cand.0).abs() <= tol && (o.1 - cand.1).abs() <= tol) + .count(); + if n > best { + best = n; + best_x = cand.0; + best_y = cand.1; + } + } + let ok = best >= 2; // >=2 distinct-x targets at one offset = proof + all_ok &= ok; + details.push(format!( + "mon#{} origin~({},{}) {}/{}{}", + m.i, best_x, best_y, best, targets.len(), if ok { "" } else { " FAIL" } + )); + } + session.monitor_switch(0); // restore + if all_ok { + rep.pass("INPUT/per-monitor", &format!("injected mouse lands on EVERY monitor: {}", details.join("; "))); + } else { + rep.fail("INPUT/per-monitor", &format!("input mis-maps on some monitor: {}", details.join("; "))); + } + } + } + + // ---- SYS/processes -------------------------------------------------- + if let Some(r) = col.ended.clone() { + rep.skip("SYS/processes", &format!("session ended earlier: {r}")); + } else { + let n0 = count_kind(&col, "processes"); + session.list_processes(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(4), |c| { + count_kind(c, "processes") > n0 && last_items_len(c, "processes").unwrap_or(0) > 0 + }) + .await; + match last_items_len(&col, "processes") { + Some(n) if n > 0 => rep.pass("SYS/processes", &format!("sys.res kind=processes, {n} items")), + Some(_) => rep.fail("SYS/processes", "processes reply had 0 items"), + None => rep.fail("SYS/processes", "no sys.res kind=processes within 4s"), + } + } + + // ---- SYS/run (execute CMD/PowerShell on the host — admin feature) --- + if let Some(r) = col.ended.clone() { + rep.skip("SYS/run", &format!("session ended earlier: {r}")); + } else { + let n0 = count_kind(&col, "run"); + session.run_command("echo NETDESK-EXEC-OK && whoami"); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(6), |c| { + count_kind(c, "run") > n0 + }) + .await; + let msg = last_str(&col, "run", "msg").unwrap_or_default(); + let elevated = last_bool(&col, "run", "elevated"); + if msg.contains("NETDESK-EXEC-OK") { + rep.pass( + "SYS/run", + &format!( + "host executed the command and returned output ({} bytes); elevated={:?}", + msg.len(), + elevated + ), + ); + } else if count_kind(&col, "run") > n0 { + rep.fail( + "SYS/run", + &format!("run reply arrived but marker missing; msg='{}'", msg.chars().take(80).collect::()), + ); + } else { + rep.fail("SYS/run", "no sys.res kind=run within 6s"); + } + } + + // ---- SYS/refresh_screen (forces a keyframe) ------------------------- + if let Some(r) = col.ended.clone() { + rep.skip("SYS/refresh_screen", &format!("session ended earlier: {r}")); + } else { + let kb = col.keyframes; + session.refresh_screen(); + let got = + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(4), |c| c.keyframes > kb) + .await; + if got { + rep.pass("SYS/refresh_screen", &format!("keyframe received (+{})", col.keyframes - kb)); + } else { + rep.fail("SYS/refresh_screen", "no keyframe within 4s"); + } + } + + // ---- SYS/block_input (toggle on then back off) ---------------------- + if let Some(r) = col.ended.clone() { + rep.skip("SYS/block_input", &format!("session ended earlier: {r}")); + } else { + let n0 = count_kind(&col, "block_input"); + session.block_input(); + let got1 = + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(4), |c| { + count_kind(c, "block_input") > n0 + }) + .await; + let on1 = last_on(&col, "block_input"); + let n1 = count_kind(&col, "block_input"); + session.block_input(); + let got2 = + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(4), |c| { + count_kind(c, "block_input") > n1 + }) + .await; + let on2 = last_on(&col, "block_input"); + // SAFETY: never leave the host's input blocked. + if last_on(&col, "block_input") == Some(true) { + let n2 = count_kind(&col, "block_input"); + session.block_input(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(3), |c| { + count_kind(c, "block_input") > n2 + }) + .await; + } + if got1 && got2 && on1 == Some(true) && on2 == Some(false) { + rep.pass("SYS/block_input", "on=true then on=false (input restored)"); + } else { + rep.fail( + "SYS/block_input", + &format!("expected replies on=true then on=false, saw got1={got1} on1={on1:?} got2={got2} on2={on2:?}"), + ); + } + } + + // ---- SYS/hide_wallpaper (toggle then restore) ----------------------- + if let Some(r) = col.ended.clone() { + rep.skip("SYS/hide_wallpaper", &format!("session ended earlier: {r}")); + } else { + let n0 = count_kind(&col, "wallpaper"); + session.hide_wallpaper(); + let got1 = + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(4), |c| { + count_kind(c, "wallpaper") > n0 + }) + .await; + let on1 = last_on(&col, "wallpaper"); + let n1 = count_kind(&col, "wallpaper"); + session.hide_wallpaper(); + let got2 = + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(4), |c| { + count_kind(c, "wallpaper") > n1 + }) + .await; + let on2 = last_on(&col, "wallpaper"); + // SAFETY: never leave the wallpaper hidden. + if last_on(&col, "wallpaper") == Some(true) { + let n2 = count_kind(&col, "wallpaper"); + session.hide_wallpaper(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(3), |c| { + count_kind(c, "wallpaper") > n2 + }) + .await; + } + if got1 && got2 && on2 == Some(false) { + rep.pass( + "SYS/hide_wallpaper", + &format!("sys.res kind=wallpaper (on1={on1:?} then on2=false, restored)"), + ); + } else { + rep.fail( + "SYS/hide_wallpaper", + &format!("expected two wallpaper replies ending on=false, saw got1={got1} on1={on1:?} got2={got2} on2={on2:?}"), + ); + } + } + + // ---- FILES/list_roots ---------------------------------------------- + let mut first_root: Option = None; + if let Some(r) = col.ended.clone() { + rep.skip("FILES/list_roots", &format!("session ended earlier: {r}")); + } else { + let before = col.file_lists.len(); + session.list_roots(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(5), |c| { + c.file_lists.len() > before + }) + .await; + match col.file_lists.get(before).cloned() { + Some((_path, entries)) if !entries.is_empty() => { + first_root = entries.first().map(|e| e.name.clone()); + let names: Vec = entries.iter().map(|e| e.name.clone()).collect(); + rep.pass( + "FILES/list_roots", + &format!("{} root(s): {}", entries.len(), names.join(", ")), + ); + } + Some(_) => rep.fail("FILES/list_roots", "roots listing was empty"), + None => rep.fail("FILES/list_roots", "no FileList within 5s"), + } + } + + // ---- FILES/list_dir (+ optional download) --------------------------- + if let Some(r) = col.ended.clone() { + rep.skip("FILES/list_dir", &format!("session ended earlier: {r}")); + rep.skip("FILES/download", "depends on list_dir"); + } else if let Some(root) = first_root.clone() { + let before = col.file_lists.len(); + session.list_dir(&root); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(5), |c| { + c.file_lists.len() > before + }) + .await; + match col.file_lists.get(before).cloned() { + Some((_path, entries)) if !entries.is_empty() => { + rep.pass( + "FILES/list_dir", + &format!("{} entries under {}", entries.len(), root), + ); + // optional: download the smallest regular file <= 256 KiB. + let small = entries + .iter() + .filter(|e| !e.is_dir && e.size > 0 && e.size <= 256 * 1024) + .min_by_key(|e| e.size) + .cloned(); + if let Some(f) = small { + let full = join_path(&root, &f.name); + let ndl = col.downloads.len(); + let nerr = col.file_errs.len(); + session.download(&full); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(8), |c| { + c.downloads.len() > ndl || c.file_errs.len() > nerr + }) + .await; + if let Some((_fid, name, size, path)) = col.downloads.get(ndl).cloned() { + let len = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + let _ = std::fs::remove_file(&path); + if len == size && size == f.size { + rep.pass( + "FILES/download", + &format!("{name}: {len} bytes on disk == host size {size} == listing size"), + ); + } else { + rep.fail( + "FILES/download", + &format!( + "{name}: got {len} bytes, host size {size}, listing size {}", + f.size + ), + ); + } + } else if let Some(e) = col.file_errs.get(nerr) { + rep.fail("FILES/download", &format!("file.err: {e}")); + } else { + rep.fail("FILES/download", "no FileDownloadDone within 8s"); + } + } else { + rep.skip( + "FILES/download", + &format!("no regular file <=256KiB in {root} to safely test"), + ); + } + } + Some(_) => { + rep.fail("FILES/list_dir", &format!("{root} listed but 0 entries")); + rep.skip("FILES/download", "list_dir returned no entries"); + } + None => { + rep.fail("FILES/list_dir", "no FileList within 5s"); + rep.skip("FILES/download", "list_dir did not respond"); + } + } + } else { + rep.skip("FILES/list_dir", "no root available from list_roots"); + rep.skip("FILES/download", "no root available from list_roots"); + } + + // ---- FILES/upload (verified on disk — the live host writes here) ----- + // This is the same path the viewer's "Upload…" button and drag & drop use. + if let Some(r) = col.ended.clone() { + rep.skip("FILES/upload", &format!("session ended earlier: {r}")); + } else { + let dir = std::env::temp_dir(); + let fname = format!("netdesk-e2e-up-{}.txt", now_unix()); + let content = format!("netdesk upload e2e {}", now_unix()).into_bytes(); + let dest = dir.join(&fname); + let _ = std::fs::remove_file(&dest); + let dir_s = dir.to_string_lossy().to_string(); + session.upload(&dir_s, &fname, &content); + let mut ok = false; + for _ in 0..25 { + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(120), |_| false).await; + if std::fs::read(&dest).map(|g| g == content).unwrap_or(false) { + ok = true; + break; + } + } + let _ = std::fs::remove_file(&dest); + if ok { + rep.pass("FILES/upload", &format!( + "uploaded {} bytes as {fname}; file on the host matches byte-for-byte (removed after)", + content.len())); + } else { + rep.fail("FILES/upload", "uploaded file never appeared on the host with matching content"); + } + } + + // ---- FILES/download-stream — multi-chunk, disk-streamed, byte-verified -- + // Round-trip a 1 MiB file (4 x 256 KiB chunks) to exercise the sequential + // seek/write disk-streaming path (no RAM buffering) and prove integrity. + if let Some(r) = col.ended.clone() { + rep.skip("FILES/download-stream", &format!("session ended earlier: {r}")); + } else { + let dir = std::env::temp_dir(); + let fname = format!("netdesk-e2e-stream-{}.bin", now_unix()); + let hostfile = dir.join(&fname); // host writes here (host == this machine) + let _ = std::fs::remove_file(&hostfile); + let data: Vec = (0..1024u32 * 1024) + .map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8) + .collect(); + session.upload(&dir.to_string_lossy(), &fname, &data); + let mut uploaded = false; + for _ in 0..60 { + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(120), |_| false).await; + if std::fs::metadata(&hostfile).map(|m| m.len() == data.len() as u64).unwrap_or(false) { + uploaded = true; + break; + } + } + // Settle so the host flushes/closes its write handle before we read the + // file back — otherwise the download can race the still-open writer and + // return same-length-but-different bytes. + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(800), |_| false).await; + if !uploaded { + rep.fail("FILES/download-stream", "upload of the 1 MiB test file never completed"); + } else { + let ndl = col.downloads.len(); + let nerr = col.file_errs.len(); + session.download(&hostfile.to_string_lossy()); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(20), |c| { + c.downloads.len() > ndl || c.file_errs.len() > nerr + }) + .await; + if let Some((_fid, _name, size, path)) = col.downloads.get(ndl).cloned() { + let got = std::fs::read(&path).unwrap_or_default(); + let _ = std::fs::remove_file(&path); + if got.len() == data.len() && got == data { + rep.pass("FILES/download-stream", &format!( + "1 MiB round-trip ({} chunks) streamed to disk, byte-for-byte match (host size {size})", + data.len() / (256 * 1024))); + } else { + rep.fail("FILES/download-stream", &format!( + "mismatch: {} bytes vs {} (host size {size})", got.len(), data.len())); + } + } else if let Some(e) = col.file_errs.get(nerr) { + rep.fail("FILES/download-stream", &format!("file.err: {e}")); + } else { + rep.fail("FILES/download-stream", "no FileDownloadDone within 20s"); + } + } + let _ = std::fs::remove_file(&hostfile); + } + + // ---- remote cursor overlay (position + bitmap stream) ---------------- + if col.cursors > 0 { + rep.pass( + "CURSOR", + &format!( + "{} remote-cursor positions, shape bitmap {} bytes", + col.cursors, col.cursor_shape_bytes + ), + ); + } else { + rep.fail("CURSOR", "no remote-cursor events received"); + } + + // ---- RECORDING — mux the live H.264 into a real .mp4, read it back ----- + // The viewer's Rec button tees the same access units into this recorder. + if let Some(r) = col.ended.clone() { + rep.skip("RECORDING", &format!("session ended earlier: {r}")); + } else { + let (w, h) = col + .connected + .as_ref() + .map(|c| (c.2 as u16, c.3 as u16)) + .unwrap_or((1920, 1080)); + let path = std::env::temp_dir().join(format!("netdesk-e2e-rec-{}.mp4", now_unix())); + col.recorder = Some(netdesk_core::recorder::Mp4Recorder::new(&path, w, h)); + session.set_quality(30, 8000, 1.0); + // Force several frames even if the host screen is static. + for _ in 0..8 { + session.request_keyframe(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(400), |_| false).await; + } + let rec = col.recorder.take().unwrap(); + let fed = rec.samples(); + match rec.finish() { + Ok((p, _)) => { + let sz = std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0); + match netdesk_core::recorder::probe_mp4(&p) { + Ok((vt, samples, pw, ph)) if vt >= 1 && samples > 0 => rep.pass( + "RECORDING", + &format!( + "valid .mp4 ({sz} bytes): {vt} video track, {samples} samples, {pw}x{ph} — H.264 remux, no re-encode" + ), + ), + Ok((vt, samples, _, _)) => rep.fail( + "RECORDING", + &format!("mp4 parsed but empty: {vt} tracks / {samples} samples (fed {fed})"), + ), + Err(e) => rep.fail("RECORDING", &format!("mp4 unreadable: {e} (fed {fed})")), + } + let _ = std::fs::remove_file(&p); + } + Err(e) => rep.fail("RECORDING", &format!("finalize failed: {e}")), + } + } + + // ---- close cleanly -------------------------------------------------- + session.close(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(2), |c| c.ended.is_some()).await; + println!( + " session closed (ended reason: {})", + col.ended.as_deref().unwrap_or("") + ); + println!( + " tallies: frames_rx={} decoded={} keyframes={} audio_pkts={} sys_replies={} file_lists={}", + col.frames, col.decoded, col.keyframes, col.audio_pkts, col.sys.len(), col.file_lists.len() + ); + + summary(&rep); +} + +fn host_ctl_checks(rep: &mut Report) { + // Point config resolution at the live engine's portable config (fresh + // heartbeat) unless the operator already set NETDESK_CONFIG. + if std::env::var_os("NETDESK_CONFIG").is_none() { + std::env::set_var("NETDESK_CONFIG", PORTABLE_CONFIG); + } + match host_ctl::read_config() { + Ok(cfg) => { + if cfg.id == host_id() { + rep.pass( + "HOSTCTL/read_config", + &format!("id={} name={} server={}", cfg.id, cfg.name, cfg.server), + ); + } else { + rep.fail( + "HOSTCTL/read_config", + &format!("id={} (expected {})", cfg.id, host_id()), + ); + } + + let es = host_ctl::engine_status(&cfg); + if es.online { + rep.pass( + "HOSTCTL/engine_status", + &format!("online=true state={} in_session={}", es.state, es.in_session), + ); + } else { + rep.fail( + "HOSTCTL/engine_status", + &format!("online=false state={}", es.state), + ); + } + + match host_ctl::ping_relay(&cfg) { + Ok(ms) => rep.pass("HOSTCTL/ping_relay", &format!("{ms} ms to {}/health", cfg.api_base())), + Err(e) => rep.fail("HOSTCTL/ping_relay", &format!("{e}")), + } + + match host_ctl::list_devices(&cfg) { + Ok(devs) => { + let names: Vec = devs + .iter() + .map(|d| format!("{}({},{})", d.name, d.id, if d.online { "online" } else { "offline" })) + .collect(); + let has_devpc = devs.iter().any(|d| d.name.to_uppercase().contains("DEVPC")); + if !devs.is_empty() && has_devpc { + rep.pass( + "HOSTCTL/list_devices", + &format!("{} device(s): {}", devs.len(), names.join(", ")), + ); + } else if !devs.is_empty() { + rep.fail( + "HOSTCTL/list_devices", + &format!("{} device(s) but none named DEVPC: {}", devs.len(), names.join(", ")), + ); + } else { + rep.fail("HOSTCTL/list_devices", "0 devices returned"); + } + } + Err(e) => rep.fail("HOSTCTL/list_devices", &format!("{e}")), + } + } + Err(e) => { + rep.fail("HOSTCTL/read_config", &format!("{e}")); + rep.fail("HOSTCTL/engine_status", "no config"); + rep.fail("HOSTCTL/ping_relay", "no config"); + rep.fail("HOSTCTL/list_devices", "no config"); + } + } +} + +fn summary(rep: &Report) { + println!("================================================================"); + println!("WINDOW_END {}", now_unix()); + println!( + "SUMMARY: {} / {} passed ({} skipped)", + rep.pass, + rep.pass + rep.fail, + rep.skip + ); +} diff --git a/native/cli/src/bin/fps_probe.rs b/native/cli/src/bin/fps_probe.rs new file mode 100644 index 0000000..4e09b9c --- /dev/null +++ b/native/cli/src/bin/fps_probe.rs @@ -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 [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 = std::env::args().collect(); + if a.len() < 3 { + eprintln!("usage: netdesk-fps-probe [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::(); + 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 = vec![0; secs + 2]; + let mut first: Option = 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(()) +} diff --git a/native/cli/src/bin/getdir.rs b/native/cli/src/bin/getdir.rs new file mode 100644 index 0000000..72be520 --- /dev/null +++ b/native/cli/src/bin/getdir.rs @@ -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 `/`. Developed by Akronlogic. +//! +//! Usage: +//! netdesk-getdir [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 = std::env::args().collect(); + if a.len() < 5 { + eprintln!("usage: netdesk-getdir [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::(); + 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 /. + 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(()) +} diff --git a/native/cli/src/bin/lan_scan.rs b/native/cli/src/bin/lan_scan.rs new file mode 100644 index 0000000..3df5ba1 --- /dev/null +++ b/native/cli/src/bin/lan_scan.rs @@ -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); + } + } +} diff --git a/native/cli/src/bin/privacy_test.rs b/native/cli/src/bin/privacy_test.rs new file mode 100644 index 0000000..7d229ee --- /dev/null +++ b/native/cli/src/bin/privacy_test.rs @@ -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 [ws_url] + +use netdesk_core::{connect, ConnectConfig, SessionEvent}; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; + +type Rx = mpsc::UnboundedReceiver; + +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 { + 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 = std::env::args().collect(); + if a.len() < 3 { + eprintln!("usage: netdesk-privacy-test [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::(); + 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); + } +} diff --git a/native/cli/src/bin/tap-probe.rs b/native/cli/src/bin/tap-probe.rs new file mode 100644 index 0000000..7874a66 --- /dev/null +++ b/native/cli/src/bin/tap-probe.rs @@ -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::(); + 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 = 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(); +} diff --git a/native/cli/src/bin/term.rs b/native/cli/src/bin/term.rs new file mode 100644 index 0000000..471f756 --- /dev/null +++ b/native/cli/src/bin/term.rs @@ -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 [ws_url] [totp] +//! Input: set ND_TERM_CMD="" to run one command, or pipe commands on +//! stdin (e.g. `echo dir | netdesk-term `). 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 = std::env::args().collect(); + if a.len() < 3 { + eprintln!("usage: netdesk-term [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::(); + 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(()) +} diff --git a/native/cli/src/bin/tunnel.rs b/native/cli/src/bin/tunnel.rs new file mode 100644 index 0000000..b02dfd5 --- /dev/null +++ b/native/cli/src/bin/tunnel.rs @@ -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/` server endpoint the host now implements. +//! +//! Each accepted local connection opens its own `wss://server/tunnel/` +//! 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 [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 { + // 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 = 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 \ + [remote_host] [server] [portal_password]\n\ + or: netdesk-tunnel --rdp [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(()) +} diff --git a/native/cli/src/bin/voice_send.rs b/native/cli/src/bin/voice_send.rs new file mode 100644 index 0000000..2e023c7 --- /dev/null +++ b/native/cli/src/bin/voice_send.rs @@ -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 [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 = std::env::args().collect(); + if a.len() < 3 { + eprintln!("usage: netdesk-voice-send [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::(); + 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(()) +} diff --git a/native/cli/src/main.rs b/native/cli/src/main.rs new file mode 100644 index 0000000..c7179fe --- /dev/null +++ b/native/cli/src/main.rs @@ -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 [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 = std::env::args().collect(); + if args.len() < 3 { + eprintln!("usage: netdesk [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::(); + 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::>() + .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(()) +} diff --git a/native/core/Cargo.toml b/native/core/Cargo.toml new file mode 100644 index 0000000..e5a991a --- /dev/null +++ b/native/core/Cargo.toml @@ -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" diff --git a/native/core/src/brand.rs b/native/core/src/brand.rs new file mode 100644 index 0000000..882e48f --- /dev/null +++ b/native/core/src/brand.rs @@ -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): `-`, 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, + /// Full relay URL, reconstructed from the bare host (+ port/tls). + pub server: Option, + pub id: Option, + pub key: Option, + pub name: Option, +} + +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 = None; + let mut port: Option = 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); + } +} diff --git a/native/core/src/cidr.rs b/native/core/src/cidr.rs new file mode 100644 index 0000000..8b0aa7a --- /dev/null +++ b/native/core/src/cidr.rs @@ -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::().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")); + } +} diff --git a/native/core/src/crypto.rs b/native/core/src/crypto.rs new file mode 100644 index 0000000..c5f9002 --- /dev/null +++ b/native/core/src/crypto.rs @@ -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 { + let salt = B64 + .decode(salt_b64) + .map_err(|e| NdError::Crypto(format!("salt b64: {e}")))?; + let mut dk = [0u8; 32]; + pbkdf2::pbkdf2_hmac::(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 { + let salt = B64 + .decode(salt_b64) + .map_err(|e| NdError::Crypto(format!("salt b64: {e}")))?; + let mut dk = [0u8; 32]; + pbkdf2::pbkdf2_hmac::(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 { + 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::::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> { + 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> { + 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 { + 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::::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::(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> { + 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 { + use hmac::{Hmac, Mac}; + use sha1::Sha1; + let key = b32_decode(secret)?; + let counter = (unix_secs / step).to_be_bytes(); + let mut mac = 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() +} diff --git a/native/core/src/decode.rs b/native/core/src/decode.rs new file mode 100644 index 0000000..731e7a3 --- /dev/null +++ b/native/core/src/decode.rs @@ -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, +} + +/// 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` 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>; +} + +pub struct H264Decoder { + inner: Decoder, +} + +impl H264Decoder { + pub fn new() -> Result { + 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> { + 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> { + // 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` 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> { + 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 { + #[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 { + 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> { + 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 { + #[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() +} diff --git a/native/core/src/decode_mediacodec.rs b/native/core/src/decode_mediacodec.rs new file mode 100644 index 0000000..41c91cf --- /dev/null +++ b/native/core/src/decode_mediacodec.rs @@ -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, + pps: Vec, + 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 { + 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> { + 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> { + if au.is_empty() { + return Ok(None); + } + + // Cache parameter sets from keyframes; (re)prime when they change. + let mut new_sps: Option> = None; + let mut new_pps: Option> = 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 { + 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 { + 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(au: &[u8], mut f: F) { + let n = au.len(); + let mut starts: Vec = 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 `` / `` 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; + } +} diff --git a/native/core/src/decode_mf.rs b/native/core/src/decode_mf.rs new file mode 100644 index 0000000..b58bddc --- /dev/null +++ b/native/core/src/decode_mf.rs @@ -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, + context: Option, + _manager: Option, + staging: Option, + 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, +} + +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 { + 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 { + 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> { + loop { + let sample_slot: Option = 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 { + let buffer = sample + .GetBufferByIndex(0) + .map_err(mferr("GetBufferByIndex"))?; + + if self.hw { + if let Ok(dxgi) = buffer.cast::() { + 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 { + 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 = 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> { + 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 = None; + let mut context: Option = 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::() { + let _ = mt.SetMultithreadProtected(TRUE); + } + + let mut token = 0u32; + let mut manager: Option = 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 { + 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::(), + std::mem::size_of::(), + ); + 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())) +} diff --git a/native/core/src/decode_vaapi.rs b/native/core/src/decode_vaapi.rs new file mode 100644 index 0000000..c8794ac --- /dev/null +++ b/native/core/src/decode_vaapi.rs @@ -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 { + 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> { + // 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::(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> { + 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 = 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())) +} diff --git a/native/core/src/decode_vt.rs b/native/core/src/decode_vt.rs new file mode 100644 index 0000000..3bd8a16 --- /dev/null +++ b/native/core/src/decode_vt.rs @@ -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, + pps: Vec, + /// 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, + pts: i64, +} + +/// Output slot shared with the decompression callback via a raw `refcon`. +struct Shared { + frame: Option, +} + +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 { + 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> { + 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> { + 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> = None; + let mut new_pps: Option> = None; + let mut avcc: Vec = 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(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 = 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, + 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; + } +} diff --git a/native/core/src/error.rs b/native/core/src/error.rs new file mode 100644 index 0000000..44757d8 --- /dev/null +++ b/native/core/src/error.rs @@ -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 = std::result::Result; diff --git a/native/core/src/host.rs b/native/core/src/host.rs new file mode 100644 index 0000000..838cff2 --- /dev/null +++ b/native/core/src/host.rs @@ -0,0 +1,1049 @@ +//! HOST side of the NetDesk protocol — lets a viewer connect to and control +//! THIS device. Built for the Android host (screen via MediaProjection + +//! MediaCodec, input via the accessibility service), but transport-agnostic: +//! it takes encoded H.264 access units on a channel and emits the viewer's +//! input as events for the platform layer to inject. +//! +//! It mirrors [`crate::signaling::connect`] (the viewer) in reverse: +//! reg.host -> session.begin -> session.challenge -> session.auth +//! -> session.accept -> stream `CHAN_VIDEO` frames (E2E-sealed) +//! Auth + E2E reuse [`crate::crypto`] (`verify_proof`, [`HostE2e`], +//! `encrypt_frame`), proven to interoperate with the web/desktop/native viewers. + +use crate::crypto::{self, HostE2e}; +use crate::error::{NdError, Result}; +use crate::proto::{CHAN_AUDIO, CHAN_FILE, CHAN_FILE_END, CHAN_VIDEO, CHAN_VOICE}; +use aes_gcm::Aes256Gcm; +use rand::RngCore; +use serde_json::{json, Value}; +use tokio::sync::mpsc; + +fn install_crypto_provider() { + use std::sync::Once; + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + let _ = rustls::crypto::ring::default_provider().install_default(); + }); +} + +/// Per-session permission switches the host ENFORCES (events for a disabled +/// channel are never emitted to the platform layer, so a modified viewer can't +/// bypass them). Advertised in `session.accept` `info.perms` with the same +/// full-name keys the Python engine uses, so the web viewer's existing +/// `info.perms.clipboard === false` checks light up unchanged. +#[derive(Clone, Copy, Debug)] +pub struct HostPerms { + pub input: bool, + pub clipboard: bool, + pub files: bool, + pub audio: bool, +} + +impl Default for HostPerms { + fn default() -> Self { + Self { input: true, clipboard: true, files: true, audio: true } + } +} + +/// Host identity + unattended credentials (the fields the Python engine keeps in +/// config.json). Generate fresh ones with [`crate::crypto::new_password_record`] +/// + [`crate::crypto::gen_host_id`]. +#[derive(Clone)] +pub struct HostConfig { + pub ws_url: String, + pub host_id: String, + pub name: String, + /// base64 of the RAW salt bytes (sent in the challenge). + pub salt_b64: String, + pub iters: u32, + /// PBKDF2 verifier (hex) — checked against the viewer's proof. + pub verifier_hex: String, + /// Current capture resolution advertised to the viewer. + pub width: u32, + pub height: u32, + /// Whether this host streams system audio (Opus over CHAN_AUDIO). Advertised + /// in session.accept so the viewer knows to expect audio. + pub audio: bool, + /// All display outputs as (index, width, height); `index` is what the viewer + /// echoes in `monitor.switch{mon}`. Empty falls back to a single monitor. + pub monitors: Vec<(u32, u32, u32)>, + /// The currently-captured monitor index (advertised as `info.mon`). + pub monitor: u32, + /// Host-enforced per-session permissions (see [`HostPerms`]). Defaults to + /// everything allowed — exactly the pre-perms behavior. + pub perms: HostPerms, + /// Optional TOTP (RFC 6238) 2FA secret (base32). When set, the host + /// advertises `totp:true` in the challenge and requires a valid code in + /// `session.auth` before accepting. Empty disables 2FA. + pub totp_secret: String, + /// Approve-before-connect: when true, a viewer that passes password auth is + /// HELD until the platform answers [`HostEvent::ApproveRequest`] with + /// [`HostControl::ApproveReply`]. No answer within ~20s denies (the relay + /// frees a pre-accept session slot at 25s, so the window must beat that). + pub approve: bool, +} + +/// One encoded video access unit from the platform encoder. Clone so one +/// capture+encode can fan out to multiple simultaneous viewer sessions (each +/// seals it with its own E2E key). +#[derive(Clone)] +pub struct HostFrame { + pub data: Vec, // codec bitstream (H.264 Annex-B, or VP9) + pub keyframe: bool, + /// Codec id for this frame (see `proto::VideoCodec`): 0 = H.264, 1 = VP9. + /// Packed into the frame's flags byte so the viewer selects the decoder. + pub codec: u8, +} + +/// Outbound control the platform layer pushes to the connected viewer (sent as +/// plaintext JSON over the relay, like the rest of the control channel). The +/// video stream stays E2E-encrypted; these mirror the existing protocol. +#[derive(Clone, Debug)] +pub enum HostControl { + /// Resolution / stream info changed (e.g. the quality scale lever) — the + /// viewer trusts `session.info` for the authoritative frame size. + Info { w: u32, h: u32 }, + /// The host's OS clipboard changed; push it to the viewer (origin "host"). + Clipboard { text: String }, + /// The host's OS clipboard now holds an IMAGE; push it to the viewer as PNG + /// bytes (origin "host"). Sent as `clip.image` with base64 PNG. + ClipboardImage { png: Vec }, + /// A ready-made control JSON object to send verbatim (e.g. a `sys.res` + /// reply the platform built, already carrying its `sid`). + Raw { json: String }, + /// Remote cursor position in frame-pixel coordinates (`hidden` = not shown). + Cursor { id: u64, x: i32, y: i32, hidden: bool }, + /// Remote cursor shape: a CSS name for a standard system cursor, and/or a + /// PNG bitmap (+ size/hotspot) for an app-custom cursor so it renders on the + /// viewer instead of falling back to the default pointer. `png` empty = none. + CursorShape { + id: u64, + css: Option, + png: Vec, + w: u32, + h: u32, + hx: i32, + hy: i32, + }, + /// One download data chunk (host→viewer, 0xf1 frame): fid + byte offset + + /// bytes. Sent E2E-sealed exactly like video. + FileData { fid: u32, offset: u64, data: Vec }, + /// One Opus audio packet (system-audio capture): sent as `[CHAN_AUDIO][opus]`, + /// E2E-sealed like video, over the reliable Ctl lane. + AudioFrame(Vec), + /// Raw TCP-tunnel bytes from the host's local socket to relay to the tunnel + /// peer: sent as `[0xF3][tid u32 LE][data]`, NOT E2E-sealed (the server + /// relays it verbatim to the portal-authed tunnel client). + TunnelData { tid: u32, data: Vec }, + /// The host's local TCP socket for `tid` closed: drop tracking + tell the + /// server (`tunnel.closed`) so it can close the client socket. + TunnelClosed { tid: u32 }, + /// Terminal `tid` produced output — send it to the viewer (`term.data`). + TermData { tid: u32, data: String }, + /// Terminal `tid`'s shell exited — tell the viewer (`term.exit`). + TermExit { tid: u32 }, + /// The platform's answer to [`HostEvent::ApproveRequest`]: accept finalizes + /// the held session (session.accept + ViewerConnected); reject denies it. + /// A reply whose `sid` no longer matches the held session is ignored (e.g. + /// the engine's own 20s timeout already denied it). + ApproveReply { sid: String, accept: bool }, +} + +/// Events the platform layer reacts to (input to inject, keyframe requests, …). +#[derive(Clone, Debug)] +pub enum HostEvent { + /// Registered with the server under `host_id`; now reachable. + Registered, + /// A viewer authenticated and the session is live. `vp9` is the viewer's + /// advertised VP9 decode capability (from `session.begin`); the host only + /// sends VP9 frames when it is true. + ViewerConnected { e2e: bool, vp9: bool, i444: bool }, + /// The viewer updated its capabilities post-connect (e.g. VP9 decode), sent + /// as a `viewer.caps` control message since the server strips the handshake + /// advertisement. Updates the live codec gate. + ViewerCaps { vp9: bool, i444: bool }, + /// The viewer ended the session (host stays registered for the next one). + ViewerLeft, + /// Pointer in the captured image's pixel space (buttons = bitmask, wheel = rows). + Mouse { x: i32, y: i32, buttons: u32, wheel: f32 }, + /// Relative pointer motion (game / pointer-lock mode): dx/dy deltas. + MouseRel { dx: i32, dy: i32, buttons: u32, wheel: f32 }, + /// Typed text for translate / international-keyboard mode (Unicode). + Text { text: String }, + /// Key: DOM-style code (e.g. "KeyA", "Enter", "Backspace") + down/up. + Key { code: String, down: bool }, + /// The viewer pushed its local clipboard text; apply it to the host OS + /// clipboard (RustDesk-style clipboard sync, viewer -> host). + Clipboard { text: String }, + /// The viewer pushed an IMAGE to apply to the host clipboard (PNG bytes). + ClipboardImage { png: Vec }, + /// A `sys.cmd` control (process list/kill, block-input, wallpaper, power, + /// run, …). The raw JSON is handed to the platform, which acts and replies. + Sys { json: String }, + /// Collaborative annotation: a viewer stroke/clear for the host overlay. + Annot { json: String }, + /// A `file.*` control (roots/list/get/put). The platform enumerates or + /// starts the transfer and replies via [`HostControl`]. + File { json: String }, + /// Open an interactive terminal (ConPTY) `tid`; stream its output back via + /// [`HostControl::TermData`] and signal exit with [`HostControl::TermExit`]. + TermOpen { tid: u32 }, + /// Keystrokes / command bytes for terminal `tid` (write to its ConPTY input). + TermInput { tid: u32, data: String }, + /// Resize terminal `tid` to cols×rows (ResizePseudoConsole). + TermResize { tid: u32, cols: u32, rows: u32 }, + /// Close terminal `tid` (kill its shell). + TermClose { tid: u32 }, + /// One decrypted upload data chunk (viewer→host, 0xf2 frame) to write. + FileChunk { fid: u32, offset: u64, data: Vec }, + /// One Opus packet of the viewer's microphone (voice call, `[CHAN_VOICE][opus]`). + /// The platform decodes it and plays it on the host's default render device. + Voice { opus: Vec }, + /// The viewer requested a fresh keyframe — feed one next. + KeyframeRequested, + /// The viewer asked to capture a different monitor (`monitor.switch{mon}`). + SwitchMonitor { mon: u32 }, + /// The viewer changed quality targets (manual lever — the adaptive ceiling). + Quality { fps: u32, bitrate: u32, scale: f32 }, + /// A network telemetry sample measured by the host (round-trip time to the + /// viewer + packet loss), driving the adaptive QoS controller. + NetStat { rtt_ms: u32, loss: f32 }, + /// The viewer selected the host audio source: 0 = off, 1 = system (what you + /// hear), 2 = microphone. + AudioSource { src: u8 }, + /// The viewer pinned the video codec for this session (e.g. while recording, + /// which muxes H.264 only): -1 = auto (release the pin), 0 = H.264, 1 = VP9. + /// The capture loop forces this codec instead of auto-selecting by link. + PinCodec { codec: i32 }, + /// Approve-before-connect (`cfg.approve`): a viewer passed password auth and + /// is HELD pending the platform's Accept/Reject decision — show a prompt and + /// answer with [`HostControl::ApproveReply`]. `peer`/`user` are what the + /// relay reported in `session.begin` (viewer IP + account), for display. + ApproveRequest { sid: String, peer: String, user: String }, + /// The viewer toggled host-enforced view-only for this session: while on, + /// the engine drops all mouse/key input before it reaches the platform. + /// Platforms should release any held buttons when it turns on. + ViewOnly { on: bool }, + /// The server (portal-authed tunnel client) asks this host to open a TCP + /// tunnel to `thost:port`. Independent of any viewer session. The platform + /// opens the socket and replies `tunnel.ok`/`tunnel.err` via HostControl::Raw. + TunnelOpen { tid: u32, thost: String, port: u16 }, + /// Close the TCP tunnel `tid` (the tunnel client disconnected). + TunnelClose { tid: u32 }, + /// Raw bytes from the tunnel peer to write to the host's local TCP socket. + TunnelData { tid: u32, data: Vec }, + /// The host link ended (socket closed / error). The caller should reconnect. + Ended { reason: String }, +} + +fn gen_nonce_hex() -> String { + let mut b = [0u8; 16]; + rand::rngs::OsRng.fill_bytes(&mut b); + hex::encode(b) // 32 hex chars, matching secrets.token_hex(16) +} + +/// Build the `session.accept` info object: resolution/codec hints, the REAL +/// enforced permission set (full-name keys, matching the Python engine so the +/// web viewer's `info.perms.*` checks just work), and capability flags. +fn build_info(cfg: &HostConfig, e2e: bool) -> Value { + let monitors: Vec = if cfg.monitors.is_empty() { + vec![json!({"i": cfg.monitor, "w": cfg.width, "h": cfg.height})] + } else { + cfg.monitors + .iter() + .map(|(i, w, h)| { + // u32::MAX is the all-monitors sentinel; advertise it as -1 + // (MonitorInfo.i is i32, so u32::MAX could not deserialize). + let iv: i64 = if *i == u32::MAX { -1 } else { *i as i64 }; + json!({"i": iv, "w": w, "h": h}) + }) + .collect() + }; + let mut info = json!({ + "mon": cfg.monitor, + "monitors": monitors, + "w": cfg.width, "h": cfg.height, + "fps": 30, "bitrate": 6000, + "e2e": e2e, "audio": cfg.audio && cfg.perms.audio, + "host_name": cfg.name, "os": "windows", + // The ENFORCED per-session permissions — not a suggestion: the engine + // never emits events for a disabled channel (see the handler gates). + "perms": { + "input": cfg.perms.input, + "clipboard": cfg.perms.clipboard, + "files": cfg.perms.files, + "audio": cfg.perms.audio, + }, + "version": "native-host", + // Privacy mode is off at connect; live changes are reported via + // sys.res {kind:"privacy", on:} when the viewer toggles it. + "privacy_active": false, + // File-range capability: this host supports ranged file.get{offset,len} + // + file.ack flow control, so viewers may download in parallel segments + // (IDM-style) with resume. + "fr": true, + }); + // Advertise native single-channel P2P so a native viewer only upgrades + // against a native host; web/Python hosts omit this and the viewer stays on + // the relay (safe default-on). NETDESK_P2P_FORCE_WEB suppresses it so a + // native host presents as a web host — used to e2e the web answerer path. + if std::env::var("NETDESK_P2P_FORCE_WEB").is_err() { + info["p2p"] = json!("native"); + } + info +} + +/// Run the host until `frames` closes or the socket drops. Serves one viewer at +/// a time (a second `session.begin` while busy is denied). Returns when the link +/// ends; the caller reconnects for resilience. +pub async fn run_host( + cfg: HostConfig, + frames: mpsc::UnboundedReceiver, + events: mpsc::UnboundedSender, + control: mpsc::UnboundedReceiver, +) -> Result<()> { + install_crypto_provider(); + let transport = crate::transport::WsRelay::connect(&cfg.ws_url).await?; + // Host answers a viewer-initiated P2P upgrade when NETDESK_P2P is on; the + // multiplexer is a no-op passthrough to the relay otherwise. + if crate::p2p::p2p_enabled() { + let api = std::sync::Arc::new( + crate::rtc::build_api().map_err(|e| NdError::Connect(e.to_string()))?, + ); + let mux = + crate::p2p::upgrade_relay(Box::new(transport), crate::p2p::P2pRole::Answerer, api); + run_host_with(cfg, frames, events, control, Box::new(mux)).await + } else { + run_host_with(cfg, frames, events, control, Box::new(transport)).await + } +} + +/// Run the host over any [`crate::transport::Transport`]. `run_host` is the thin +/// WSS wrapper over this. +pub async fn run_host_with( + cfg: HostConfig, + mut frames: mpsc::UnboundedReceiver, + events: mpsc::UnboundedSender, + mut control: mpsc::UnboundedReceiver, + mut transport: Box, +) -> Result<()> { + use crate::transport::TransportMsg; + + // Register as the host for `host_id` (takeover reclaims an idle stale holder; + // the server never displaces an ACTIVE session). + let reg = json!({ + "t": "reg.host", "id": cfg.host_id, "name": cfg.name, + "ver": "native-host", "key": "", "token": "", "takeover": true, + }); + transport.send_text(reg.to_string()) + .await + .map_err(|e| NdError::Connect(e.to_string()))?; + let _ = events.send(HostEvent::Registered); + + // Handshake + session state. + let mut pending: Option<(String, String, HostE2e)> = None; // (sid, nonce, ecdh) + let mut cipher: Option = None; + let mut viewer_vp9 = false; // viewer's advertised VP9 decode capability + let mut viewer_i444 = false; // viewer can decode VP9 profile-1 4:4:4 (crisp text) + let mut active_sid: Option = None; + // Approve-before-connect: a password-authenticated viewer HELD for the + // platform's Accept/Reject (sid, derived session cipher, deadline). + let mut approving: Option<(String, Option, std::time::Instant)> = None; + // Viewer identity the relay attached to session.begin (IP + account), shown + // in the approve prompt. + let mut peer_info: (String, String) = (String::new(), String::new()); + // Host-enforced view-only for the CURRENT session (viewer-requested via the + // `view.only` control message; input is dropped here, not just in the UI). + let mut view_only = false; + // One log line per session when input is dropped, not one per event. + let mut warned_input = false; + // Open TCP-tunnel ids (server-relayed, portal-authed, independent of a video + // session). Tracked so a raw `[0xF3][tid]` frame is only routed to the tunnel + // when that tid is actually open — otherwise an E2E session frame whose random + // nonce starts with 0xF3 could be misread as tunnel data. + let mut tunnel_tids: std::collections::HashSet = std::collections::HashSet::new(); + // Outbound control channel stays polled until its sender is dropped. + let mut control_open = true; + + // Network RTT probe: once a second, while a viewer is connected, send a + // net.ping; the viewer echoes net.pong and we measure the round trip. This + // drives the adaptive QoS controller. The ctl path shares the P2P SCTP + // association / the relay socket, so it rises under congestion. + let mut ping_tick = tokio::time::interval(std::time::Duration::from_millis(1000)); + ping_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut ping_seq: u64 = 0; + let mut ping_inflight: Option<(u64, std::time::Instant)> = None; + // Adaptive-QoS telemetry: a rolling-baseline RTT (delay above the link's + // inherent floor, not absolute RTT) + a real packet-loss ratio from + // ping/pong hit-miss. The viewer's frame-arrival jitter rides in net.pong. + let mut rttcalc = crate::netqos::RttCalc::new(); + let mut losscalc = crate::netqos::LossCalc::new(); + + loop { + tokio::select! { + _ = ping_tick.tick() => { + if let Some(sid) = active_sid.as_deref() { + // A probe still in flight from the previous tick never got a + // pong -> count it as lost and let the loss drive the QoS. + if ping_inflight.is_some() { + let loss = losscalc.record(true); + let _ = events.send(HostEvent::NetStat { rtt_ms: 0, loss }); + } + // Only one probe in flight; a dropped pong just re-probes next tick. + ping_seq = ping_seq.wrapping_add(1); + ping_inflight = Some((ping_seq, std::time::Instant::now())); + let _ = transport.send_text( + json!({"t":"net.ping","sid":sid,"id":ping_seq}).to_string()).await; + } + // Approve-before-connect timeout: nobody answered the prompt in + // time — deny so the viewer gets a clear reason (and the relay's + // own 25s pre-accept reaper never has to fire). + if let Some((sid, _, deadline)) = &approving { + if std::time::Instant::now() >= *deadline { + let sid = sid.clone(); + approving = None; + log::info!("approve: timed out — denying sid={sid}"); + let _ = transport.send_text( + json!({"t":"session.deny","sid":sid,"reason":"not accepted by host"}).to_string()).await; + } + } + } + inbound = transport.recv() => { + let msg = match inbound { + Some(Ok(m)) => m, + Some(Err(e)) => { let _ = events.send(HostEvent::Ended { reason: e.to_string() }); break; } + None => { let _ = events.send(HostEvent::Ended { reason: "socket closed".into() }); break; } + }; + match msg { + TransportMsg::Text(txt) => { + let v: Value = match serde_json::from_str(&txt) { Ok(v) => v, Err(_) => continue }; + match v.get("t").and_then(|x| x.as_str()).unwrap_or("") { + "session.begin" => { + let sid = v.get("sid").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let sid = if sid.is_empty() { gen_nonce_hex() } else { sid }; + // Remember whether this viewer can decode VP9 (+ 4:4:4). + viewer_vp9 = v.get("vp9").and_then(|x| x.as_bool()).unwrap_or(false); + viewer_i444 = v.get("i444").and_then(|x| x.as_bool()).unwrap_or(false); + // Viewer identity the relay attached (IP + account) — shown + // in the approve prompt. + peer_info = ( + v.get("peer").and_then(|x| x.as_str()).unwrap_or("").to_string(), + v.get("user").and_then(|x| x.as_str()).unwrap_or("").to_string(), + ); + if active_sid.is_some() || approving.is_some() { + let _ = transport.send_text( + json!({"t":"session.deny","sid":sid,"reason":"host busy"}).to_string()).await; + } else { + let he = HostE2e::new(); + let nonce = gen_nonce_hex(); + let ch = json!({ + "t": "session.challenge", "sid": sid, + "salt": cfg.salt_b64, "iters": cfg.iters, + "nonce": nonce, "e2e": true, + "totp": !cfg.totp_secret.is_empty(), + "pub": he.our_pub_b64, + }); + pending = Some((sid.clone(), nonce, he)); + let _ = transport.send_text(ch.to_string()).await; + } + } + "session.auth" => { + let sid = v.get("sid").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let proof = v.get("proof").and_then(|x| x.as_str()).unwrap_or(""); + match pending.take() { + Some((psid, nonce, he)) if psid == sid => { + // Channel-bound proof (MITM hardening): if the viewer + // sent `proof_bind`, it must verify against OUR ephemeral + // pubkey — a relay that swapped the pub in the challenge + // can't satisfy this, so a key swap is detected and denied. + // When absent (legacy viewer / non-E2E), fall back to the + // plain proof so nothing regresses. + let bind_ok = match v.get("proof_bind").and_then(|x| x.as_str()) { + Some(pb) => { + let ok = crypto::verify_proof_bound( + &nonce, &cfg.verifier_hex, &he.our_pub_b64, pb); + if !ok { + log::warn!("auth: channel-bound proof MISMATCH (possible relay key-swap) sid={sid}"); + } + ok + } + None => { + log::debug!("auth: no proof_bind (legacy/non-E2E viewer) sid={sid}"); + true + } + }; + if !bind_ok { + let _ = transport.send_text( + json!({"t":"session.deny","sid":sid,"reason":"key verification failed"}).to_string()).await; + } else if !crypto::verify_proof(&nonce, &cfg.verifier_hex, proof) { + let _ = transport.send_text( + json!({"t":"session.deny","sid":sid,"reason":"wrong password"}).to_string()).await; + } else if !cfg.totp_secret.is_empty() + && !crypto::totp_verify( + &cfg.totp_secret, + v.get("totp").and_then(|x| x.as_str()).unwrap_or(""), + 1, + ) + { + log::info!("auth: bad 2FA code sid={sid}"); + let _ = transport.send_text( + json!({"t":"session.deny","sid":sid,"reason":"bad 2FA code"}).to_string()).await; + } else { + let derived = v.get("pub").and_then(|x| x.as_str()) + .and_then(|vp| he.derive(vp).ok()); + // VP9 capability rides session.auth (the server + // strips session.begin's custom fields but relays + // auth). Auth's value wins if present. + if let Some(b) = v.get("vp9").and_then(|x| x.as_bool()) { + viewer_vp9 = b; + } + if let Some(b) = v.get("i444").and_then(|x| x.as_bool()) { + viewer_i444 = b; + } + if cfg.approve { + // Hold the session for the platform's Accept/ + // Reject prompt. 20s beats the relay's 25s + // pre-accept reaper, so the deny is always ours. + log::info!( + "approve: viewer {} {} passed auth — awaiting accept (sid={sid})", + peer_info.0, peer_info.1 + ); + approving = Some(( + sid.clone(), + derived, + std::time::Instant::now() + + std::time::Duration::from_secs(20), + )); + let _ = events.send(HostEvent::ApproveRequest { + sid, + peer: peer_info.0.clone(), + user: peer_info.1.clone(), + }); + } else { + cipher = derived; + active_sid = Some(sid.clone()); + view_only = false; + warned_input = false; + let info = build_info(&cfg, cipher.is_some()); + let _ = transport.send_text( + json!({"t":"session.accept","sid":sid,"info":info}).to_string()).await; + let _ = events.send(HostEvent::ViewerConnected { e2e: cipher.is_some(), vp9: viewer_vp9, i444: viewer_i444 }); + } + } + } + _ => { + let _ = transport.send_text( + json!({"t":"session.deny","sid":sid,"reason":"no pending session"}).to_string()).await; + } + } + } + "i.mouse" if active_sid.is_some() => { + // HOST-ENFORCED gate: a disabled input perm (or + // host-enforced view-only) drops the event here, + // so a modified viewer can't inject anyway. + if !cfg.perms.input || view_only { + if !warned_input { + log::info!( + "perm: input blocked (perms.input={}, view_only={view_only})", + cfg.perms.input + ); + warned_input = true; + } + continue; + } + let _ = events.send(HostEvent::Mouse { + x: v.get("x").and_then(|x| x.as_i64()).unwrap_or(0) as i32, + y: v.get("y").and_then(|x| x.as_i64()).unwrap_or(0) as i32, + buttons: v.get("b").and_then(|x| x.as_u64()).unwrap_or(0) as u32, + wheel: v.get("w").and_then(|x| x.as_f64()).unwrap_or(0.0) as f32, + }); + } + "i.mouse.rel" if active_sid.is_some() => { + // Same HOST-ENFORCED input gate as i.mouse. + if !cfg.perms.input || view_only { + if !warned_input { + log::info!( + "perm: input blocked (perms.input={}, view_only={view_only})", + cfg.perms.input + ); + warned_input = true; + } + continue; + } + let _ = events.send(HostEvent::MouseRel { + dx: v.get("dx").and_then(|x| x.as_i64()).unwrap_or(0) as i32, + dy: v.get("dy").and_then(|x| x.as_i64()).unwrap_or(0) as i32, + buttons: v.get("b").and_then(|x| x.as_u64()).unwrap_or(0) as u32, + wheel: v.get("w").and_then(|x| x.as_f64()).unwrap_or(0.0) as f32, + }); + } + "i.text" if active_sid.is_some() => { + // Same HOST-ENFORCED input gate as i.mouse / i.key. + if !cfg.perms.input || view_only { + if !warned_input { + log::info!( + "perm: input blocked (perms.input={}, view_only={view_only})", + cfg.perms.input + ); + warned_input = true; + } + continue; + } + if let Some(t) = v.get("text").and_then(|x| x.as_str()) { + if !t.is_empty() { + let _ = events.send(HostEvent::Text { text: t.to_string() }); + } + } + } + "i.key" if active_sid.is_some() => { + if !cfg.perms.input || view_only { + if !warned_input { + log::info!( + "perm: input blocked (perms.input={}, view_only={view_only})", + cfg.perms.input + ); + warned_input = true; + } + continue; + } + let code = v.get("c").and_then(|x| x.as_str()).unwrap_or("").to_string(); + if !code.is_empty() { + let _ = events.send(HostEvent::Key { + code, + down: v.get("d").and_then(|x| x.as_bool()).unwrap_or(false), + }); + } + } + // Host-enforced view-only toggle from the viewer: input + // is dropped in the gates above while on (defense in + // depth under the viewer's own local toggle). + "view.only" if active_sid.is_some() => { + let on = v.get("on").and_then(|x| x.as_bool()).unwrap_or(false); + if view_only != on { + view_only = on; + warned_input = false; + log::info!("view-only (host-enforced): {}", if on { "ON" } else { "off" }); + let _ = events.send(HostEvent::ViewOnly { on }); + } + } + "clip.set" if active_sid.is_some() => { + // Only apply viewer-originated clips (the host's + // own clipboard is pushed the other way). + if !cfg.perms.clipboard { + log::info!("perm: clipboard push from viewer blocked"); + continue; + } + if v.get("origin").and_then(|x| x.as_str()) == Some("viewer") { + if let Some(text) = v.get("text").and_then(|x| x.as_str()) { + let _ = events.send(HostEvent::Clipboard { + text: text.to_string(), + }); + } + } + } + "clip.image" if active_sid.is_some() => { + if !cfg.perms.clipboard { + log::info!("perm: clipboard image from viewer blocked"); + continue; + } + if v.get("origin").and_then(|x| x.as_str()) == Some("viewer") { + if let Some(b64) = v.get("png").and_then(|x| x.as_str()) { + use base64::Engine as _; + if let Ok(png) = + base64::engine::general_purpose::STANDARD.decode(b64) + { + let _ = events.send(HostEvent::ClipboardImage { png }); + } + } + } + } + "annot" if active_sid.is_some() => { + // Drawing on the host screen is an interaction, + // so it rides the same input/view-only gate. + if !cfg.perms.input || view_only { + continue; + } + let _ = events.send(HostEvent::Annot { json: txt.clone() }); + } + "sys.cmd" if active_sid.is_some() => { + let _ = events.send(HostEvent::Sys { json: txt.clone() }); + } + "term.open" if active_sid.is_some() => { + let tid = v.get("tid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let _ = events.send(HostEvent::TermOpen { tid }); + } + "term.data" if active_sid.is_some() => { + let tid = v.get("tid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let data = v.get("d").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let _ = events.send(HostEvent::TermInput { tid, data }); + } + "term.resize" if active_sid.is_some() => { + let tid = v.get("tid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let cols = v.get("cols").and_then(|x| x.as_u64()).unwrap_or(80) as u32; + let rows = v.get("rows").and_then(|x| x.as_u64()).unwrap_or(25) as u32; + let _ = events.send(HostEvent::TermResize { tid, cols, rows }); + } + "term.close" if active_sid.is_some() => { + let tid = v.get("tid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let _ = events.send(HostEvent::TermClose { tid }); + } + "file.roots" | "file.list" | "file.walk" | "file.get" | "file.put" + if active_sid.is_some() => + { + if !cfg.perms.files { + log::info!("perm: file request blocked"); + let sid = v.get("sid").and_then(|x| x.as_str()).unwrap_or(""); + let _ = transport.send_text(json!({ + "t":"file.err","sid":sid, + "msg":"file transfer is disabled on this host" + }).to_string()).await; + continue; + } + let _ = events.send(HostEvent::File { json: txt.clone() }); + } + "net.pong" => { + let id = v.get("id").and_then(|x| x.as_u64()).unwrap_or(0); + if let Some((pid, sent)) = ping_inflight { + if pid == id { + let rtt_ms = sent.elapsed().as_millis().min(60_000) as u32; + ping_inflight = None; + // Feed QoS the delay ABOVE the rolling baseline + // (not the absolute RTT), folded with the + // viewer's reported frame-arrival jitter, plus + // the real loss ratio. + let delay = rttcalc.add(rtt_ms); + let jit = v.get("jit").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let loss = losscalc.record(false); + log::debug!( + "netstat: rtt={rtt_ms}ms baseline_delay={delay}ms jit={jit}ms loss={loss:.2} -> qos_delay={}ms", + delay.max(jit) + ); + let _ = events.send(HostEvent::NetStat { + rtt_ms: delay.max(jit), + loss, + }); + } + } + } + // Post-connect capability advertisement. The server + // strips custom handshake fields, so the viewer sends + // its VP9 decode capability here (relayed by sid like + // any input message). Enables auto-VP9 on slow links. + "viewer.caps" => { + let mut changed = false; + if let Some(b) = v.get("vp9").and_then(|x| x.as_bool()) { + viewer_vp9 = b; + changed = true; + } + if let Some(b) = v.get("i444").and_then(|x| x.as_bool()) { + viewer_i444 = b; + changed = true; + } + if changed { + let _ = events.send(HostEvent::ViewerCaps { vp9: viewer_vp9, i444: viewer_i444 }); + } + } + "keyframe" => { let _ = events.send(HostEvent::KeyframeRequested); } + "audio.set" if active_sid.is_some() => { + let src = v.get("src").and_then(|x| x.as_u64()).unwrap_or(0) as u8; + // "off" is always allowed; starting capture needs the perm. + if src != 0 && !cfg.perms.audio { + log::info!("perm: audio blocked"); + continue; + } + let _ = events.send(HostEvent::AudioSource { src }); + } + "set.codec" if active_sid.is_some() => { + // Viewer pins the codec (e.g. H.264 while recording). + let codec = v.get("codec").and_then(|x| x.as_i64()).unwrap_or(-1) as i32; + let _ = events.send(HostEvent::PinCodec { codec }); + } + "monitor.switch" if active_sid.is_some() => { + if let Some(mon) = v.get("mon").and_then(|x| x.as_i64()) { + // -1 (any negative) selects the all-monitors virtual + // desktop (capture::MON_ALL == u32::MAX on the host). + let mon = if mon < 0 { u32::MAX } else { mon as u32 }; + let _ = events.send(HostEvent::SwitchMonitor { mon }); + } + } + "set.quality" => { + let _ = events.send(HostEvent::Quality { + fps: v.get("fps").and_then(|x| x.as_u64()).unwrap_or(30) as u32, + bitrate: v.get("bitrate").and_then(|x| x.as_u64()).unwrap_or(6000) as u32, + scale: v.get("scale").and_then(|x| x.as_f64()).unwrap_or(1.0) as f32, + }); + } + // TCP tunnels live OUTSIDE the video session (server + // portal-gated), so they are handled without an active_sid. + "tunnel.open" => { + let tid = v.get("tid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let thost = v.get("thost").and_then(|x| x.as_str()) + .unwrap_or("127.0.0.1").to_string(); + let port = v.get("port").and_then(|x| x.as_u64()).unwrap_or(0) as u16; + tunnel_tids.insert(tid); + let _ = events.send(HostEvent::TunnelOpen { tid, thost, port }); + } + "tunnel.close" => { + let tid = v.get("tid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + tunnel_tids.remove(&tid); + let _ = events.send(HostEvent::TunnelClose { tid }); + } + "session.end" => { + active_sid = None; + cipher = None; + pending = None; + approving = None; + view_only = false; + let _ = events.send(HostEvent::ViewerLeft); + } + _ => {} + } + } + TransportMsg::Binary(buf) => { + // Raw TCP-tunnel frame [0xF3][tid u32 LE][payload] — server- + // relayed, NOT E2E-sealed. Routed only when tid is an open + // tunnel, so it can't be confused with an E2E session frame + // whose random nonce happens to start with 0xF3. + if buf.len() >= 5 && buf[0] == 0xF3 { + let tid = u32::from_le_bytes([buf[1], buf[2], buf[3], buf[4]]); + if tunnel_tids.contains(&tid) { + let _ = events.send(HostEvent::TunnelData { + tid, + data: buf[5..].to_vec(), + }); + continue; + } + } + // Viewer->host binary is an upload data frame, E2E-sealed + // like video: decrypt, then [0xf2][fid u32][offset u64][data]. + if active_sid.is_some() { + let plain = match &cipher { + Some(c) => match crypto::decrypt_frame(c, &buf) { + Ok(p) => p, + Err(_) => continue, + }, + None => buf, + }; + if plain.len() >= 13 + && (plain[0] == CHAN_FILE_END || plain[0] == CHAN_FILE) + { + if !cfg.perms.files { + log::debug!("perm: upload chunk dropped"); + continue; + } + let fid = u32::from_le_bytes( + [plain[1], plain[2], plain[3], plain[4]]); + let offset = u64::from_le_bytes([ + plain[5], plain[6], plain[7], plain[8], + plain[9], plain[10], plain[11], plain[12], + ]); + let _ = events.send(HostEvent::FileChunk { + fid, + offset, + data: plain[13..].to_vec(), + }); + } else if plain.len() >= 2 && plain[0] == CHAN_VOICE { + // Viewer microphone (voice call): [CHAN_VOICE][opus]. + // Voice rides the audio permission. + if !cfg.perms.audio { + log::debug!("perm: voice packet dropped"); + continue; + } + let _ = events.send(HostEvent::Voice { + opus: plain[1..].to_vec(), + }); + } + } + } + } + } + frame = frames.recv() => { + match frame { + Some(f) => { + if active_sid.is_some() { + // plaintext = CHAN_VIDEO || flags || bitstream, then + // the WHOLE thing is E2E-sealed (viewer decrypts, reads + // plain[0]=channel, plain[1]=flags, plain[2..]=bitstream). + // flags: bit0 = keyframe, bits1-3 = codec id (0=H.264, + // 1=VP9) — the viewer picks the matching decoder. + let mut plain = Vec::with_capacity(2 + f.data.len()); + plain.push(CHAN_VIDEO); + plain.push(u8::from(f.keyframe) | (f.codec << 1)); + plain.extend_from_slice(&f.data); + let wire = match &cipher { + Some(c) => match crypto::encrypt_frame(c, &plain) { Ok(w) => w, Err(_) => continue }, + None => plain, + }; + // Video -> Media lane (drop-tolerant: a late frame is + // worse than a dropped one). + if transport.send_binary_lane(wire, crate::transport::Lane::Media).await.is_err() { break; } + } + } + None => break, // encoder stopped -> end the host run + } + } + ctl = control.recv(), if control_open => { + match ctl { + // The relay routes host->viewer control by `sid`; messages + // without the active sid are dropped, so always include it. + Some(HostControl::Info { w, h }) => { + if let Some(sid) = active_sid.as_deref() { + let _ = transport.send_text( + json!({"t":"session.info","sid":sid,"info":{"w":w,"h":h}}).to_string()).await; + } + } + Some(HostControl::Clipboard { text }) => { + if let Some(sid) = active_sid.as_deref() { + let _ = transport.send_text( + json!({"t":"clip.set","sid":sid,"text":text,"origin":"host"}).to_string()).await; + } + } + Some(HostControl::ClipboardImage { png }) => { + if let Some(sid) = active_sid.as_deref() { + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode(&png); + let _ = transport.send_text( + json!({"t":"clip.image","sid":sid,"png":b64,"origin":"host"}).to_string()).await; + } + } + Some(HostControl::Raw { json }) => { + // Already a complete control object (carries its own sid). + let _ = transport.send_text(json).await; + } + Some(HostControl::TunnelData { tid, data }) => { + // Raw tunnel bytes to the server: [0xF3][tid LE][data], NOT + // E2E-sealed (the server relays it verbatim). Over the relay + // (reliable), never the P2P media lane. + let mut frame = Vec::with_capacity(5 + data.len()); + frame.push(0xF3); + frame.extend_from_slice(&tid.to_le_bytes()); + frame.extend_from_slice(&data); + if transport.send_binary(frame).await.is_err() { + break; + } + } + Some(HostControl::TunnelClosed { tid }) => { + tunnel_tids.remove(&tid); + let _ = transport.send_text( + json!({"t":"tunnel.closed","tid":tid}).to_string()).await; + } + Some(HostControl::TermData { tid, data }) => { + if let Some(sid) = active_sid.as_deref() { + let _ = transport.send_text( + json!({"t":"term.data","sid":sid,"tid":tid,"d":data}).to_string()).await; + } + } + Some(HostControl::TermExit { tid }) => { + if let Some(sid) = active_sid.as_deref() { + let _ = transport.send_text( + json!({"t":"term.exit","sid":sid,"tid":tid}).to_string()).await; + } + } + Some(HostControl::ApproveReply { sid, accept }) => { + // Finalize (or deny) the session held for approve-before- + // connect. A stale reply (sid mismatch — e.g. the 20s + // timeout already denied it) is ignored. + let matches = approving.as_ref().map(|(s, _, _)| s == &sid).unwrap_or(false); + if matches { + let (sid, derived, _) = approving.take().unwrap(); + if accept { + log::info!("approve: ACCEPTED sid={sid}"); + cipher = derived; + active_sid = Some(sid.clone()); + view_only = false; + warned_input = false; + let info = build_info(&cfg, cipher.is_some()); + let _ = transport.send_text( + json!({"t":"session.accept","sid":sid,"info":info}).to_string()).await; + let _ = events.send(HostEvent::ViewerConnected { + e2e: cipher.is_some(), + vp9: viewer_vp9, + i444: viewer_i444, + }); + } else { + log::info!("approve: REJECTED sid={sid}"); + let _ = transport.send_text( + json!({"t":"session.deny","sid":sid,"reason":"not accepted by host"}).to_string()).await; + } + } + } + Some(HostControl::Cursor { id, x, y, hidden }) => { + if let Some(sid) = active_sid.as_deref() { + let _ = transport.send_text( + json!({"t":"cursor","sid":sid,"id":id,"x":x,"y":y,"hidden":hidden}).to_string()).await; + } + } + Some(HostControl::CursorShape { id, css, png, w, h, hx, hy }) => { + if let Some(sid) = active_sid.as_deref() { + use base64::Engine as _; + let mut msg = json!({"t":"cursor.shape","sid":sid,"id":id}); + if let Some(css) = css { + msg["css"] = json!(css); + } + if !png.is_empty() { + msg["png"] = json!(base64::engine::general_purpose::STANDARD.encode(&png)); + msg["w"] = json!(w); + msg["h"] = json!(h); + msg["hx"] = json!(hx); + msg["hy"] = json!(hy); + } + let _ = transport.send_text(msg.to_string()).await; + } + } + Some(HostControl::FileData { fid, offset, data }) => { + // Download chunk host->viewer: [0xf1][fid][offset][data], + // E2E-sealed like video. + if active_sid.is_some() { + let mut plain = Vec::with_capacity(13 + data.len()); + plain.push(CHAN_FILE); + plain.extend_from_slice(&fid.to_le_bytes()); + plain.extend_from_slice(&offset.to_le_bytes()); + plain.extend_from_slice(&data); + let wire = match &cipher { + Some(c) => match crypto::encrypt_frame(c, &plain) { + Ok(w) => w, + Err(_) => continue, + }, + None => plain, + }; + // File download chunk -> Ctl lane (must be reliable). + if transport + .send_binary_lane(wire, crate::transport::Lane::Ctl) + .await + .is_err() + { + break; + } + } + } + Some(HostControl::AudioFrame(opus)) => { + // [CHAN_AUDIO][opus], E2E-sealed like video. Reliable Ctl + // lane: audio dropouts are more jarring than a late video + // frame, and packets are tiny (~50 B at 50/s). + if active_sid.is_some() { + let mut plain = Vec::with_capacity(1 + opus.len()); + plain.push(CHAN_AUDIO); + plain.extend_from_slice(&opus); + let wire = match &cipher { + Some(c) => match crypto::encrypt_frame(c, &plain) { + Ok(w) => w, + Err(_) => continue, + }, + None => plain, + }; + if transport + .send_binary_lane(wire, crate::transport::Lane::Ctl) + .await + .is_err() + { + break; + } + } + } + None => { control_open = false; } // sender dropped; stop polling this arm + } + } + } + } + Ok(()) +} diff --git a/native/core/src/lan.rs b/native/core/src/lan.rs new file mode 100644 index 0000000..6d6aabb --- /dev/null +++ b/native/core/src/lan.rs @@ -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 { + 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, +) -> 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::(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> { + 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 = 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::(&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")); + } +} diff --git a/native/core/src/lib.rs b/native/core/src/lib.rs new file mode 100644 index 0000000..574bc51 --- /dev/null +++ b/native/core/src/lib.rs @@ -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"; diff --git a/native/core/src/netqos.rs b/native/core/src/netqos.rs new file mode 100644 index 0000000..7cb1faf --- /dev/null +++ b/native/core/src/netqos.rs @@ -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, + 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, + 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, + cap: usize, + last_ms: Option, +} + +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::() as f64 / n as f64; + let mad = self + .intervals + .iter() + .map(|&x| (x as f64 - mean).abs()) + .sum::() + / 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"); + } +} diff --git a/native/core/src/p2p.rs b/native/core/src/p2p.rs new file mode 100644 index 0000000..fa9b5b7 --- /dev/null +++ b/native/core/src/p2p.rs @@ -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), + Web(Arc), +} + +/// 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) -> 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>, + 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) -> 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, 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> { + 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, role: P2pRole, api: Arc) -> P2pRelay { + let (app_out_tx, app_out_rx) = mpsc::unbounded_channel::<(TransportMsg, Lane)>(); + let (app_in_tx, app_in_rx) = mpsc::unbounded_channel::>(); + 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 { + serde_json::from_str::(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, P2pConn)>; + +fn spawn_offerer( + api: Arc, + sid: String, + rtc_out: mpsc::UnboundedSender, + mut rtc_in: mpsc::UnboundedReceiver, + 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, + mut rtc_in: mpsc::UnboundedReceiver, + 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, + sid: String, + offer_sdp: String, + ice: Vec, + rtc_out: mpsc::UnboundedSender, + 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, + rtc_out: mpsc::UnboundedSender, + 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, + sid: String, + rtc_out: mpsc::UnboundedSender, + done: DoneTx, +) -> mpsc::UnboundedSender { + let (tx, rx) = mpsc::unbounded_channel::(); + 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, + role: P2pRole, + api: Arc, + mut app_out_rx: mpsc::UnboundedReceiver<(TransportMsg, Lane)>, + app_in_tx: mpsc::UnboundedSender>, + down_tx: mpsc::UnboundedSender<()>, + mut down_rx: mpsc::UnboundedReceiver<()>, +) { + let mut sid: Option = None; + let mut media_on_p2p = false; + let mut p2p_send: Option = None; + let mut _pc: Option> = 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::(); + // rtc.* the upgrade task wants put on the relay. + let (rtc_out_tx, mut rtc_out_rx) = mpsc::unbounded_channel::(); + // 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> = None; + // Negotiated (pc, connection). + let (done_tx, mut done_rx) = + mpsc::unbounded_channel::<(Arc, 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 = 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>), + P2pIn(Option), + RtcOut(Option), + Done(Option<(Arc, 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::(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 = 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)); + } +} diff --git a/native/core/src/proto.rs b/native/core/src/proto.rs new file mode 100644 index 0000000..83268ca --- /dev/null +++ b/native/core/src/proto.rs @@ -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, + /// 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, +} + +/// 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, // 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, + /// 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, + 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, +} + +/// Inbound: accepted. `info` carries host name, dims, e2e flag, etc. +#[derive(Deserialize, Debug, Clone, Default)] +pub struct AcceptInfo { + #[serde(default)] + pub host_name: Option, + #[serde(default)] + pub e2e: bool, + #[serde(default)] + pub w: Option, + #[serde(default)] + pub h: Option, +} + +/// 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 = 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 + } +} diff --git a/native/core/src/recorder.rs b/native/core/src/recorder.rs new file mode 100644 index 0000000..d4ab40e --- /dev/null +++ b/native/core/src/recorder.rs @@ -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>>, + sps: Option>, + pps: Option>, + start_ms: Option, + /// 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, 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, 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, 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) -> 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)) +} diff --git a/native/core/src/rtc.rs b/native/core/src/rtc.rs new file mode 100644 index 0000000..bc7320a --- /dev/null +++ b/native/core/src/rtc.rs @@ -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 = ":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, + #[serde(default)] + pub username: Option, + #[serde(default)] + pub credential: Option, +} + +/// 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 { + 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 { + 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) -> 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> { + 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, gather_cap: Duration) -> Result { + 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, + offer_sdp: &str, + gather_cap: Duration, +) -> Result { + 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, 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 { + 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, + want: &str, + cap: Duration, +) -> Result { + 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>, + channel_label: &str, + send_text: &(dyn Fn(String) + Send + Sync), + rtc_in: &mut mpsc::UnboundedReceiver, +) -> Result<(Arc, Arc)> { + 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::>(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, + send_text: &(dyn Fn(String) + Send + Sync), +) -> Result<(Arc, Arc)> { + let pc = new_pc(api, rtc_config(ice_servers_from_json(&ice))).await?; + let (dc_tx, mut dc_rx) = mpsc::channel::>(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| { + 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) -> 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, + pub ctl: Arc, + pub mov: Arc, +} + +/// 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) -> Result { + 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 { + 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, + pub ctl: Arc, +} + +/// 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>, + send_text: &(dyn Fn(String) + Send + Sync), + rtc_in: &mut mpsc::UnboundedReceiver, +) -> Result<(Arc, 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::>(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, + send_text: &(dyn Fn(String) + Send + Sync), +) -> Result<(Arc, 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>>> = + Arc::new(StdMutex::new(HashMap::new())); + let (open_tx, mut open_rx) = mpsc::unbounded_channel::(); + { + let chans = chans.clone(); + let open_tx = open_tx.clone(); + pc.on_data_channel(Box::new(move |d: Arc| { + 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 })) +} diff --git a/native/core/src/signaling.rs b/native/core/src/signaling.rs new file mode 100644 index 0000000..a16e809 --- /dev/null +++ b/native/core/src/signaling.rs @@ -0,0 +1,1170 @@ +//! WebSocket signaling + session lifecycle against the NetDesk server. +//! Phase 1: connect, complete the auth/E2E handshake, then stream decrypted +//! H.264 video frames out as events. Transport is WSS relay; the P2P (WebRTC) +//! fast path is added in a later phase behind the same event API. + +use crate::crypto::{self, E2e}; +use crate::error::{NdError, Result}; +use crate::proto::*; +use aes_gcm::aead::Aead; +use aes_gcm::{Aes256Gcm, Nonce}; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use rand::RngCore; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU32, Ordering}; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::Message; + +/// Upload chunk size (viewer→host 0xf2 data frames), matching the web viewer's +/// 256 KiB `CHUNK`. The host reassembles by offset, so any value works, but the +/// native client uses the same size the browser does. +pub const UPLOAD_CHUNK: usize = 256 * 1024; + +/// Config for one outbound connection. +#[derive(Clone)] +pub struct ConnectConfig { + pub ws_url: String, // e.g. wss://netdesk.space/ws + pub host_id: String, // 9-digit partner id + pub password: String, + pub totp: String, + pub takeover: bool, +} + +/// Events emitted to the UI / caller during a session. +#[derive(Debug, Clone)] +pub enum SessionEvent { + Connecting, + Challenged { e2e: bool }, + /// Session accepted; the remote screen is about to stream. + Connected { host_name: String, e2e: bool, w: u32, h: u32 }, + /// A decode-ready video access unit, with keyframe flag and codec id + /// (`proto::VideoCodec`: 0 = H.264 Annex-B, 1 = VP9). The viewer selects the + /// matching decoder and can switch mid-session at any keyframe. + Video { keyframe: bool, codec: u8, au: Vec, nals: usize }, + /// A decode-ready Opus packet (one frame) from the host's system audio + /// (CHAN_AUDIO 0xa1, 48 kHz stereo). The UI decodes + plays it if unmuted. + Audio { data: Vec }, + /// Remote mouse cursor position in frame-pixel coordinates (RustDesk-style + /// overlay — sent separately from video so idle screens still move the + /// cursor). `hidden` true means the remote cursor is not shown. + Cursor { id: u64, x: i32, y: i32, hidden: bool }, + /// Remote cursor bitmap: a decoded PNG (`png` bytes) with size + hotspot, or + /// a named standard cursor (`css`). The UI uploads it to a texture. + CursorShape { + id: u64, + png: Vec, + w: u32, + h: u32, + hx: i32, + hy: i32, + css: Option, + }, + /// A chat message from the host (web viewer's inbound `chat`). The host + /// does not currently send a `from`, so it defaults to "Host". + ChatMessage { from: String, text: String }, + /// Remote clipboard text pushed by the host (web viewer's `clip.set`, + /// `origin:"host"`). RustDesk-style clipboard sync. + Clipboard { text: String }, + /// Remote clipboard IMAGE pushed by the host (`clip.image`, origin "host"), + /// as decoded PNG bytes. The UI writes it to the local clipboard. + ClipboardImage { png: Vec }, + /// A live `session.info` update: the host's `info` object serialized as a + /// JSON string. Carries `w,h,mon,monitors,fps,bitrate,host_name,e2e,audio, + /// perms,lock_on_end,privacy_active,logon_screen`. (web viewer's `onInfo`.) + Info { json: String }, + /// Result of a `sys.cmd` action (web viewer's `sys.res`), as a JSON string. + /// Carries `kind` + `ok` + `msg` and, per kind: `items` (processes / the + /// resolution-mode list) with `current:[w,h]` for `modes`, and `on` for the + /// `privacy` / `block_input` / `wallpaper` toggles. + SysResult { json: String }, + + // ---- file transfer (web viewer's Files panel) ------------------------- + /// A remote directory listing — the host's `file.roots.res` (drives / home) + /// or `file.list.res`. `path` is `""` for the roots/home listing, otherwise + /// the directory just listed. Entries arrive pre-sorted by the host (folders + /// first, then case-insensitive by name) and are capped at 2000 by the host. + FileList { path: String, entries: Vec }, + /// A recursive folder manifest (the host's `file.walk.res`): every FILE + /// under the requested folder as (relative forward-slash path, size), so the + /// viewer can recreate the tree and download each entry. `truncated` = the + /// walk hit the host's entry/depth cap. + FileWalk { path: String, files: Vec<(String, u64)>, truncated: bool }, + /// Progress of an in-flight download (identified by the `fid` that + /// `download()` returned). `received` is the number of bytes assembled so + /// far. The host does not send the total size until the transfer completes, + /// so for a percentage the UI uses the size it already has from the listing + /// (`FileEntry::size`) — exactly as the web viewer does. + FileDownloadProgress { fid: u32, received: u64 }, + /// A download finished: the host's `file.done`. `path` is a temp `.part` + /// file the 0xf1 chunks were streamed to on disk (never buffered in RAM); + /// `name` is the remote file's base name and `size` the host's byte count. + /// The consumer MOVES `path` to its destination (delete it on failure). + FileDownloadDone { fid: u32, name: String, size: u64, path: std::path::PathBuf }, + /// A file operation the host reports as successful: the host's `file.ok`. + /// Covers an upload completing *and* a successful `make_dir` / `delete` / + /// `rename`. `refresh == true` means the host wants the current folder + /// re-listed (the web viewer re-sends `file.list` on it). + FileUploadAck { msg: String, refresh: bool }, + /// A file operation failed: the host's `file.err` (bad path, permission + /// denied, upload write error, …). + FileError { msg: String }, + /// Interactive terminal output streamed from the host's shell (`term.data`). + TermData { tid: u32, data: String }, + /// The host's shell for this terminal ended (`term.exit`). + TermExit { tid: u32 }, + + /// The active transport changed: `active` true means media + latency- + /// critical input now ride a direct WebRTC DataChannel (`direct` = a + /// non-TURN pair); false means it fell back to the relay. Surfaced for the + /// UI ("Direct P2P" / "relay") and drives the e2e P2P assertions. Emitted + /// only when NETDESK_P2P is on. + P2pState { active: bool, direct: bool }, + + Denied { reason: String }, + Ended { reason: String }, +} + +/// One entry in a remote directory listing — the web viewer's `{n,t,s,m}` items +/// (see server/web/app.js `onFileList` and host `file_list`). +#[derive(Debug, Clone)] +pub struct FileEntry { + /// File or folder name (no path), the host's `n`. + pub name: String, + /// `true` for a directory (host `t:"d"`), `false` for a file (`t:"f"`). + pub is_dir: bool, + /// Size in bytes (host `s`; 0 for directories). + pub size: u64, + /// Last-modified time in Unix epoch seconds (host `m`). + pub mtime: i64, +} + +/// Reader-side state for one in-flight download. Chunks are written straight to +/// a temp `.part` file on disk (seeking to each chunk's offset) instead of being +/// buffered in RAM, so a multi-gigabyte file never exhausts memory. The consumer +/// moves the finished `.part` to its destination. +struct FileDownload { + received: u64, + pos: u64, // current write position, to avoid a seek on the common sequential path + part: std::path::PathBuf, + writer: Option>, +} + +impl FileDownload { + fn new(fid: u32) -> Self { + let mut part = std::env::temp_dir(); + part.push(format!("netdesk-dl-{fid}-{:x}.part", rand::random::())); + Self { received: 0, pos: 0, part, writer: None } + } + + fn write_chunk(&mut self, off: u64, data: &[u8]) { + use std::io::{Seek, SeekFrom, Write}; + if self.writer.is_none() { + match std::fs::File::create(&self.part) { + Ok(f) => self.writer = Some(std::io::BufWriter::new(f)), + Err(_) => return, + } + } + if let Some(w) = self.writer.as_mut() { + if off != self.pos { + if w.seek(SeekFrom::Start(off)).is_err() { + return; + } + self.pos = off; + } + if w.write_all(data).is_ok() { + self.pos += data.len() as u64; + self.received = self.received.max(self.pos); + } + } + } + + /// Flush and return the path to the completed `.part` file. + fn finish(mut self) -> std::path::PathBuf { + if let Some(mut w) = self.writer.take() { + use std::io::Write; + let _ = w.flush(); + } + self.part + } +} + +/// A monitor/display the host reported in the accept `info.monitors` array. +/// Mirrors the web viewer's `{ i, w, h }` entries (see server/web/app.js onInfo). +#[derive(Debug, Clone)] +pub struct MonitorInfo { + pub i: i32, + pub w: u32, + pub h: u32, +} + +/// A live session handle. Drop it (or call `close`) to end the session. +pub struct Session { + pub info: AcceptInfo, + out: mpsc::UnboundedSender, + sid: String, + monitors: Vec, + cur_mon: i32, + /// Next file-transfer id, handed out by `download()` / `upload_begin()`. + /// Starts at 1, matching the web viewer's `S.nextFid`. + next_fid: AtomicU32, + /// Session AES-256-GCM key, when E2E is active. Needed to encrypt outbound + /// upload (0xf2) binary frames the same way the media path is encrypted; + /// `None` on a non-E2E session (frames sent in the clear, like the browser). + cipher: Option, +} + +impl Session { + /// Send a pointer event (coordinates in the remote screen's pixel space). + pub fn mouse(&self, x: i32, y: i32, buttons: u32, wheel: f32, hwheel: f32) { + let m = MouseInput { t: "i.mouse", x, y, b: buttons, w: wheel, hw: hwheel, sid: self.sid.clone() }; + if let Ok(s) = serde_json::to_string(&m) { + let _ = self.out.send(Message::Text(s)); + } + } + /// Send a RELATIVE pointer move (game / pointer-lock mode): dx/dy are + /// deltas in the remote screen's pixel space, not absolute coordinates. + /// Apps that read raw mouse motion (FPS games, 3D/CAD) consume these. + pub fn mouse_rel(&self, dx: i32, dy: i32, buttons: u32, wheel: f32) { + let _ = self.out.send(Message::Text(format!( + r#"{{"t":"i.mouse.rel","dx":{},"dy":{},"b":{},"w":{},"sid":"{}"}}"#, + dx, dy, buttons, wheel, self.sid + ))); + } + /// Send typed text for translate / international-keyboard mode: the host + /// injects it as literal Unicode (KEYEVENTF_UNICODE), bypassing its + /// layout — how AltGr combos, dead keys and non-US layouts come through. + pub fn text(&self, s: &str) { + self.send_json(serde_json::json!({ "t": "i.text", "text": s, "sid": self.sid })); + } + /// Send a key event (code is a DOM-style key code, e.g. "KeyA", "Enter"). + pub fn key(&self, code: &str, down: bool) { + let m = KeyInput { t: "i.key", code: code.to_string(), down, sid: self.sid.clone() }; + if let Ok(s) = serde_json::to_string(&m) { + let _ = self.out.send(Message::Text(s)); + } + } + /// Annotation: draw a stroke on the host's screen overlay. `pts` are in + /// the remote FRAME pixel space (same as mouse coords); the host maps + /// them to the desktop. `color` is 0xRRGGBB. + pub fn annotate_stroke(&self, color: u32, width: i32, pts: &[(i32, i32)]) { + let arr: Vec<[i32; 2]> = pts.iter().map(|&(x, y)| [x, y]).collect(); + self.send_json(serde_json::json!({ + "t": "annot", "op": "stroke", "color": color, "width": width, + "pts": arr, "sid": self.sid + })); + } + /// Annotation: erase all strokes on the host overlay. + pub fn annotate_clear(&self) { + self.send_json(serde_json::json!({ "t": "annot", "op": "clear", "sid": self.sid })); + } + /// Ask the host for a fresh keyframe (used after a decode resync). + pub fn request_keyframe(&self) { + let _ = self + .out + .send(Message::Text(format!(r#"{{"t":"keyframe","sid":"{}"}}"#, self.sid))); + } + /// Ask the host to (re)configure the encoder: target fps, bitrate (kbps) and + /// resolution scale. Matches the web viewer's `set.quality` control message. + pub fn set_quality(&self, fps: u32, bitrate: u32, scale: f32) { + let _ = self.out.send(Message::Text(format!( + r#"{{"t":"set.quality","fps":{},"bitrate":{},"scale":{},"sid":"{}"}}"#, + fps, bitrate, scale, self.sid + ))); + } + /// Switch the captured monitor (index as reported in `monitors()`). + /// Toggle HOST-ENFORCED view-only for this session: while on, the host + /// drops all mouse/key input from this viewer at the engine (defense in + /// depth under the local UI toggle — a modified viewer can't bypass it). + pub fn view_only(&self, on: bool) { + let _ = self.out.send(Message::Text(format!( + r#"{{"t":"view.only","on":{},"sid":"{}"}}"#, + on, self.sid + ))); + } + + /// Pin the video codec for this session: -1 = auto, 0 = H.264, 1 = VP9. + /// The viewer uses this to force H.264 while recording (the .mp4 muxer is + /// H.264-only), so the host won't auto-switch to VP9 and break the file. + pub fn pin_codec(&self, codec: i32) { + let _ = self.out.send(Message::Text(format!( + r#"{{"t":"set.codec","codec":{},"sid":"{}"}}"#, + codec, self.sid + ))); + } + + /// Pick the host audio source: 0 = off, 1 = system ("what you hear"), 2 = mic. + pub fn audio_set(&self, src: u8) { + let _ = self.out.send(Message::Text(format!( + r#"{{"t":"audio.set","src":{},"sid":"{}"}}"#, + src, self.sid + ))); + } + /// Send one Opus packet of the local microphone to the host (voice call): + /// `[CHAN_VOICE][opus]`, E2E-sealed via `send_binary` like file uploads. + pub fn voice(&self, opus: &[u8]) { + let mut frame = Vec::with_capacity(opus.len() + 1); + frame.push(CHAN_VOICE); + frame.extend_from_slice(opus); + self.send_binary(frame); + } + pub fn monitor_switch(&self, mon: i32) { + let _ = self.out.send(Message::Text(format!( + r#"{{"t":"monitor.switch","mon":{},"sid":"{}"}}"#, + mon, self.sid + ))); + } + /// Send a system command to the host (`"cad"` = Ctrl+Alt+Del, `"lock"`, + /// `"logoff"`, `"reboot"`, …). Matches the web viewer's `sys.cmd` message. + /// This is the escape hatch for any bare-action `sys.cmd`; the named + /// wrappers below cover the actions the web viewer exposes. + pub fn sys(&self, cmd: &str) { + let _ = self.out.send(Message::Text(format!( + r#"{{"t":"sys.cmd","a":"{}","sid":"{}"}}"#, + cmd, self.sid + ))); + } + + /// Serialize a control message to JSON and enqueue it on the outbound + /// channel. Using serde_json guarantees text fields (chat, clipboard, run + /// commands) are correctly escaped. + fn send_json(&self, v: serde_json::Value) { + if let Ok(s) = serde_json::to_string(&v) { + let _ = self.out.send(Message::Text(s)); + } + } + + // ---- clipboard -------------------------------------------------------- + /// Push text to the remote clipboard — and the same message is how the + /// viewer announces its own local clipboard to the host. The host only + /// applies viewer-originated clips (and only when its clipboard perm is on). + /// Wire: `{"t":"clip.set","text":,"origin":"viewer","sid":}`. + /// (There is no separate "request local clipboard" message in the protocol: + /// the viewer reads its own OS clipboard and pushes it with this same call.) + pub fn clipboard(&self, text: &str) { + self.send_json(serde_json::json!({ + "t": "clip.set", "text": text, "origin": "viewer", "sid": self.sid, + })); + } + + /// Push an IMAGE (PNG bytes) to the remote clipboard — the image equivalent + /// of [`clipboard`]. The host applies it when its clipboard perm is on. + /// Wire: `{"t":"clip.image","png":,"origin":"viewer","sid":}`. + pub fn clipboard_image(&self, png: &[u8]) { + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode(png); + self.send_json(serde_json::json!({ + "t": "clip.image", "png": b64, "origin": "viewer", "sid": self.sid, + })); + } + + // ---- chat ------------------------------------------------------------- + /// Send a chat message to the host. + /// Wire: `{"t":"chat","text":,"ts":,"sid":}`. + pub fn chat(&self, text: &str) { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + self.send_json(serde_json::json!({ + "t": "chat", "text": text, "ts": ts, "sid": self.sid, + })); + } + + // ---- interactive terminal ("cmd") ------------------------------------- + /// Open a shell on the host. `tid` identifies this terminal; the host + /// streams its output back as `SessionEvent::TermData{tid,..}`. + pub fn term_open(&self, tid: u32) { + self.send_json(serde_json::json!({ "t": "term.open", "tid": tid, "sid": self.sid })); + } + /// Send keystrokes / a command line to the terminal (include the trailing + /// `\r\n` to run a command). + pub fn term_data(&self, tid: u32, data: &str) { + self.send_json(serde_json::json!({ "t": "term.data", "tid": tid, "d": data, "sid": self.sid })); + } + /// Resize the terminal (cols×rows). A no-op on a pipe-based host shell. + pub fn term_resize(&self, tid: u32, cols: u32, rows: u32) { + self.send_json(serde_json::json!({ + "t": "term.resize", "tid": tid, "cols": cols, "rows": rows, "sid": self.sid, + })); + } + /// Close the terminal's shell on the host. + pub fn term_close(&self, tid: u32) { + self.send_json(serde_json::json!({ "t": "term.close", "tid": tid, "sid": self.sid })); + } + + // ---- power / actions (all map to sys.cmd) ----------------------------- + /// Lock the remote workstation. Wire: `{"t":"sys.cmd","a":"lock","sid":}`. + pub fn lock(&self) { self.sys("lock"); } + /// Sign the remote user out. Wire: `{"t":"sys.cmd","a":"logoff","sid":}`. + pub fn logoff(&self) { self.sys("logoff"); } + /// Reboot the remote computer (ends the session). + /// Wire: `{"t":"sys.cmd","a":"reboot","sid":}`. + pub fn reboot(&self) { self.sys("reboot"); } + /// Shut the remote computer down. Wire: `{"t":"sys.cmd","a":"shutdown","sid":}`. + pub fn shutdown(&self) { self.sys("shutdown"); } + /// Send Ctrl+Alt+Del on the host. Wire: `{"t":"sys.cmd","a":"cad","sid":}`. + pub fn ctrl_alt_del(&self) { self.sys("cad"); } + /// Run a command line on the host (e.g. `cmd /c echo hi`). The host replies + /// with a `sys.res` carrying `ok`/`msg` (delivered as `SessionEvent::SysResult`). + /// Wire: `{"t":"sys.cmd","a":"run","cmd":,"sid":}`. + pub fn run_command(&self, cmd: &str) { + self.send_json(serde_json::json!({ + "t": "sys.cmd", "a": "run", "cmd": cmd, "sid": self.sid, + })); + } + + // ---- toggles ---------------------------------------------------------- + /// Toggle blocking the remote user's own mouse/keyboard. The host flips the + /// state and replies `sys.res` `kind:"block_input"` with the new `on` flag. + /// Wire: `{"t":"sys.cmd","a":"block_input","sid":}`. + pub fn block_input(&self) { self.sys("block_input"); } + /// Toggle privacy mode (host monitor off). The host flips the state and + /// replies `sys.res` `kind:"privacy"` with the new `on` flag; live state is + /// also reflected in `session.info`'s `privacy_active`. + /// Wire: `{"t":"sys.cmd","a":"privacy","sid":}`. + pub fn privacy(&self) { self.sys("privacy"); } + /// Toggle hiding the host wallpaper. The host flips the state and replies + /// `sys.res` `kind:"wallpaper"` with the new `on` flag. + /// Wire: `{"t":"sys.cmd","a":"wallpaper","sid":}`. + pub fn hide_wallpaper(&self) { self.sys("wallpaper"); } + /// Set whether the remote PC locks when this session ends. The host saves + /// its config and replies `sys.res` `ok:true`. + /// Wire: `{"t":"set.cfg","lock_on_end":,"sid":}`. + pub fn set_lock_on_end(&self, on: bool) { + self.send_json(serde_json::json!({ + "t": "set.cfg", "lock_on_end": on, "sid": self.sid, + })); + } + + // ---- screen ----------------------------------------------------------- + /// Refresh the screen (force a fresh keyframe). Same wire message as + /// `request_keyframe`: `{"t":"keyframe","sid":}`. Named for the web + /// viewer's "Refresh screen" menu item. + pub fn refresh_screen(&self) { self.request_keyframe(); } + + // ---- display / resolution --------------------------------------------- + /// Ask the host for its list of display resolutions ("Load resolutions…"). + /// The reply is a `sys.res` `kind:"modes"` with `items:[[w,h],…]` and + /// `current:[w,h]` (delivered as `SessionEvent::SysResult`). + /// Wire: `{"t":"sys.cmd","a":"modes","sid":}`. + pub fn list_modes(&self) { self.sys("modes"); } + /// Set the host's display resolution. The host replies `sys.res` + /// `kind:"setmode"` and forces a keyframe on success; it restores the + /// original resolution at session end. + /// Wire: `{"t":"sys.cmd","a":"setmode","w":,"h":,"sid":}`. + pub fn set_mode(&self, w: u32, h: u32) { + self.send_json(serde_json::json!({ + "t": "sys.cmd", "a": "setmode", "w": w, "h": h, "sid": self.sid, + })); + } + + // ---- processes -------------------------------------------------------- + /// Ask the host for its process list. The reply is a `sys.res` + /// `kind:"processes"` with `items` (delivered as `SessionEvent::SysResult`). + /// Wire: `{"t":"sys.cmd","a":"processes","sid":}`. + pub fn list_processes(&self) { self.sys("processes"); } + /// Kill a process by PID on the host. The host replies `sys.res` `ok`/`msg`. + /// Wire: `{"t":"sys.cmd","a":"kill","pid":,"sid":}`. + pub fn kill_process(&self, pid: u32) { + self.send_json(serde_json::json!({ + "t": "sys.cmd", "a": "kill", "pid": pid, "sid": self.sid, + })); + } + + // NOTE: two of the web viewer's controls are client-only and have NO server + // message, so there is deliberately no Session method for them: + // * Scaling "Best fit" / "Original (1:1)" — a CSS object-fit choice on the + // canvas. (Encoder-side resolution scale is the `scale` arg of + // `set_quality`, which IS a server message and already exists.) + // * "Play remote computer sound" — a local mute/unmute of the audio output; + // the host streams audio whenever `session.info.audio` is true and the + // core emits `SessionEvent::Audio`. The UI decides whether to play it. + // * "Session information…" is likewise assembled on the client from the + // already-received `session.info` (see `SessionEvent::Info`) plus local + // stats; there is no request/response round-trip. + + /// Monitors the host advertised at accept time (may be empty). + pub fn monitors(&self) -> &[MonitorInfo] { + &self.monitors + } + /// Index of the monitor the host started streaming. + pub fn current_monitor(&self) -> i32 { + self.cur_mon + } + + // ---- file transfer ---------------------------------------------------- + // Outbound half of the web viewer's Files panel. The host replies with + // `file.*` JSON (-> FileList / FileUploadAck / FileError / FileDownloadDone) + // and, for downloads, 0xf1 binary chunks (-> FileDownloadProgress). + + /// Request the remote "home" listing — drive letters on Windows (`C:\`, + /// `D:\`, …) or `/` on Unix. The reply (`file.roots.res`) is delivered as + /// `SessionEvent::FileList` with `path == ""`. This is the web viewer's + /// "Home" button / initial Files-panel open. + /// Wire: `{"t":"file.roots","sid":}`. The `sid` is required: the server + /// relays a viewer→host message only when its `sid` matches the session (the + /// web viewer's `send()` auto-adds it to every message); without it the + /// server drops the message and the host never replies. + pub fn list_roots(&self) { + self.send_json(serde_json::json!({ "t": "file.roots", "sid": self.sid })); + } + + /// Request a remote directory listing. An empty `path` is treated by the + /// host as a `file.roots` request. The reply (`file.list.res`) is delivered + /// as `SessionEvent::FileList`. To go "up", pass the parent path; to enter a + /// folder, pass its full path (the UI joins listing entries onto `path`). + /// Wire: `{"t":"file.list","path":,"sid":}`. + pub fn list_dir(&self, path: &str) { + self.send_json(serde_json::json!({ "t": "file.list", "path": path, "sid": self.sid })); + } + + /// Request a recursive manifest of every file under `path` (for folder + /// download). The reply (`file.walk.res`) arrives as `SessionEvent::FileWalk`. + /// Wire: `{"t":"file.walk","path":,"sid":}`. + pub fn walk(&self, path: &str) { + self.send_json(serde_json::json!({ "t": "file.walk", "path": path, "sid": self.sid })); + } + + /// Create a remote folder at `path`. The host replies + /// `file.ok{refresh:true}` (delivered as `SessionEvent::FileUploadAck`). + /// Wire: `{"t":"file.mkdir","path":,"sid":}`. + pub fn make_dir(&self, path: &str) { + self.send_json(serde_json::json!({ "t": "file.mkdir", "path": path, "sid": self.sid })); + } + + /// Delete a remote file, or — with `is_dir` — recursively delete a folder. + /// The host replies `file.ok{refresh:true}` (`SessionEvent::FileUploadAck`). + /// Wire: `{"t":"file.del","path":,"dir":,"sid":}`. + pub fn delete(&self, path: &str, is_dir: bool) { + self.send_json(serde_json::json!({ "t": "file.del", "path": path, "dir": is_dir, "sid": self.sid })); + } + + /// Rename / move a remote entry from `src` to `dst`. The host replies + /// `file.ok{refresh:true}` (`SessionEvent::FileUploadAck`). + /// Wire: `{"t":"file.ren","src":,"dst":,"sid":}`. + pub fn rename(&self, src: &str, dst: &str) { + self.send_json(serde_json::json!({ "t": "file.ren", "src": src, "dst": dst, "sid": self.sid })); + } + + /// Start a remote→viewer download of `remote_path`. Returns the transfer id + /// (`fid`) the host tags its `0xf1` chunks and terminating `file.done` with. + /// As chunks arrive the read loop emits + /// `SessionEvent::FileDownloadProgress{fid,received}`, and on `file.done` it + /// emits `SessionEvent::FileDownloadDone{fid,name,size,path}` with a temp + /// `.part` file the chunks were streamed to on disk (no RAM buffering, so + /// multi-GB files are safe). Errors come back as `SessionEvent::FileError`. + /// Wire: `{"t":"file.get","path":,"fid":,"sid":}`. + pub fn download(&self, remote_path: &str) -> u32 { + let fid = self.alloc_fid(); + self.send_json(serde_json::json!({ "t": "file.get", "path": remote_path, "fid": fid, "sid": self.sid })); + fid + } + + /// Upload `bytes` to remote folder `remote_dir` as file `name` + /// (viewer→host), in one call. Returns the transfer id. Convenience wrapper + /// over the chunked API: sends `file.put` then 0xf2 data frames of up to + /// `UPLOAD_CHUNK` (256 KiB) each. The host writes the bytes to + /// `.netdesk.part`, and once `size` bytes have arrived atomically + /// renames it into place and replies `file.ok{refresh:true}` + /// (`SessionEvent::FileUploadAck`); a write error yields `file.err` + /// (`SessionEvent::FileError`). An empty `remote_dir` makes the host drop + /// the file on the remote Desktop (its TeamViewer-style default for a + /// relative path). + pub fn upload(&self, remote_dir: &str, name: &str, bytes: &[u8]) -> u32 { + let fid = self.upload_begin(remote_dir, name, bytes.len() as u64); + if bytes.is_empty() { + // zero-byte file: the host completes on the first (empty) frame. + self.upload_chunk(fid, 0, &[]); + } else { + let mut off: u64 = 0; + for chunk in bytes.chunks(UPLOAD_CHUNK) { + self.upload_chunk(fid, off, chunk); + off += chunk.len() as u64; + } + } + fid + } + + /// Begin a chunked upload: allocate a transfer id and tell the host the + /// destination path + total `size`. Follow with `upload_chunk` calls whose + /// offsets cover exactly `size` bytes; the host finalizes automatically when + /// the last byte lands (there is no explicit "end" message — the host counts + /// received bytes against `size`, then sends `file.ok`). Returns the `fid`. + /// The path is `remote_dir` joined with `name` and normalized to backslashes + /// (every `/` → `\`), matching the web viewer's + /// `joinPath(...).replace(/\//g,"\\")`. + /// Wire: `{"t":"file.put","path":,"size":,"fid":,"sid":}`. + /// (The binary 0xf2 data frames that follow carry no `sid` — the server + /// routes those by the viewer's own socket, exactly as app.js `sendBinary` + /// does — so only this control message needs it.) + pub fn upload_begin(&self, remote_dir: &str, name: &str, size: u64) -> u32 { + let fid = self.alloc_fid(); + let path = join_remote_path(remote_dir, name); + self.send_json(serde_json::json!({ + "t": "file.put", "path": path, "size": size, "fid": fid, "sid": self.sid, + })); + fid + } + + /// Send one upload data frame for transfer `fid` starting at byte `offset`. + /// The binary frame is `[0xf2][fid: u32 LE][offset: u64 LE][data…]` (a + /// 13-byte header), the exact framing the host's upload reader expects; it + /// is AES-256-GCM encrypted with the session key when E2E is active, just + /// like the media frames. Keep `data.len()` ≤ `UPLOAD_CHUNK` to mirror the + /// browser. + pub fn upload_chunk(&self, fid: u32, offset: u64, data: &[u8]) { + let mut frame = Vec::with_capacity(13 + data.len()); + frame.push(CHAN_FILE_END); // 0xf2 — the viewer→host upload data channel + frame.extend_from_slice(&fid.to_le_bytes()); + frame.extend_from_slice(&offset.to_le_bytes()); + frame.extend_from_slice(data); + self.send_binary(frame); + } + + /// Allocate the next file-transfer id (starts at 1, like `S.nextFid`). + fn alloc_fid(&self) -> u32 { + self.next_fid.fetch_add(1, Ordering::Relaxed) + } + + /// Enqueue a binary frame, AES-GCM-encrypting it (random 12-byte nonce, + /// prepended) when the session is E2E — identical to the web viewer's + /// `sendBinary`. The reader task forwards it to the socket verbatim. + fn send_binary(&self, frame: Vec) { + let payload = match &self.cipher { + Some(c) => { + let mut nonce = [0u8; 12]; + rand::rngs::OsRng.fill_bytes(&mut nonce); + let nonce = Nonce::from(nonce); + match c.encrypt(&nonce, frame.as_ref()) { + Ok(ct) => { + let mut out = Vec::with_capacity(12 + ct.len()); + out.extend_from_slice(&nonce); + out.extend_from_slice(&ct); + out + } + Err(_) => return, // drop on the (practically impossible) GCM error + } + } + None => frame, + }; + let _ = self.out.send(Message::Binary(payload)); + } + + pub fn close(&self) { + let _ = self + .out + .send(Message::Text(format!(r#"{{"t":"session.end","sid":"{}"}}"#, self.sid))); + } +} + +/// Join a remote directory and a file name the way the web viewer does, then +/// normalize to Windows separators (`/` → `\`). An empty `dir` yields just the +/// name. Mirrors app.js `joinPath(dir,name).replace(/\//g,"\\")`. +fn join_remote_path(dir: &str, name: &str) -> String { + let joined = if dir.is_empty() { + name.to_string() + } else if dir.ends_with('/') || dir.ends_with('\\') { + format!("{dir}{name}") + } else { + format!("{dir}/{name}") + }; + joined.replace('/', "\\") +} + +/// Last path component (the file name) of a `/`- or `\`-separated path, used to +/// name a completed download from the host's `file.done` `path`. +fn basename(path: &str) -> String { + let name = path + .rsplit(|c| c == '/' || c == '\\') + .find(|s| !s.is_empty()) + .unwrap_or(""); + if name.is_empty() { + "file".to_string() + } else { + name.to_string() + } +} + +fn install_crypto_provider() { + use std::sync::Once; + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + let _ = rustls::crypto::ring::default_provider().install_default(); + }); +} + +/// Connect and run the handshake. On success returns a `Session` plus a receiver +/// of `SessionEvent`s. The network loop runs on a spawned task until the socket +/// closes or `Session` is dropped. +pub async fn connect( + cfg: ConnectConfig, + events: mpsc::UnboundedSender, +) -> Result { + let _ = events.send(SessionEvent::Connecting); + // rustls 0.23 needs a process-wide crypto provider; install ring once. + install_crypto_provider(); + let transport = crate::transport::WsRelay::connect(&cfg.ws_url).await?; + // Relay-first, with a transparent direct-P2P upgrade when NETDESK_P2P is on. + // The multiplexer owns the relay and hands the loop an unchanged Transport, + // so the relay path is byte-identical when the flag is off. + if crate::p2p::p2p_enabled() { + let api = std::sync::Arc::new( + crate::rtc::build_api().map_err(|e| NdError::Connect(e.to_string()))?, + ); + let mux = crate::p2p::upgrade_relay(Box::new(transport), crate::p2p::P2pRole::Offerer, api); + connect_with(cfg, events, Box::new(mux)).await + } else { + connect_with(cfg, events, Box::new(transport)).await + } +} + +/// Run a session over any [`crate::transport::Transport`] (the WSS relay today, +/// or a P2P WebRTC DataChannel later). `connect` is the thin WSS wrapper. +pub async fn connect_with( + cfg: ConnectConfig, + events: mpsc::UnboundedSender, + mut transport: Box, +) -> Result { + use crate::transport::TransportMsg; + + // outbound queue so input events and control messages can be sent from the + // Session handle while the read loop owns the stream. + let (tx, mut rx) = mpsc::unbounded_channel::(); + + // 1) begin the session + let begin = SessionBegin { + t: "session.begin", + id: &cfg.host_id, + direct: false, + takeover: if cfg.takeover { Some(true) } else { None }, + // Advertise VP9 decode only where we actually have the decoder (the + // libvpx Vp9Decoder is Windows-only for now). + vp9: if cfg!(windows) { Some(true) } else { None }, + }; + transport.send_text(serde_json::to_string(&begin)?).await?; + + // 2) drive the handshake synchronously until we reach accept/deny. + let mut e2e: Option = None; + let mut sid = String::new(); + let info: AcceptInfo; + let mut monitors: Vec = Vec::new(); + let mut cur_mon: i32 = 0; + loop { + let msg = match transport.recv().await { + Some(Ok(m)) => m, + Some(Err(e)) => return Err(e), + None => return Err(NdError::Closed), + }; + match msg { + TransportMsg::Text(txt) => { + let v: serde_json::Value = serde_json::from_str(&txt)?; + match v.get("t").and_then(|t| t.as_str()).unwrap_or("") { + "session.challenge" => { + let ch: Challenge = serde_json::from_value(v)?; + sid = ch.sid.clone(); + let _ = events.send(SessionEvent::Challenged { e2e: ch.e2e }); + let our_pub = if ch.e2e { + if let Some(hp) = &ch.pub_ { + let derived = crypto::derive_e2e(hp)?; + let pb = derived.our_pub_b64.clone(); + e2e = Some(derived); + Some(pb) + } else { + None + } + } else { + None + }; + let proof = crypto::password_proof(&cfg.password, &ch.salt, &ch.nonce, ch.iters)?; + // Channel-bound proof (MITM hardening): only when E2E is + // active (we received the host's pub). Binds that exact pub + // so a relay key-swap is detected host-side. + let proof_bind = match (&ch.pub_, ch.e2e) { + (Some(hp), true) => Some(crypto::password_proof_bound( + &cfg.password, &ch.salt, &ch.nonce, ch.iters, hp, + )?), + _ => None, + }; + let auth = SessionAuth { + t: "session.auth", + proof, + pub_: our_pub, + proof_bind, + totp: cfg.totp.clone(), + sid: sid.clone(), + // Advertise VP9 decode where we have the decoder + // (Windows); the server relays auth to the host. + vp9: if cfg!(windows) { Some(true) } else { None }, + }; + transport.send_text(serde_json::to_string(&auth)?).await?; + } + "session.accept" => { + let accepted: AcceptInfo = v + .get("info") + .cloned() + .map(serde_json::from_value) + .transpose()? + .unwrap_or_default(); + // if the host didn't actually negotiate e2e, drop our key + if !accepted.e2e { + e2e = None; + } + info = accepted.clone(); + // Parse the host's monitor list + current monitor straight from + // the raw accept `info` (proto.rs AcceptInfo stays untouched). + if let Some(infoval) = v.get("info") { + if let Some(arr) = infoval.get("monitors").and_then(|m| m.as_array()) { + for mon in arr { + monitors.push(MonitorInfo { + i: mon.get("i").and_then(|x| x.as_i64()).unwrap_or(0) as i32, + w: mon.get("w").and_then(|x| x.as_u64()).unwrap_or(0) as u32, + h: mon.get("h").and_then(|x| x.as_u64()).unwrap_or(0) as u32, + }); + } + } + cur_mon = infoval.get("mon").and_then(|x| x.as_i64()).unwrap_or(0) as i32; + } + // Advertise decode capabilities now that we're connected. + // The signaling server strips custom fields from the + // handshake, so VP9 capability is delivered here (relayed + // by sid like input) — this is what enables the host's + // auto-VP9 on constrained links. + // The native (Windows) viewer's libvpx decoder handles VP9 + // profile-1 4:4:4, so it advertises i444 too; the host sends + // 4:4:4 only to i444-capable viewers (web/Android decoders omit it). + let caps = format!( + r#"{{"t":"viewer.caps","vp9":{},"i444":{},"sid":"{}"}}"#, + cfg!(windows), + cfg!(windows), + sid + ); + let _ = transport.send_text(caps).await; + let _ = events.send(SessionEvent::Connected { + host_name: accepted.host_name.clone().unwrap_or_default(), + e2e: accepted.e2e, + w: accepted.w.unwrap_or(0), + h: accepted.h.unwrap_or(0), + }); + break; + } + "session.deny" => { + let reason = v.get("reason").and_then(|r| r.as_str()).unwrap_or("denied").to_string(); + let _ = events.send(SessionEvent::Denied { reason: reason.clone() }); + return Err(NdError::Denied(reason)); + } + _ => {} + } + } + TransportMsg::Binary(_) => {} + } + } + + // 3) spawn the steady-state loop: forward outbound queue + handle inbound frames. + let cipher: Option = e2e.map(|e| e.cipher); + // the Session needs the key too, to encrypt outbound upload frames. + let session_cipher = cipher.clone(); + let ev = events.clone(); + tokio::spawn(async move { + // in-flight downloads, keyed by fid: host 0xf1 chunks assembled here and + // emitted as a whole file when the matching `file.done` arrives. + let mut downloads: HashMap = HashMap::new(); + // Outbound commands are queued on each wakeup and flushed AFTER the + // select!, so `ws` is borrowed by only one future at a time. We do NOT + // use futures `.split()`: its BiLock dropped host->viewer replies + // (sys.res / file.*.res) that arrived right after a command was sent, + // which was the "process list / file transfer / block-input never reply" + // bug. This single-task read+write matches the (working) web client. + let mut outbox: std::collections::VecDeque = std::collections::VecDeque::new(); + // Viewer-measured frame-arrival jitter, reported to the host in net.pong so + // the adaptive QoS sees a real end-to-end delay, not just the RTT proxy. + let mut jitter = crate::netqos::Jitter::new(); + let net_start = std::time::Instant::now(); + loop { + tokio::select! { + cmd = rx.recv() => { + match cmd { + Some(m) => outbox.push_back(m), // don't touch ws inside select! + None => break, // Session dropped + } + } + inc = transport.recv() => { + match inc { + Some(Ok(TransportMsg::Binary(buf))) => { + let plain = match &cipher { + Some(c) => match crypto::decrypt_frame(c, &buf) { + Ok(p) => p, + Err(_) => continue, // drop undecryptable chunk + }, + None => buf, + }; + if plain.len() < 2 { continue; } + match plain[0] { + CHAN_VIDEO => { + // Record the arrival for the viewer's jitter estimate. + jitter.frame(net_start.elapsed().as_millis() as u64); + // flags: bit0 = keyframe, bits1-3 = codec id. + let flags = plain[1]; + let keyframe = flags & 1 != 0; + let codec = (flags >> 1) & 0x7; + // NAL count is an H.264-only debug metric. + let nals = if codec == 0 { + split_nals(&plain[2..]).len() + } else { + 0 + }; + let _ = ev.send(SessionEvent::Video { + keyframe, + codec, + au: plain[2..].to_vec(), // codec bitstream + nals, + }); + } + CHAN_AUDIO => { + // plain = [0xa1][opus packet]; hand the raw + // Opus frame to the UI for decode + playback. + if plain.len() > 1 { + let _ = ev.send(SessionEvent::Audio { + data: plain[1..].to_vec(), + }); + } + } + // Download data (host→viewer). The host only ever + // uses 0xf1 for this, but the web viewer treats a + // 0xf2 receive identically, so mirror that. Frame: + // [chan][fid: u32 LE][offset: u64 LE][data…]. + CHAN_FILE | CHAN_FILE_END => { + if plain.len() >= 13 { + let fid = u32::from_le_bytes( + [plain[1], plain[2], plain[3], plain[4]]); + let off = u64::from_le_bytes([ + plain[5], plain[6], plain[7], plain[8], + plain[9], plain[10], plain[11], plain[12], + ]); + let data = &plain[13..]; + let dl = downloads + .entry(fid) + .or_insert_with(|| FileDownload::new(fid)); + dl.write_chunk(off, data); + let _ = ev.send(SessionEvent::FileDownloadProgress { + fid, + received: dl.received, + }); + } + } + _ => {} + } + } + Some(Ok(TransportMsg::Text(txt))) => { + if let Ok(v) = serde_json::from_str::(&txt) { + match v.get("t").and_then(|t| t.as_str()).unwrap_or("") { + "session.end" => { + let reason = v.get("reason").and_then(|r| r.as_str()).unwrap_or("ended").to_string(); + let _ = ev.send(SessionEvent::Ended { reason }); + break; + } + "chat" => { + let from = v.get("from").and_then(|x| x.as_str()).unwrap_or("Host").to_string(); + let text = v.get("text").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let _ = ev.send(SessionEvent::ChatMessage { from, text }); + } + "term.data" => { + let tid = v.get("tid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let data = v.get("d").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let _ = ev.send(SessionEvent::TermData { tid, data }); + } + "term.exit" => { + let tid = v.get("tid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let _ = ev.send(SessionEvent::TermExit { tid }); + } + "clip.set" => { + let text = v.get("text").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let _ = ev.send(SessionEvent::Clipboard { text }); + } + "clip.image" => { + if let Some(b64) = v.get("png").and_then(|x| x.as_str()) { + use base64::Engine as _; + if let Ok(png) = base64::engine::general_purpose::STANDARD.decode(b64) { + let _ = ev.send(SessionEvent::ClipboardImage { png }); + } + } + } + "session.info" => { + // emit the host's `info` object (fall back to the whole message) + let info = v.get("info").cloned().unwrap_or(v); + if let Ok(json) = serde_json::to_string(&info) { + let _ = ev.send(SessionEvent::Info { json }); + } + } + "sys.res" => { + if let Ok(json) = serde_json::to_string(&v) { + let _ = ev.send(SessionEvent::SysResult { json }); + } + } + // ---- file transfer replies ---------------- + "file.roots.res" | "file.list.res" => { + let path = v.get("path").and_then(|x| x.as_str()) + .unwrap_or("").to_string(); + let mut entries = Vec::new(); + if let Some(items) = + v.get("items").and_then(|x| x.as_array()) + { + for it in items { + entries.push(FileEntry { + name: it.get("n").and_then(|x| x.as_str()) + .unwrap_or("").to_string(), + is_dir: it.get("t").and_then(|x| x.as_str()) + == Some("d"), + size: it.get("s").and_then(|x| x.as_u64()) + .unwrap_or(0), + mtime: it.get("m").and_then(|x| x.as_i64()) + .unwrap_or(0), + }); + } + } + let _ = ev.send(SessionEvent::FileList { path, entries }); + } + "file.walk.res" => { + let path = v.get("path").and_then(|x| x.as_str()) + .unwrap_or("").to_string(); + let truncated = v.get("truncated") + .and_then(|x| x.as_bool()).unwrap_or(false); + let mut files = Vec::new(); + if let Some(items) = v.get("items").and_then(|x| x.as_array()) { + for it in items { + let rel = it.get("rel").and_then(|x| x.as_str()) + .unwrap_or("").to_string(); + let size = it.get("s").and_then(|x| x.as_u64()) + .unwrap_or(0); + if !rel.is_empty() { + files.push((rel, size)); + } + } + } + let _ = ev.send(SessionEvent::FileWalk { path, files, truncated }); + } + "file.done" => { + let fid = v.get("fid").and_then(|x| x.as_u64()) + .unwrap_or(0) as u32; + let path = v.get("path").and_then(|x| x.as_str()) + .unwrap_or(""); + let size = v.get("size").and_then(|x| x.as_u64()) + .unwrap_or(0); + let part = match downloads.remove(&fid) { + Some(dl) => dl.finish(), + None => { + // zero-byte file, or no chunks seen: hand + // over an empty .part so the consumer still + // gets a real file. + let dl = FileDownload::new(fid); + let _ = std::fs::File::create(&dl.part); + dl.part + } + }; + let _ = ev.send(SessionEvent::FileDownloadDone { + fid, + name: basename(path), + size, + path: part, + }); + } + "file.ok" => { + let msg = v.get("msg").and_then(|x| x.as_str()) + .unwrap_or("ok").to_string(); + let refresh = v.get("refresh").and_then(|x| x.as_bool()) + .unwrap_or(false); + let _ = ev.send(SessionEvent::FileUploadAck { msg, refresh }); + } + "file.err" => { + let msg = v.get("msg").and_then(|x| x.as_str()) + .unwrap_or("error").to_string(); + let _ = ev.send(SessionEvent::FileError { msg }); + } + // ---- remote cursor overlay (RustDesk-style) ---- + "cursor" => { + let hidden = v.get("hidden").and_then(|x| x.as_bool()).unwrap_or(false); + let id = v.get("id").and_then(|x| x.as_u64()).unwrap_or(0); + let x = v.get("x").and_then(|x| x.as_i64()).unwrap_or(0) as i32; + let y = v.get("y").and_then(|x| x.as_i64()).unwrap_or(0) as i32; + let _ = ev.send(SessionEvent::Cursor { id, x, y, hidden }); + } + "cursor.shape" => { + let id = v.get("id").and_then(|x| x.as_u64()).unwrap_or(0); + let w = v.get("w").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let h = v.get("h").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let hx = v.get("hx").and_then(|x| x.as_i64()).unwrap_or(0) as i32; + let hy = v.get("hy").and_then(|x| x.as_i64()).unwrap_or(0) as i32; + let css = v.get("css").and_then(|x| x.as_str()).map(|s| s.to_string()); + let png = v.get("png").and_then(|x| x.as_str()) + .and_then(|s| B64.decode(s).ok()) + .unwrap_or_default(); + let _ = ev.send(SessionEvent::CursorShape { id, png, w, h, hx, hy, css }); + } + // Synthetic signal injected by the P2P multiplexer (never + // from the peer): the active transport changed. + "p2p.state" => { + let active = v.get("active").and_then(|x| x.as_bool()).unwrap_or(false); + let direct = v.get("direct").and_then(|x| x.as_bool()).unwrap_or(false); + let _ = ev.send(SessionEvent::P2pState { active, direct }); + } + // Host RTT probe: echo it straight back (same sid + id) so + // the host can measure the round trip for adaptive QoS. + "net.ping" => { + let sid = v.get("sid").and_then(|x| x.as_str()).unwrap_or(""); + let id = v.get("id").cloned().unwrap_or(serde_json::Value::from(0)); + // Piggyback the viewer-measured frame jitter so the + // host's QoS gets a real delay signal, not just RTT. + outbox.push_back(Message::Text( + serde_json::json!({"t":"net.pong","sid":sid,"id":id,"jit":jitter.jitter()}).to_string())); + } + _ => {} + } + } + } + None => { + let _ = ev.send(SessionEvent::Ended { reason: "connection closed".into() }); + break; + } + Some(Err(_)) => { + let _ = ev.send(SessionEvent::Ended { reason: "socket error".into() }); + break; + } + } + } + } + // `ws` is free again (the select! futures are dropped). Flush every + // queued outbound command now, plus any that arrived meanwhile. + while let Some(m) = outbox.pop_front() { + let r = match m { + Message::Text(s) => transport.send_text(s).await, + Message::Binary(b) => transport.send_binary_lane(b, crate::transport::Lane::Ctl).await, // viewer binary is only file upload (reliable) + _ => Ok(()), + }; + if r.is_err() { + let _ = ev.send(SessionEvent::Ended { reason: "send failed".into() }); + return; + } + } + while let Ok(m) = rx.try_recv() { + let r = match m { + Message::Text(s) => transport.send_text(s).await, + Message::Binary(b) => transport.send_binary_lane(b, crate::transport::Lane::Ctl).await, // viewer binary is only file upload (reliable) + _ => Ok(()), + }; + if r.is_err() { + let _ = ev.send(SessionEvent::Ended { reason: "send failed".into() }); + return; + } + } + } + }); + + Ok(Session { + info, + out: tx, + sid, + monitors, + cur_mon, + next_fid: AtomicU32::new(1), + cipher: session_cipher, + }) +} diff --git a/native/core/src/transport.rs b/native/core/src/transport.rs new file mode 100644 index 0000000..7126e0a --- /dev/null +++ b/native/core/src/transport.rs @@ -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), + 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) -> 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>; + + /// 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, 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>, +} + +impl WsRelay { + /// Dial the relay (TLS). Mirrors the old `connect_async` + error mapping. + pub async fn connect(ws_url: &str) -> Result { + 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>) -> Self { + Self { ws } + } +} + +#[async_trait] +impl Transport for WsRelay { + async fn send_binary(&mut self, data: Vec) -> 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> { + 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>>, +} + +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 { + 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, out: mpsc::UnboundedSender) { + let reasm: Arc>> = 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, 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, + inbound: mpsc::UnboundedReceiver, + /// 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, + 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) -> Self { + let (in_tx, in_rx) = mpsc::unbounded_channel::(); + + // --- 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 { + &self.dc + } +} + +#[async_trait] +impl Transport for WebRtcChannel { + async fn send_binary(&mut self, data: Vec) -> 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> { + 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>, +} + +/// 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) -> 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> { + 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(); + } +} diff --git a/native/core/src/update.rs b/native/core/src/update.rs new file mode 100644 index 0000000..9a0a045 --- /dev/null +++ b/native/core/src/update.rs @@ -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/` static route — no server code change needed): +//! ```json +//! { "version": "0.2.0", +//! "url": "https://netdesk.space/download/NetDesk-Setup.exe", +//! "sha256": "", "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 { + 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::().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"); + } +} diff --git a/native/core/src/webm.rs b/native/core/src/webm.rs new file mode 100644 index 0000000..6d4c727 --- /dev/null +++ b/native/core/src/webm.rs @@ -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 { + 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 { + 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 { + 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 { + elem(id, &uint_be(v)) +} + +fn ebml_header() -> Vec { + 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 { + 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 { + 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 { + 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 { + 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>, + start_ms: Option, + cluster_base: u64, + cluster: Vec, + open_cluster: bool, + frames: u64, +} + +impl WebmRecorder { + /// `channels` 0 records video only; >0 adds an Opus audio track. + pub fn new(path: impl AsRef, 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> { + 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 { + 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) -> 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); + } +} diff --git a/native/core/tests/host_viewer.rs b/native/core/tests/host_viewer.rs new file mode 100644 index 0000000..742ca68 --- /dev/null +++ b/native/core/tests/host_viewer.rs @@ -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 bool>( + rx: &mut mpsc::UnboundedReceiver, + pred: F, + secs: u64, +) -> Option { + 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 bool>( + rx: &mut mpsc::UnboundedReceiver, + pred: F, + secs: u64, +) -> Option { + 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::(); + let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::(); + 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::(); + 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}"); +} diff --git a/native/core/tests/p2p_loopback.rs b/native/core/tests/p2p_loopback.rs new file mode 100644 index 0000000..3dbb470 --- /dev/null +++ b/native/core/tests/p2p_loopback.rs @@ -0,0 +1,1723 @@ +//! Phase 2 of the direct-P2P build (see P2P-PLAN.md): prove a raw WebRTC +//! DataChannel forms and round-trips between two `RTCPeerConnection`s *in one +//! process*, with no signaling server and no NAT — pure 127.0.0.1 / host-LAN +//! ICE candidates. +//! +//! The in-process signaling shim is two pairs of `tokio::sync::mpsc` channels: +//! one carries each side's trickled ICE candidates to the other peer, and the +//! SDP offer/answer is handed across directly in the test body (only two +//! messages, strictly ordered). This exercises the exact `on_ice_candidate` → +//! `to_json()` → `add_ice_candidate` path that the Phase 4 native<->native +//! trickle upgrade uses over the real relay. Early remote candidates are +//! "buffered" simply by the unbounded channel — we only start draining a +//! peer's inbound candidate queue AFTER its remote description is set, so +//! `add_ice_candidate` never races ahead of `set_remote_description`. +//! +//! webrtc spins up its own tokio tasks, so these tests MUST run on a +//! multi-thread runtime (`flavor = "multi_thread"`), not the default +//! current-thread `#[tokio::test]`. +//! +//! Run: cargo test -p netdesk-core --target x86_64-pc-windows-gnu \ +//! --test p2p_loopback -- --nocapture + +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; +use tokio::sync::mpsc; +use tokio::time::timeout; + +use async_trait::async_trait; +use std::sync::atomic::{AtomicBool, Ordering}; +use netdesk_core::crypto::{gen_host_id, new_host_credentials}; +use netdesk_core::host::{run_host_with, HostConfig, HostControl, HostEvent, HostFrame}; +use netdesk_core::{upgrade_relay, NdError, P2pControl, P2pRole, Session, Transport, TransportMsg}; +use netdesk_core::rtc::{ + answer_nontrickle, answer_offer, apply_answer, build_api, build_api_web, create_web_channels, + new_pc, offer_nontrickle, rtc_config, upgrade_offerer, RtcIceServerJson, +}; +use std::collections::HashMap; +use std::sync::Mutex as StdMutex; +use serde_json::Value; +use netdesk_core::signaling::connect_with; +use netdesk_core::transport::WebRtcChannel; +use netdesk_core::{ConnectConfig, SessionEvent}; + +use webrtc::data_channel::data_channel_init::RTCDataChannelInit; +use webrtc::data_channel::data_channel_message::DataChannelMessage; +use webrtc::data_channel::RTCDataChannel; +use webrtc::ice_transport::ice_candidate::{RTCIceCandidate, RTCIceCandidateInit}; +use webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState; +use webrtc::peer_connection::RTCPeerConnection; + +/// Drain one peer's inbound trickled-candidate queue into the peer connection. +/// Spawned only after that peer's remote description is set, so every +/// `add_ice_candidate` is valid; the unbounded channel is the "early candidate" +/// buffer. +async fn drain_candidates( + pc: Arc, + mut rx: mpsc::UnboundedReceiver, +) { + while let Some(cand) = rx.recv().await { + if let Err(e) = pc.add_ice_candidate(cand).await { + eprintln!("add_ice_candidate: {e}"); + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn datachannel_forms_and_roundtrips() { + // No ICE servers: on loopback the agents gather host candidates (127.0.0.1 + // + the machine's LAN addresses) and connect directly, server-free. + let api = build_api().expect("build_api"); + let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("offerer pc"); + let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("answerer pc"); + + // --- signaling shim: candidate wires (offerer->answerer, answerer->offerer). + let (off_ice_tx, off_ice_rx) = mpsc::unbounded_channel::(); + let (ans_ice_tx, ans_ice_rx) = mpsc::unbounded_channel::(); + + // --- event wires back to the test body. + let (off_open_tx, mut off_open_rx) = mpsc::channel::<()>(1); + let (ans_open_tx, mut ans_open_rx) = mpsc::channel::<()>(1); + let (off_recv_tx, mut off_recv_rx) = mpsc::channel::>(4); + let (ans_recv_tx, mut ans_recv_rx) = mpsc::channel::>(4); + + // ============ register ALL handlers BEFORE create_offer/set_remote ============ + + // Offerer: forward its gathered candidates toward the answerer. + { + let tx = off_ice_tx.clone(); + pc_off.on_ice_candidate(Box::new(move |c: Option| { + let tx = tx.clone(); + Box::pin(async move { + if let Some(c) = c { + if let Ok(init) = c.to_json() { + let _ = tx.send(init); + } + } + }) + })); + } + pc_off.on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| { + Box::pin(async move { eprintln!("offerer pc state: {s}"); }) + })); + + // Offerer creates the reliable+ordered channel it will own. + let dc = pc_off + .create_data_channel( + "netdesk", + Some(RTCDataChannelInit { + ordered: Some(true), + ..Default::default() + }), + ) + .await + .expect("create_data_channel"); + + // Offerer's channel: on open, announce + send the ping; on message, forward + // whatever comes back to the test body. + { + let open_tx = off_open_tx.clone(); + let dc_send = dc.clone(); + dc.on_open(Box::new(move || { + let open_tx = open_tx.clone(); + let dc_send = dc_send.clone(); + Box::pin(async move { + let _ = open_tx.send(()).await; + let _ = dc_send.send(&Bytes::from_static(b"ping")).await; + }) + })); + let recv_tx = off_recv_tx.clone(); + dc.on_message(Box::new(move |msg: DataChannelMessage| { + let recv_tx = recv_tx.clone(); + Box::pin(async move { + let _ = recv_tx.send(msg.data.to_vec()).await; + }) + })); + } + + // Answerer: forward its gathered candidates toward the offerer. + { + let tx = ans_ice_tx.clone(); + pc_ans.on_ice_candidate(Box::new(move |c: Option| { + let tx = tx.clone(); + Box::pin(async move { + if let Some(c) = c { + if let Ok(init) = c.to_json() { + let _ = tx.send(init); + } + } + }) + })); + } + pc_ans.on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| { + Box::pin(async move { eprintln!("answerer pc state: {s}"); }) + })); + + // Answerer binds the incoming channel: on open announce; on message forward + // to the test body AND echo "pong" back so the round-trip closes. + { + let open_tx = ans_open_tx.clone(); + let recv_tx = ans_recv_tx.clone(); + pc_ans.on_data_channel(Box::new(move |dc2: Arc| { + let open_tx = open_tx.clone(); + let recv_tx = recv_tx.clone(); + Box::pin(async move { + let open_tx2 = open_tx.clone(); + dc2.on_open(Box::new(move || { + let open_tx2 = open_tx2.clone(); + Box::pin(async move { + let _ = open_tx2.send(()).await; + }) + })); + let recv_tx2 = recv_tx.clone(); + let dc_echo = dc2.clone(); + dc2.on_message(Box::new(move |msg: DataChannelMessage| { + let recv_tx2 = recv_tx2.clone(); + let dc_echo = dc_echo.clone(); + Box::pin(async move { + let _ = recv_tx2.send(msg.data.to_vec()).await; + let _ = dc_echo.send(&Bytes::from_static(b"pong")).await; + }) + })); + }) + })); + } + + // ============ negotiate (offer/answer handed across directly) ============ + + let offer = pc_off.create_offer(None).await.expect("create_offer"); + pc_off + .set_local_description(offer.clone()) + .await + .expect("offerer set_local"); // starts offerer ICE gathering + + pc_ans + .set_remote_description(offer) + .await + .expect("answerer set_remote(offer)"); + // Answerer's remote desc is set -> safe to add the offerer's candidates. + tokio::spawn(drain_candidates(pc_ans.clone(), off_ice_rx)); + + let answer = pc_ans.create_answer(None).await.expect("create_answer"); + pc_ans + .set_local_description(answer.clone()) + .await + .expect("answerer set_local"); // starts answerer ICE gathering + + pc_off + .set_remote_description(answer) + .await + .expect("offerer set_remote(answer)"); + // Offerer's remote desc is set -> safe to add the answerer's candidates. + tokio::spawn(drain_candidates(pc_off.clone(), ans_ice_rx)); + + // ============ assert: both ends open + the payload round-trips ============ + + let t = Duration::from_secs(20); + timeout(t, off_open_rx.recv()) + .await + .expect("offerer DataChannel open timed out") + .expect("offerer open channel closed"); + timeout(t, ans_open_rx.recv()) + .await + .expect("answerer DataChannel open timed out") + .expect("answerer open channel closed"); + + let got_ping = timeout(t, ans_recv_rx.recv()) + .await + .expect("answerer recv timed out") + .expect("answerer recv channel closed"); + assert_eq!(got_ping, b"ping", "answerer should receive the ping intact"); + + let got_pong = timeout(t, off_recv_rx.recv()) + .await + .expect("offerer recv timed out") + .expect("offerer recv channel closed"); + assert_eq!(got_pong, b"pong", "offerer should receive the echoed pong intact"); + + // Tidy up (also stops the drain tasks when the senders drop). + pc_off.close().await.expect("close offerer"); + pc_ans.close().await.expect("close answerer"); + + println!("P2P_LOOPBACK_OK datachannel formed + round-tripped server-free"); +} + +// ========================= Phase 3: full host<->viewer over the DataChannel ========================= + +/// Register the trickle-ICE candidate shim on both peers and drive the +/// offer/answer exchange. Caller must have registered every dc / on_data_channel +/// handler BEFORE calling this (candidates + the data channel must be in place +/// before set_local_description / set_remote_description). +async fn drive_negotiation(pc_off: &Arc, pc_ans: &Arc) { + let (off_ice_tx, off_ice_rx) = mpsc::unbounded_channel::(); + let (ans_ice_tx, ans_ice_rx) = mpsc::unbounded_channel::(); + { + let tx = off_ice_tx.clone(); + pc_off.on_ice_candidate(Box::new(move |c: Option| { + let tx = tx.clone(); + Box::pin(async move { + if let Some(c) = c { + if let Ok(i) = c.to_json() { + let _ = tx.send(i); + } + } + }) + })); + } + { + let tx = ans_ice_tx.clone(); + pc_ans.on_ice_candidate(Box::new(move |c: Option| { + let tx = tx.clone(); + Box::pin(async move { + if let Some(c) = c { + if let Ok(i) = c.to_json() { + let _ = tx.send(i); + } + } + }) + })); + } + + let offer = pc_off.create_offer(None).await.expect("create_offer"); + pc_off + .set_local_description(offer.clone()) + .await + .expect("offerer set_local"); + // Munge the SDP the peer receives to advertise max-message-size:262144 + // (same as the production offer_nontrickle path), so whole ~200KB frames + // are accepted. + let offer_rx = webrtc::peer_connection::sdp::session_description::RTCSessionDescription::offer( + netdesk_core::advertise_max_message_size(offer.sdp), + ) + .expect("offer munge"); + pc_ans + .set_remote_description(offer_rx) + .await + .expect("answerer set_remote(offer)"); + tokio::spawn(drain_candidates(pc_ans.clone(), off_ice_rx)); + + let answer = pc_ans.create_answer(None).await.expect("create_answer"); + pc_ans + .set_local_description(answer.clone()) + .await + .expect("answerer set_local"); + let answer_rx = + webrtc::peer_connection::sdp::session_description::RTCSessionDescription::answer( + netdesk_core::advertise_max_message_size(answer.sdp), + ) + .expect("answer munge"); + pc_off + .set_remote_description(answer_rx) + .await + .expect("offerer set_remote(answer)"); + tokio::spawn(drain_candidates(pc_off.clone(), ans_ice_rx)); +} + +/// Bring up a connected pair of `WebRtcChannel`s on loopback: the viewer side +/// is the offerer that creates the single reliable+ordered channel, the host +/// side binds it via on_data_channel. Both channels are open on return. The pcs +/// are returned so the caller keeps them alive (and can close them). +async fn connected_channel_pair() -> ( + WebRtcChannel, + WebRtcChannel, + Arc, + Arc, +) { + let api = build_api().expect("build_api"); + let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("viewer pc"); // offerer + let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("host pc"); // answerer + + let (off_open_tx, mut off_open_rx) = mpsc::channel::<()>(1); + let (ans_open_tx, mut ans_open_rx) = mpsc::channel::<()>(1); + let (host_dc_tx, mut host_dc_rx) = mpsc::channel::>(1); + + // Viewer owns the channel. + let dc = pc_off + .create_data_channel( + "netdesk", + Some(RTCDataChannelInit { + ordered: Some(true), + ..Default::default() + }), + ) + .await + .expect("create_data_channel"); + { + let tx = off_open_tx.clone(); + dc.on_open(Box::new(move || { + let tx = tx.clone(); + Box::pin(async move { + let _ = tx.send(()).await; + }) + })); + } + + // Host binds the incoming channel + reports when it opens. + { + let open_tx = ans_open_tx.clone(); + let dc_tx = host_dc_tx.clone(); + pc_ans.on_data_channel(Box::new(move |d: Arc| { + let open_tx = open_tx.clone(); + let dc_tx = dc_tx.clone(); + Box::pin(async move { + let open_tx2 = open_tx.clone(); + d.on_open(Box::new(move || { + let t = open_tx2.clone(); + Box::pin(async move { + let _ = t.send(()).await; + }) + })); + let _ = dc_tx.send(d).await; + }) + })); + } + + drive_negotiation(&pc_off, &pc_ans).await; + + let t = Duration::from_secs(20); + timeout(t, off_open_rx.recv()) + .await + .expect("viewer dc open timed out") + .expect("viewer open channel closed"); + timeout(t, ans_open_rx.recv()) + .await + .expect("host dc open timed out") + .expect("host open channel closed"); + let host_dc = timeout(t, host_dc_rx.recv()) + .await + .expect("host dc never arrived") + .expect("host dc channel closed"); + + // Wrap AFTER open and BEFORE any protocol loop runs, so no app message is + // sent before its reassembly handler is installed. + let viewer_chan = WebRtcChannel::new(dc).await; + let host_chan = WebRtcChannel::new(host_dc).await; + (viewer_chan, host_chan, pc_off, pc_ans) +} + +/// A ~`n`-byte stand-in Annex-B access unit with a varying byte pattern (so a +/// mis-ordered / dropped fragment is caught by the byte-equality check on top +/// of the AES-GCM auth tag). Starts with a NAL start code for realism. +fn make_big_keyframe(n: usize) -> Vec { + let mut v = Vec::with_capacity(n); + v.extend_from_slice(&[0, 0, 0, 1, 0x65]); // start code + IDR NAL header + let mut i: usize = 0; + while v.len() < n { + v.push((i % 251) as u8); + i += 1; + } + v.truncate(n); + v +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn host_viewer_over_datachannel() { + let (viewer_chan, host_chan, pc_off, pc_ans) = connected_channel_pair().await; + + // ---- host over its DataChannel end ---- + let password = "p2p-dc-test-pw"; + let (salt_b64, iters, verifier_hex) = new_host_credentials(password); + let host_id = gen_host_id(); + let (frame_tx, frame_rx) = mpsc::unbounded_channel::(); + let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::(); + let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + let hcfg = HostConfig { + ws_url: "p2p://loopback".into(), // unused: transport is supplied directly + host_id: host_id.clone(), + name: "P2PHost".into(), + salt_b64, + iters, + verifier_hex, + width: 1280, + height: 720, + audio: false, + monitors: Vec::new(), + monitor: 0, + perms: Default::default(), + approve: false, + totp_secret: String::new(), + }; + let host_task = tokio::spawn(async move { + run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_chan)).await + }); + + // ---- viewer over its DataChannel end ---- + let (vtx, mut vrx) = mpsc::unbounded_channel::(); + let vcfg = ConnectConfig { + ws_url: "p2p://loopback".into(), + host_id: host_id.clone(), + password: password.into(), + totp: String::new(), + takeover: false, + }; + let session = connect_with(vcfg, vtx, Box::new(viewer_chan)) + .await + .expect("viewer connect+handshake over DataChannel"); + + // Handshake completed peer-to-peer (no relay): E2E negotiated, size advertised. + match wait_view(&mut vrx, |e| matches!(e, SessionEvent::Connected { .. }), 15).await { + Some(SessionEvent::Connected { w, h, e2e, .. }) => { + assert_eq!((w, h), (1280, 720), "advertised resolution over DataChannel"); + assert!(e2e, "E2E negotiated over DataChannel"); + } + _ => panic!("viewer never got Connected over the DataChannel"), + } + assert!( + wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ViewerConnected { .. }), 15) + .await + .is_some(), + "host never saw ViewerConnected over the DataChannel" + ); + + // Input flows viewer -> host over the ctl path of the channel. + 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 over DataChannel"); + } + _ => panic!("host never received the viewer's mouse over the DataChannel"), + } + 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 over DataChannel"); + } + _ => panic!("host never received the viewer's key over the DataChannel"), + } + + // A small keyframe fed to the host decrypts to Video on the viewer (one chunk). + let small = vec![0, 0, 0, 1, 0x67, 0x42, 0, 0x28, 0, 0, 0, 1, 0x65, 9, 9, 9]; + let _ = frame_tx.send(HostFrame { + data: small.clone(), + keyframe: true, + codec: 0, + }); + match wait_view(&mut vrx, |e| matches!(e, SessionEvent::Video { keyframe: true, .. }), 10).await { + Some(SessionEvent::Video { au, .. }) => { + assert_eq!(au, small, "small keyframe AU must survive the DataChannel intact"); + } + _ => panic!("small keyframe never arrived as Video over the DataChannel"), + } + + // A ~200 KB keyframe exercises fragmentation (>16 KiB -> ~13 chunks) + + // reassembly. If any chunk were mis-ordered or dropped, the rejoined blob + // would fail the AES-GCM auth tag and never emit — so arrival with matching + // bytes proves contiguous reassembly end to end. + let big = make_big_keyframe(200 * 1024); + let _ = frame_tx.send(HostFrame { + data: big.clone(), + keyframe: true, + codec: 0, + }); + match wait_view(&mut vrx, |e| matches!(e, SessionEvent::Video { keyframe: true, .. }), 15).await { + Some(SessionEvent::Video { au, .. }) => { + assert_eq!(au.len(), big.len(), "reassembled keyframe length must match"); + assert_eq!(au, big, "reassembled keyframe bytes identical (GCM proves contiguity)"); + } + _ => panic!("200KB keyframe never reassembled into Video over the DataChannel"), + } + + session.close(); + drop(frame_tx); + let _ = timeout(Duration::from_secs(5), host_task).await; + pc_off.close().await.ok(); + pc_ans.close().await.ok(); + println!("P2P_HOST_VIEWER_OVER_DC_OK video+input+200KB keyframe over the DataChannel, E2E intact"); +} + +// --- waiters (mirror core/tests/host_viewer.rs) --- +async fn wait_host bool>( + rx: &mut mpsc::UnboundedReceiver, + pred: F, + secs: u64, +) -> Option { + 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 bool>( + rx: &mut mpsc::UnboundedReceiver, + pred: F, + secs: u64, +) -> Option { + let deadline = Duration::from_secs(secs); + loop { + match timeout(deadline, rx.recv()).await { + Ok(Some(ev)) => { + if pred(&ev) { + return Some(ev); + } + } + _ => return None, + } + } +} + +// ===================== Phase 4: non-trickle SDP-only negotiation ===================== +// The relay carries only rtc.offer.sdp and rtc.answer.sdp (NON-TRICKLE): every +// ICE candidate is embedded in the gathered SDP, with no separate candidate +// message. This test drives offer_nontrickle / answer_nontrickle / apply_answer +// exactly as the relay path will, exchanging ONLY the two SDP strings (no +// candidate shim), and proves the pc still connects + the DataChannel opens. + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn nontrickle_sdp_exchange_connects() { + let api = build_api().expect("build_api"); + let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("offerer pc"); + let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("answerer pc"); + + let (off_open_tx, mut off_open_rx) = mpsc::channel::<()>(1); + let (ans_open_tx, mut ans_open_rx) = mpsc::channel::<()>(1); + + // Offerer creates the channel BEFORE offer_nontrickle (it shapes the offer). + let dc = pc_off + .create_data_channel( + "netdesk", + Some(RTCDataChannelInit { + ordered: Some(true), + ..Default::default() + }), + ) + .await + .expect("create_data_channel"); + { + let tx = off_open_tx.clone(); + dc.on_open(Box::new(move || { + let tx = tx.clone(); + Box::pin(async move { + let _ = tx.send(()).await; + }) + })); + } + { + let tx = ans_open_tx.clone(); + pc_ans.on_data_channel(Box::new(move |d: Arc| { + let tx = tx.clone(); + Box::pin(async move { + let t = tx.clone(); + d.on_open(Box::new(move || { + let t = t.clone(); + Box::pin(async move { + let _ = t.send(()).await; + }) + })); + }) + })); + } + + // The ENTIRE signaling is these three calls + two SDP strings. No candidate + // exchange at all — exactly the relay topology. + let offer_sdp = offer_nontrickle(&pc_off, Duration::from_millis(2500)) + .await + .expect("offer_nontrickle"); + let answer_sdp = answer_nontrickle(&pc_ans, &offer_sdp, Duration::from_millis(2500)) + .await + .expect("answer_nontrickle"); + apply_answer(&pc_off, &answer_sdp) + .await + .expect("apply_answer"); + + // Non-trickle means candidates must be embedded in the SDP. + assert!( + offer_sdp.contains("a=candidate"), + "offer SDP must embed ICE candidates (non-trickle)" + ); + assert!( + answer_sdp.contains("a=candidate"), + "answer SDP must embed ICE candidates (non-trickle)" + ); + + let t = Duration::from_secs(20); + timeout(t, off_open_rx.recv()) + .await + .expect("offerer dc open timed out") + .expect("offerer open channel closed"); + timeout(t, ans_open_rx.recv()) + .await + .expect("answerer dc open timed out") + .expect("answerer open channel closed"); + + pc_off.close().await.ok(); + pc_ans.close().await.ok(); + println!("P2P_NONTRICKLE_OK offer/answer SDP (candidates embedded) connected, server-free"); +} + +// ============ Phase 4: relay-brokered rtc.* handshake -> live pc ============ +// Proves upgrade_offerer (viewer) + answer_offer (host) negotiate a real pc +// purely by exchanging rtc.* text through a relay-like pipe — exactly what the +// WSS relay carries between the two session peers, minus the server (so the +// rtc.config.req is skipped via ice_override, as on loopback). A dumb +// bidirectional String pipe stands in for the relay. + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn rtc_relay_handshake_connects() { + // relay pipe: viewer->host and host->viewer rtc.* text. + let (v2h_tx, mut v2h_rx) = mpsc::unbounded_channel::(); + let (h2v_tx, mut h2v_rx) = mpsc::unbounded_channel::(); + + // viewer's rtc_in: forward only rtc.* Values arriving from the host. + let (v_rtc_tx, mut v_rtc_rx) = mpsc::unbounded_channel::(); + tokio::spawn(async move { + while let Some(s) = h2v_rx.recv().await { + if let Ok(v) = serde_json::from_str::(&s) { + if v.get("t") + .and_then(|t| t.as_str()) + .map(|t| t.starts_with("rtc.")) + .unwrap_or(false) + { + let _ = v_rtc_tx.send(v); + } + } + } + }); + + // host side: wait for rtc.offer off the relay, then answer it. + let h2v_tx_host = h2v_tx.clone(); + let host = tokio::spawn(async move { + let api = build_api().expect("host build_api"); + loop { + match v2h_rx.recv().await { + Some(s) => { + let v: Value = match serde_json::from_str(&s) { + Ok(v) => v, + Err(_) => continue, + }; + if v.get("t").and_then(|t| t.as_str()) == Some("rtc.offer") { + let sid = v.get("sid").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let sdp = v.get("sdp").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let ice: Vec = v + .get("ice") + .and_then(|x| serde_json::from_value(x.clone()).ok()) + .unwrap_or_default(); + let send = { + let tx = h2v_tx_host.clone(); + move |m: String| { + let _ = tx.send(m); + } + }; + return answer_offer(&api, &sid, &sdp, ice, &send).await; + } + // ignore rtc.config.req + anything else (server-answered, not us) + } + None => return Err(anyhow::anyhow!("relay closed before rtc.offer")), + } + } + }); + + // viewer side: run the offerer handshake. ice_override Some(vec![]) => no + // rtc.config.req, loopback host candidates only. + let send_v = { + let tx = v2h_tx.clone(); + move |m: String| { + let _ = tx.send(m); + } + }; + let viewer = upgrade_offerer( + &build_api().expect("viewer build_api"), + "relay-test-sid", + Some(vec![]), + "netdesk", + &send_v, + &mut v_rtc_rx, + ) + .await; + + let (vpc, vdc) = viewer.expect("viewer upgrade_offerer"); + let (hpc, hdc) = host.await.unwrap().expect("host answer_offer"); + + // Both returned only after pc Connected + DataChannel open. Prove the + // established channel actually carries data end to end. + let (got_tx, mut got_rx) = mpsc::channel::>(1); + hdc.on_message(Box::new(move |msg: DataChannelMessage| { + let got_tx = got_tx.clone(); + Box::pin(async move { + let _ = got_tx.send(msg.data.to_vec()).await; + }) + })); + vdc.send(&Bytes::from_static(b"over-relay")) + .await + .expect("send over negotiated channel"); + let got = timeout(Duration::from_secs(10), got_rx.recv()) + .await + .expect("round-trip timed out") + .expect("round-trip channel closed"); + assert_eq!(got, b"over-relay", "data must cross the relay-negotiated channel"); + + vpc.close().await.ok(); + hpc.close().await.ok(); + println!("P2P_RTC_RELAY_OK rtc.offer/rtc.answer over the relay -> live pc + DataChannel"); +} + +// ===================== Phase 5 harness: in-memory relay ===================== +// A dumb bidirectional in-process pipe that behaves like the WSS relay for a +// single viewer<->host pair: every frame one end sends is delivered verbatim to +// the other. Lets the full connect_with / run_host_with stacks run server-free, +// and (next) lets the P2P multiplexer negotiate over it. + +struct MemTransport { + tx: mpsc::UnboundedSender, + rx: mpsc::UnboundedReceiver, + /// When set, this end silently drops outbound binary (simulates the relay + /// media path going away so only a P2P channel can carry video). + drop_binary: Arc, + /// When set, this end silently drops outbound rtc.* text (simulates a P2P + /// upgrade that can never negotiate, forcing the relay-only fallback). + drop_rtc: Arc, +} + +/// Control handles for one relay end: toggle dropping of binary / rtc.* on it. +#[derive(Clone)] +struct MemCtl { + drop_binary: Arc, + drop_rtc: Arc, +} + +impl MemTransport { + fn ctl(&self) -> MemCtl { + MemCtl { + drop_binary: self.drop_binary.clone(), + drop_rtc: self.drop_rtc.clone(), + } + } +} + +fn mem_relay_pair() -> (MemTransport, MemTransport) { + let (a_tx, a_rx) = mpsc::unbounded_channel::(); + let (b_tx, b_rx) = mpsc::unbounded_channel::(); + // end A writes to b_tx (read by B), end B writes to a_tx (read by A). + ( + MemTransport { + tx: b_tx, + rx: a_rx, + drop_binary: Arc::new(AtomicBool::new(false)), + drop_rtc: Arc::new(AtomicBool::new(false)), + }, + MemTransport { + tx: a_tx, + rx: b_rx, + drop_binary: Arc::new(AtomicBool::new(false)), + drop_rtc: Arc::new(AtomicBool::new(false)), + }, + ) +} + +#[async_trait] +impl Transport for MemTransport { + async fn send_binary(&mut self, data: Vec) -> netdesk_core::Result<()> { + if self.drop_binary.load(Ordering::SeqCst) { + return Ok(()); // silently dropped + } + 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<()> { + if self.drop_rtc.load(Ordering::SeqCst) && text.contains("\"t\":\"rtc.") { + return Ok(()); // silently dropped + } + self.tx + .send(TransportMsg::Text(text)) + .map_err(|_| NdError::Connect("mem relay peer gone".into())) + } + async fn recv(&mut self) -> Option> { + self.rx.recv().await.map(Ok) + } +} + +/// Sanity: the full host<->viewer stack runs over the in-memory relay with NO +/// server and NO P2P — proving the harness faithfully stands in for the WSS +/// relay before we layer the P2P multiplexer on top. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn relay_pair_host_viewer_no_p2p() { + let (viewer_relay, host_relay) = mem_relay_pair(); + + let password = "mem-relay-pw"; + let (salt_b64, iters, verifier_hex) = new_host_credentials(password); + let host_id = gen_host_id(); + let (frame_tx, frame_rx) = mpsc::unbounded_channel::(); + let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::(); + let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + let hcfg = HostConfig { + ws_url: "mem://relay".into(), + host_id: host_id.clone(), + name: "MemHost".into(), + salt_b64, + iters, + verifier_hex, + width: 1600, + height: 900, + audio: false, + monitors: Vec::new(), + monitor: 0, + perms: Default::default(), + approve: false, + totp_secret: String::new(), + }; + 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::(); + let vcfg = ConnectConfig { + ws_url: "mem://relay".into(), + host_id: host_id.clone(), + password: password.into(), + totp: String::new(), + takeover: false, + }; + let session = connect_with(vcfg, vtx, Box::new(viewer_relay)) + .await + .expect("viewer connect over the in-memory relay"); + + match wait_view(&mut vrx, |e| matches!(e, SessionEvent::Connected { .. }), 15).await { + Some(SessionEvent::Connected { w, h, e2e, .. }) => { + assert_eq!((w, h), (1600, 900)); + assert!(e2e, "E2E over the in-memory relay"); + } + _ => panic!("viewer never got Connected over the in-memory relay"), + } + // The viewer advertises VP9 decode in session.begin; the host must surface + // it so the capture loop can safely pick VP9 on constrained links. + match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ViewerConnected { .. }), 15).await { + Some(HostEvent::ViewerConnected { vp9, .. }) => { + assert!(vp9, "host should see the viewer's VP9 capability (negotiation)"); + } + _ => panic!("host never saw ViewerConnected over the in-memory relay"), + } + + 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 not routed over the in-memory relay"), + } + + let au = vec![0, 0, 0, 1, 0x65, 1, 2, 3, 4, 5]; + let _ = frame_tx.send(HostFrame { + data: au.clone(), + keyframe: true, + codec: 0, + }); + match wait_view(&mut vrx, |e| matches!(e, SessionEvent::Video { keyframe: true, .. }), 10).await { + Some(SessionEvent::Video { au: got, .. }) => assert_eq!(got, au), + _ => panic!("video not routed over the in-memory relay"), + } + + session.close(); + drop(frame_tx); + let _ = timeout(Duration::from_secs(5), host_task).await; + println!("MEM_RELAY_OK full host<->viewer stack over an in-memory relay, server-free"); +} + +// ===================== Phase 5: relay-first upgrade / fallback / downgrade ===================== +// Each wraps BOTH ends of the in-memory relay in the P2pRelay multiplexer and +// runs the unchanged connect_with / run_host_with over them. The controllable +// relay lets us prove routing: dropping relay BINARY forces video onto P2P (so +// its arrival proves the upgrade); dropping relay RTC.* forces the relay-only +// fallback; P2pControl::force_relay drives the seamless downgrade. + +struct P2pFixture { + session: Session, + vrx: mpsc::UnboundedReceiver, + hev_rx: mpsc::UnboundedReceiver, + frame_tx: mpsc::UnboundedSender, + host_task: tokio::task::JoinHandle>, + viewer_ctl: P2pControl, + host_ctl: P2pControl, +} + +async fn start_p2p_session(viewer_relay: MemTransport, host_relay: MemTransport) -> P2pFixture { + let viewer_p2p = upgrade_relay( + Box::new(viewer_relay), + P2pRole::Offerer, + Arc::new(build_api().expect("viewer api")), + ); + let host_p2p = upgrade_relay( + Box::new(host_relay), + P2pRole::Answerer, + Arc::new(build_api().expect("host api")), + ); + let viewer_ctl = viewer_p2p.control(); + let host_ctl = host_p2p.control(); + + let password = "p2p-mux-pw"; + let (salt_b64, iters, verifier_hex) = new_host_credentials(password); + let host_id = gen_host_id(); + let (frame_tx, frame_rx) = mpsc::unbounded_channel::(); + let (hev_tx, hev_rx) = mpsc::unbounded_channel::(); + let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + let hcfg = HostConfig { + ws_url: "mem://relay".into(), + host_id: host_id.clone(), + name: "MuxHost".into(), + salt_b64, + iters, + verifier_hex, + width: 1024, + height: 768, + audio: false, + monitors: Vec::new(), + monitor: 0, + perms: Default::default(), + approve: false, + totp_secret: String::new(), + }; + let host_task = tokio::spawn(async move { + run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_p2p)).await + }); + + let (vtx, vrx) = mpsc::unbounded_channel::(); + let vcfg = ConnectConfig { + ws_url: "mem://relay".into(), + host_id: host_id.clone(), + password: password.into(), + totp: String::new(), + takeover: false, + }; + let session = connect_with(vcfg, vtx, Box::new(viewer_p2p)) + .await + .expect("viewer connect over the P2P multiplexer"); + + P2pFixture { + session, + vrx, + hev_rx, + frame_tx, + host_task, + viewer_ctl, + host_ctl, + } +} + +/// Feed `au` as a keyframe up to `tries` times, returning true as soon as the +/// viewer emits a matching Video (used to ride out the few seconds the upgrade +/// takes without a fixed sleep). +async fn feed_until_video(fx: &mut P2pFixture, au: &[u8], tries: u32) -> bool { + for _ in 0..tries { + let _ = fx.frame_tx.send(HostFrame { + data: au.to_vec(), + keyframe: true, + codec: 0, + }); + if let Some(SessionEvent::Video { au: got, .. }) = + wait_view(&mut fx.vrx, |e| matches!(e, SessionEvent::Video { .. }), 1).await + { + if got == au { + return true; + } + } + } + false +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn p2p_upgrade_video_rides_the_channel() { + let (viewer_relay, mut host_relay) = mem_relay_pair(); + // Kill the relay BINARY path host->viewer from the start. The rtc.* handshake + // is text, so it still completes; once P2P is up, video can ONLY arrive over + // the DataChannel. + host_relay.drop_binary.store(true, Ordering::SeqCst); + + let mut fx = start_p2p_session(viewer_relay, host_relay).await; + match wait_view(&mut fx.vrx, |e| matches!(e, SessionEvent::Connected { .. }), 15).await { + Some(SessionEvent::Connected { e2e, .. }) => assert!(e2e, "E2E over the relay handshake"), + _ => panic!("session never connected over the relay"), + } + + // ~40 KB keyframe (fragments over the channel). Arrival proves the upgrade + // took over, since the relay binary path is dead. + let au = make_big_keyframe(40 * 1024); + assert!( + feed_until_video(&mut fx, &au, 40).await, + "video never arrived — P2P upgrade did not take over the dead relay binary path" + ); + + fx.session.close(); + drop(fx.frame_tx); + let _ = timeout(Duration::from_secs(5), fx.host_task).await; + println!("P2P_UPGRADE_OK video rode the DataChannel while the relay binary path was dead"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn p2p_fallback_stays_on_relay() { + let (mut viewer_relay, mut host_relay) = mem_relay_pair(); + // Drop rtc.* on BOTH ends: the upgrade can never negotiate, so media must + // keep flowing over the relay, uninterrupted. + viewer_relay.drop_rtc.store(true, Ordering::SeqCst); + host_relay.drop_rtc.store(true, Ordering::SeqCst); + + let mut fx = start_p2p_session(viewer_relay, host_relay).await; + match wait_view(&mut fx.vrx, |e| matches!(e, SessionEvent::Connected { .. }), 15).await { + Some(_) => {} + _ => panic!("session never connected (relay fallback)"), + } + + // Video flows over the relay (binary path is open). + let au = vec![0, 0, 0, 1, 0x65, 7, 7, 7, 7]; + let _ = fx.frame_tx.send(HostFrame { + data: au.clone(), + keyframe: true, + codec: 0, + }); + match wait_view(&mut fx.vrx, |e| matches!(e, SessionEvent::Video { .. }), 10).await { + Some(SessionEvent::Video { au: got, .. }) => assert_eq!(got, au), + _ => panic!("video did not flow over the relay while the upgrade was failing"), + } + // Input flows over the relay too. + fx.session.mouse(11, 22, 1, 0.0, 0.0); + assert!( + wait_host(&mut fx.hev_rx, |e| matches!(e, HostEvent::Mouse { .. }), 10) + .await + .is_some(), + "input did not flow over the relay during fallback" + ); + + fx.session.close(); + drop(fx.frame_tx); + let _ = timeout(Duration::from_secs(5), fx.host_task).await; + println!("P2P_FALLBACK_OK upgrade could not negotiate; media stayed on the relay"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn p2p_downgrade_reverts_to_relay() { + let (viewer_relay, host_relay) = mem_relay_pair(); + let host_bin = host_relay.drop_binary.clone(); + + let mut fx = start_p2p_session(viewer_relay, host_relay).await; + match wait_view(&mut fx.vrx, |e| matches!(e, SessionEvent::Connected { .. }), 15).await { + Some(_) => {} + _ => panic!("session never connected"), + } + + // 1) Upgrade: kill the relay binary path, prove video rides P2P. + host_bin.store(true, Ordering::SeqCst); + let au = make_big_keyframe(32 * 1024); + assert!( + feed_until_video(&mut fx, &au, 40).await, + "never upgraded to P2P before the downgrade test" + ); + + // 2) Downgrade: force both ends back to the relay and re-open the relay + // binary path. The reroute must be seamless — no Ended, video resumes. + fx.viewer_ctl.force_relay(); + fx.host_ctl.force_relay(); + host_bin.store(false, Ordering::SeqCst); + + let au2 = vec![0, 0, 0, 1, 0x65, 9, 8, 7, 6, 5, 4]; + assert!( + feed_until_video(&mut fx, &au2, 40).await, + "video did not resume over the relay after the forced downgrade" + ); + // The session is still alive (no Ended surfaced during the reroute). + assert!( + !fx.host_task.is_finished(), + "host ended during downgrade — reroute was not seamless" + ); + + fx.session.close(); + drop(fx.frame_tx); + let _ = timeout(Duration::from_secs(5), fx.host_task).await; + println!("P2P_DOWNGRADE_OK forced back to the relay mid-session; video resumed seamlessly"); +} + +// =============== Phase 6: web 3-channel topology forms + routes per lane =============== +// Proves the native peer can speak the web/Python media/ctl/move topology: +// the offerer creates the three labelled channels, the answerer binds them by +// label, and a probe on each arrives on the matching channel. This is the +// interop wire shape (labels + options match app.js); routing it through the +// P2pRelay engine is layered on top. + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn web_channels_form_and_route_per_lane() { + let api = build_api().expect("build_api"); + let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("offerer pc"); + let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("answerer pc"); + + // offerer: the three web channels (created before negotiation so they are + // announced in the offer SDP). + let web = create_web_channels(&pc_off).await.expect("create_web_channels"); + + // answerer: bind each incoming channel by label; record which label each + // received message arrived on. + let (open_tx, mut open_rx) = mpsc::channel::(8); + let got: Arc>>> = Arc::new(StdMutex::new(HashMap::new())); + { + let open_tx = open_tx.clone(); + let got = got.clone(); + pc_ans.on_data_channel(Box::new(move |d: Arc| { + let open_tx = open_tx.clone(); + let got = got.clone(); + let label = d.label().to_string(); + Box::pin(async move { + let l_open = label.clone(); + let open_tx2 = open_tx.clone(); + d.on_open(Box::new(move || { + let l = l_open.clone(); + let tx = open_tx2.clone(); + Box::pin(async move { + let _ = tx.send(l).await; + }) + })); + let l_msg = label.clone(); + let got2 = got.clone(); + d.on_message(Box::new(move |msg: DataChannelMessage| { + let l = l_msg.clone(); + let got2 = got2.clone(); + Box::pin(async move { + got2.lock().unwrap().insert(l, msg.data.to_vec()); + }) + })); + }) + })); + } + + drive_negotiation(&pc_off, &pc_ans).await; + + // all three channels must open on the answerer (labels prove the topology). + let t = Duration::from_secs(20); + let mut opened = std::collections::HashSet::new(); + while opened.len() < 3 { + let lbl = timeout(t, open_rx.recv()) + .await + .expect("web channel open timed out") + .expect("open channel closed"); + opened.insert(lbl); + } + assert!(opened.contains("media") && opened.contains("ctl") && opened.contains("move"), + "expected media/ctl/move, got {opened:?}"); + + // send a distinct probe on each lane; each must arrive on its own channel. + web.media.send(&Bytes::from_static(b"V")).await.expect("media send"); + web.ctl.send(&Bytes::from_static(b"C")).await.expect("ctl send"); + web.mov.send(&Bytes::from_static(b"M")).await.expect("move send"); + + // wait until all three distinct labels have delivered. + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + { + let g = got.lock().unwrap(); + if g.len() >= 3 { + assert_eq!(g.get("media").map(|v| v.as_slice()), Some(&b"V"[..])); + assert_eq!(g.get("ctl").map(|v| v.as_slice()), Some(&b"C"[..])); + assert_eq!(g.get("move").map(|v| v.as_slice()), Some(&b"M"[..])); + break; + } + } + if tokio::time::Instant::now() > deadline { + panic!("not all web channels delivered: {:?}", got.lock().unwrap().keys().collect::>()); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + + pc_off.close().await.ok(); + pc_ans.close().await.ok(); + println!("P2P_WEB3_OK media/ctl/move formed with web labels/options + routed per channel"); +} + + + +/// Pins down webrtc-rs 0.17.2's whole-message behaviour, which decides how +/// web/Python interop is possible: +/// * A whole frame up to ~64 KiB round-trips intact in one message. +/// * A whole ~200 KiB frame: the SEND is accepted (we advertise +/// max-message-size:262144, so the sender's cap is lifted) but the RECEIVER +/// never delivers it — webrtc-rs reassembles whole messages only up to the +/// 64 KiB default, with no setting to raise the receive side. +/// +/// So the native single channel app-fragments at 16 KiB (each chunk a separate +/// <64 KiB message, reassembled by install_reassembler) — which is why +/// host_viewer_over_datachannel carries a 200 KiB keyframe fine. The web/Python +/// `media` channel instead sends ONE whole large frame (SCTP-fragmented by +/// aiortc/the browser), which webrtc-rs 0.17.2 will not reassemble on receive — +/// the concrete blocker for native<->web/Python video interop (see P2P-PLAN.md). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn webrtc_whole_message_64k_receive_limit() { + let api = build_api().expect("build_api"); + let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("offerer pc"); + let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("answerer pc"); + + let (open_tx, mut open_rx) = mpsc::channel::<()>(1); + let (got_tx, mut got_rx) = mpsc::channel::(8); + let dc = pc_off + .create_data_channel( + "ctl", + Some(RTCDataChannelInit { + ordered: Some(true), + ..Default::default() + }), + ) + .await + .expect("create channel"); + { + let tx = open_tx.clone(); + dc.on_open(Box::new(move || { + let tx = tx.clone(); + Box::pin(async move { + let _ = tx.send(()).await; + }) + })); + } + { + let got_tx = got_tx.clone(); + pc_ans.on_data_channel(Box::new(move |d: Arc| { + let got_tx = got_tx.clone(); + Box::pin(async move { + let got_tx = got_tx.clone(); + d.on_message(Box::new(move |msg: DataChannelMessage| { + let got_tx = got_tx.clone(); + Box::pin(async move { + let _ = got_tx.send(msg.data.len()).await; + }) + })); + }) + })); + } + + drive_negotiation(&pc_off, &pc_ans).await; + timeout(Duration::from_secs(20), open_rx.recv()) + .await + .expect("open timeout") + .expect("closed"); + + // A sub-64 KiB whole message round-trips. + let small = vec![0x5Au8; 60 * 1024]; + dc.send(&Bytes::from(small.clone())).await.expect("60K send"); + let n = timeout(Duration::from_secs(5), got_rx.recv()) + .await + .expect("60K recv timed out") + .expect("closed"); + assert_eq!(n, small.len(), "a sub-64KiB whole message must arrive intact"); + + // A ~200 KiB whole message: send accepted (cap lifted), but NOT received. + let big = make_big_keyframe(200 * 1024); + dc.send(&Bytes::from(big)).await.expect("200K send is accepted"); + let late = timeout(Duration::from_secs(3), got_rx.recv()).await; + assert!( + late.is_err() || late.unwrap().is_none(), + "webrtc-rs 0.17.2 does NOT deliver a whole >64KiB message (interop blocker)" + ); + + pc_off.close().await.ok(); + pc_ans.close().await.ok(); + println!("P2P_WHOLEMSG_LIMIT_OK <=64KiB whole msg delivers; a whole 200KiB msg is sent but not received"); +} + +/// THE UNBLOCK: with DETACHED channels (build_api_web) we read with our own +/// 256 KiB buffer, so a whole ~200 KiB message is received intact — exactly +/// what the default on_message path (64 KiB buffer) could not do. This is how a +/// native peer receives whole video frames from the web/Python `media` channel. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn detached_channel_receives_200k_whole() { + let api = build_api_web().expect("build_api_web"); + let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("offerer pc"); + let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("answerer pc"); + + // offerer: detach on open, hand the raw channel to the test body to write on. + let (raw_off_tx, mut raw_off_rx) = + mpsc::channel::>(1); + let dc = pc_off + .create_data_channel( + "ctl", + Some(RTCDataChannelInit { + ordered: Some(true), + ..Default::default() + }), + ) + .await + .expect("create channel"); + { + let dc_for_detach = dc.clone(); + let raw_off_tx = raw_off_tx.clone(); + dc.on_open(Box::new(move || { + let dc_for_detach = dc_for_detach.clone(); + let raw_off_tx = raw_off_tx.clone(); + Box::pin(async move { + if let Ok(raw) = dc_for_detach.detach().await { + let _ = raw_off_tx.send(raw).await; + } + }) + })); + } + + // answerer: detach on open, read with a 256 KiB buffer, forward whole msgs. + let (got_tx, mut got_rx) = mpsc::channel::>(4); + { + let got_tx = got_tx.clone(); + pc_ans.on_data_channel(Box::new(move |d: Arc| { + let got_tx = got_tx.clone(); + Box::pin(async move { + let d_for_detach = d.clone(); + let got_tx = got_tx.clone(); + d.on_open(Box::new(move || { + let d_for_detach = d_for_detach.clone(); + let got_tx = got_tx.clone(); + Box::pin(async move { + if let Ok(raw) = d_for_detach.detach().await { + let mut buf = vec![0u8; 262144]; + loop { + match raw.read_data_channel(&mut buf).await { + Ok((0, _)) => break, + Ok((n, _)) => { + let _ = got_tx.send(buf[..n].to_vec()).await; + } + Err(_) => break, + } + } + } + }) + })); + }) + })); + } + + drive_negotiation(&pc_off, &pc_ans).await; + + let t = Duration::from_secs(20); + let raw_off = timeout(t, raw_off_rx.recv()) + .await + .expect("offerer detach timed out") + .expect("offerer raw channel closed"); + + // 200 KB whole frame over the detached channel. + let frame = make_big_keyframe(200 * 1024); + raw_off + .write_data_channel(&Bytes::from(frame.clone()), false) + .await + .expect("200KB detached write"); + let got = timeout(Duration::from_secs(10), got_rx.recv()) + .await + .expect("200KB detached recv timed out") + .expect("closed"); + assert_eq!(got, frame, "detached channel must receive the 200KB frame whole"); + + pc_off.close().await.ok(); + pc_ans.close().await.ok(); + println!("P2P_DETACH_OK detached channel received a whole 200KB frame (webrtc-rs 64K read limit bypassed)"); +} + + + +/// Diagnostic: does a DETACHED UNRELIABLE media channel (ordered:false, +/// maxPacketLifeTime:150) deliver a whole 200KB frame? (The passing detach test +/// used a reliable ctl channel.) +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn detached_media_unreliable_200k() { + let api = build_api_web().expect("build_api_web"); + let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("off"); + let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("ans"); + let (raw_off_tx, mut raw_off_rx) = + mpsc::channel::>(1); + let dc = pc_off + .create_data_channel( + "media", + Some(RTCDataChannelInit { + ordered: Some(false), + max_packet_life_time: Some(150), + ..Default::default() + }), + ) + .await + .expect("create media"); + { + let dcd = dc.clone(); + let raw_off_tx = raw_off_tx.clone(); + dc.on_open(Box::new(move || { + let dcd = dcd.clone(); + let raw_off_tx = raw_off_tx.clone(); + Box::pin(async move { + if let Ok(raw) = dcd.detach().await { + let _ = raw_off_tx.send(raw).await; + } + }) + })); + } + let (got_tx, mut got_rx) = mpsc::channel::>(4); + { + let got_tx = got_tx.clone(); + pc_ans.on_data_channel(Box::new(move |d: Arc| { + let got_tx = got_tx.clone(); + Box::pin(async move { + let dd = d.clone(); + let got_tx = got_tx.clone(); + d.on_open(Box::new(move || { + let dd = dd.clone(); + let got_tx = got_tx.clone(); + Box::pin(async move { + if let Ok(raw) = dd.detach().await { + tokio::spawn(async move { + let mut buf = vec![0u8; 262144]; + loop { + match raw.read_data_channel(&mut buf).await { + Ok((0, _)) => break, + Ok((n, _)) => { + let _ = got_tx.send(buf[..n].to_vec()).await; + } + Err(_) => break, + } + } + }); + } + }) + })); + }) + })); + } + drive_negotiation(&pc_off, &pc_ans).await; + let raw_off = timeout(Duration::from_secs(20), raw_off_rx.recv()) + .await + .expect("off detach timeout") + .expect("closed"); + let frame = make_big_keyframe(200 * 1024); + raw_off + .write_data_channel(&Bytes::from(frame.clone()), false) + .await + .expect("media write"); + let got = timeout(Duration::from_secs(10), got_rx.recv()) + .await + .expect("media recv timeout") + .expect("closed"); + assert_eq!(got, frame); + pc_off.close().await.ok(); + pc_ans.close().await.ok(); + println!("P2P_MEDIA_UNREL_OK unreliable media delivered 200KB whole"); +} + +/// web-mode with detaches done SEQUENTIALLY in the test body (after opens are +/// signalled) instead of concurrently inside on_open handlers — isolating a +/// multi-detach contention. media(video 200KB host->viewer) + ctl(input +/// viewer->host) over the 3-channel topology. +/// +/// IGNORED: flaky when BOTH pcs + their detach read-loops share ONE process / +/// tokio runtime (this loopback harness) — multi-channel detach intermittently +/// starves the runtime. In production the viewer and the Python/web host are +/// SEPARATE processes with separate runtimes, so this contention does not +/// occur; that path is verified live instead. Single-channel detach +/// (detached_channel_receives_200k_whole) is reliable. +#[ignore = "multi-channel detach flaky only when both pcs share one runtime (loopback); verified live"] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn web_mode_body_detach() { + use std::collections::HashMap as Map; + let api = build_api_web().expect("build_api_web"); + let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("off"); + let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("ans"); + let web = create_web_channels(&pc_off).await.expect("web chans"); + + // offerer open signals (no detach in handler) + let (off_open_tx, mut off_open_rx) = mpsc::channel::(8); + for (lbl, ch) in [("media", &web.media), ("ctl", &web.ctl), ("move", &web.mov)] { + let tx = off_open_tx.clone(); + let lbl = lbl.to_string(); + ch.on_open(Box::new(move || { + let tx = tx.clone(); + let lbl = lbl.clone(); + Box::pin(async move { + let _ = tx.send(lbl).await; + }) + })); + } + + // answerer: collect wrappers by label + open signals (no detach in handler) + let ans_map: Arc>>> = + Arc::new(StdMutex::new(Map::new())); + let (ans_open_tx, mut ans_open_rx) = mpsc::channel::(8); + { + let ans_map = ans_map.clone(); + let ans_open_tx = ans_open_tx.clone(); + pc_ans.on_data_channel(Box::new(move |d: Arc| { + let ans_map = ans_map.clone(); + let ans_open_tx = ans_open_tx.clone(); + let label = d.label().to_string(); + Box::pin(async move { + ans_map.lock().unwrap().insert(label.clone(), d.clone()); + let tx = ans_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).await; + }) + })); + }) + })); + } + + drive_negotiation(&pc_off, &pc_ans).await; + + // wait until media+ctl opened on both sides + let t = Duration::from_secs(20); + let mut off_o = std::collections::HashSet::new(); + while !(off_o.contains("media") && off_o.contains("ctl")) { + off_o.insert(timeout(t, off_open_rx.recv()).await.expect("off open to").expect("c")); + } + let mut ans_o = std::collections::HashSet::new(); + while !(ans_o.contains("media") && ans_o.contains("ctl")) { + ans_o.insert(timeout(t, ans_open_rx.recv()).await.expect("ans open to").expect("c")); + } + + // detach sequentially in the body + let v_media = web.media.detach().await.expect("v media detach"); + let v_ctl = web.ctl.detach().await.expect("v ctl detach"); + let (h_media, h_ctl) = { + let m = ans_map.lock().unwrap(); + (m.get("media").unwrap().clone(), m.get("ctl").unwrap().clone()) + }; + let h_media = h_media.detach().await.expect("h media detach"); + let h_ctl = h_ctl.detach().await.expect("h ctl detach"); + + // viewer reads media (video), host reads ctl (input) + let (vid_tx, mut vid_rx) = mpsc::channel::>(4); + tokio::spawn(async move { + let mut buf = vec![0u8; 262144]; + loop { + match v_media.read_data_channel(&mut buf).await { + Ok((0, _)) => break, + Ok((n, _)) => { let _ = vid_tx.send(buf[..n].to_vec()).await; } + Err(_) => break, + } + } + }); + let (inp_tx, mut inp_rx) = mpsc::channel::>(4); + tokio::spawn(async move { + let mut buf = vec![0u8; 262144]; + loop { + match h_ctl.read_data_channel(&mut buf).await { + Ok((0, _)) => break, + Ok((n, _)) => { let _ = inp_tx.send(buf[..n].to_vec()).await; } + Err(_) => break, + } + } + }); + + let frame = make_big_keyframe(200 * 1024); + h_media.write_data_channel(&Bytes::from(frame.clone()), false).await.expect("h media write"); + let got_vid = timeout(Duration::from_secs(10), vid_rx.recv()).await.expect("vid to").expect("c"); + assert_eq!(got_vid, frame, "viewer must receive whole 200KB video on media"); + + let input = br#"{"t":"i.mouse","x":1,"y":2,"b":1}"#.to_vec(); + v_ctl.write_data_channel(&Bytes::from(input.clone()), true).await.expect("v ctl write"); + let got_inp = timeout(Duration::from_secs(10), inp_rx.recv()).await.expect("inp to").expect("c"); + assert_eq!(got_inp, input, "host must receive input on ctl"); + + pc_off.close().await.ok(); + pc_ans.close().await.ok(); + println!("P2P_WEBMODE_OK web topology (body-detach): 200KB video + input over media/ctl"); +} + +/// Re-upgrade hysteresis: after a forced downgrade, the offerer retries the +/// upgrade after the backoff and media rides P2P again (a transient drop +/// recovers instead of staying on the relay forever). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn p2p_reupgrade_after_drop() { + std::env::set_var("NETDESK_P2P_REUP_MS", "1200"); // short backoff for the test + let (viewer_relay, host_relay) = mem_relay_pair(); + let host_bin = host_relay.drop_binary.clone(); + + let mut fx = start_p2p_session(viewer_relay, host_relay).await; + match wait_view(&mut fx.vrx, |e| matches!(e, SessionEvent::Connected { .. }), 15).await { + Some(_) => {} + _ => panic!("no Connected"), + } + + // 1) initial upgrade: drop relay binary -> video can only ride P2P. + host_bin.store(true, Ordering::SeqCst); + let au = make_big_keyframe(32 * 1024); + assert!(feed_until_video(&mut fx, &au, 40).await, "never upgraded initially"); + + // 2) force a downgrade; re-open the relay so video resumes there. + fx.viewer_ctl.force_relay(); + fx.host_ctl.force_relay(); + host_bin.store(false, Ordering::SeqCst); + let au2 = vec![0, 0, 0, 1, 0x65, 3, 1, 4, 1, 5, 9]; + assert!(feed_until_video(&mut fx, &au2, 40).await, "no relay video after downgrade"); + + // 3) re-upgrade: after the backoff the offerer retries. Drop relay binary + // again; video arriving proves it rode the RE-upgraded P2P channel. + host_bin.store(true, Ordering::SeqCst); + let au3 = make_big_keyframe(28 * 1024); + assert!( + feed_until_video(&mut fx, &au3, 60).await, + "did not re-upgrade to P2P after the drop (video never returned while relay binary was dead)" + ); + + std::env::remove_var("NETDESK_P2P_REUP_MS"); + fx.session.close(); + drop(fx.frame_tx); + let _ = timeout(Duration::from_secs(5), fx.host_task).await; + println!("P2P_REUPGRADE_OK dropped session recovered to P2P after the backoff"); +} + +/// Adaptive-QoS telemetry: the host periodically probes RTT (net.ping), the +/// viewer echoes (net.pong), and the host emits HostEvent::NetStat with the +/// measured round trip — the signal that drives host/src/qos.rs. Verified over +/// the in-memory relay (no server). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn host_measures_rtt_netstat() { + let (viewer_relay, host_relay) = mem_relay_pair(); + + let password = "qos-rtt-pw"; + let (salt_b64, iters, verifier_hex) = new_host_credentials(password); + let host_id = gen_host_id(); + let (_frame_tx, frame_rx) = mpsc::unbounded_channel::(); + let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::(); + let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + let hcfg = HostConfig { + ws_url: "mem://relay".into(), + host_id: host_id.clone(), + name: "QosHost".into(), + salt_b64, + iters, + verifier_hex, + width: 1280, + height: 720, + audio: false, + monitors: Vec::new(), + monitor: 0, + perms: Default::default(), + approve: false, + totp_secret: String::new(), + }; + 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::(); + let vcfg = ConnectConfig { + ws_url: "mem://relay".into(), + host_id: host_id.clone(), + password: password.into(), + totp: String::new(), + takeover: false, + }; + let session = connect_with(vcfg, vtx, Box::new(viewer_relay)) + .await + .expect("viewer connect"); + assert!( + wait_view(&mut vrx, |e| matches!(e, SessionEvent::Connected { .. }), 10) + .await + .is_some() + ); + + // Within a couple of probe intervals the host should measure a round trip. + match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::NetStat { .. }), 8).await { + Some(HostEvent::NetStat { rtt_ms, loss }) => { + assert!(rtt_ms < 5000, "loopback RTT should be small, got {rtt_ms}ms"); + assert_eq!(loss, 0.0); + println!("QOS_RTT_OK host measured rtt={rtt_ms}ms via net.ping/pong"); + } + _ => panic!("host never emitted NetStat (net.ping/pong round-trip failed)"), + } + + session.close(); + let _ = timeout(Duration::from_secs(5), host_task).await; +} diff --git a/native/core/tests/perms_enforce.rs b/native/core/tests/perms_enforce.rs new file mode 100644 index 0000000..2ba17d7 --- /dev/null +++ b/native/core/tests/perms_enforce.rs @@ -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, + rx: mpsc::UnboundedReceiver, +} + +fn mem_relay_pair() -> (MemTransport, MemTransport) { + let (a_tx, a_rx) = mpsc::unbounded_channel::(); + let (b_tx, b_rx) = mpsc::unbounded_channel::(); + (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) -> 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> { + self.rx.recv().await.map(Ok) + } +} + +async fn wait_host bool>( + rx: &mut mpsc::UnboundedReceiver, + pred: F, + secs: u64, +) -> Option { + 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 bool>( + rx: &mut mpsc::UnboundedReceiver, + pred: F, + secs: u64, +) -> Option { + 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 bool>( + rx: &mut mpsc::UnboundedReceiver, + marker: F, + secs: u64, +) -> Vec { + 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::(); + let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::(); + let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + 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::(); + 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::(); + let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::(); + let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + 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::(); + 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::(); + let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::(); + let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + 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::(); + 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 { + 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::(); + let (hev_tx, _hev_rx) = mpsc::unbounded_channel::(); + let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + 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::(); + let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::(); + let (hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + 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::(); + let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::(); + let (hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + 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::(); + let (hev_tx, _hev_rx) = mpsc::unbounded_channel::(); + let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + 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::(); + let (hev_tx, _hev_rx) = mpsc::unbounded_channel::(); + let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + 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::(); + let (hev_tx, _hev) = mpsc::unbounded_channel::(); + let (_hc, hctl_rx) = mpsc::unbounded_channel::(); + 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::(); + let (hev_tx, _hev) = mpsc::unbounded_channel::(); + let (_hc, hctl_rx) = mpsc::unbounded_channel::(); + 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::(); + let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::(); + let (hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + 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"); +} diff --git a/native/docs/build-android.md b/native/docs/build-android.md new file mode 100644 index 0000000..b1a412f --- /dev/null +++ b/native/docs/build-android.md @@ -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//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//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//`, +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 ` | 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//`. 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 = '' +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 `. 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`. diff --git a/native/docs/build-ios.md b/native/docs/build-ios.md new file mode 100644 index 0000000..7c70520 --- /dev/null +++ b/native/docs/build-ios.md @@ -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 +``` + +--- + +## 2. Prerequisites + +### 2.1 Apple Developer Program — **$99/year** + +A paid **Apple Developer Program** membership (, +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 `.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 --apiIssuer +``` + +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) | diff --git a/native/docs/build-linux.md b/native/docs/build-linux.md new file mode 100644 index 0000000..bc480e2 --- /dev/null +++ b/native/docs/build-linux.md @@ -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--x86_64.AppImage` | No — `chmod +x` and run anywhere | +| **.deb** | `netdesk__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 ` | 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. diff --git a/native/docs/build-macos.md b/native/docs/build-macos.md new file mode 100644 index 0000000..9093f1e --- /dev/null +++ b/native/docs/build-macos.md @@ -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 `, `--version `, `--entitlements +`. 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 + + — \$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 → +**+** → *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 + + → **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 → +*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 `): + `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 \ + -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 --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. diff --git a/native/docs/cross-platform-decode.md b/native/docs/cross-platform-decode.md new file mode 100644 index 0000000..9fb1985 --- /dev/null +++ b/native/docs/cross-platform-decode.md @@ -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 } // w*h*3 bytes +pub trait VideoDecoder { fn decode(&mut self, au: &[u8]) -> Result>; } +pub fn best_decoder() -> Box; +``` + +`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_.rs` implementing `VideoDecoder` + `unsafe impl + Send`, with a `new() -> Result` 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. diff --git a/native/frb/Cargo.toml b/native/frb/Cargo.toml new file mode 100644 index 0000000..5849dd7 --- /dev/null +++ b/native/frb/Cargo.toml @@ -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 } diff --git a/native/frb/flutter_rust_bridge.yaml b/native/frb/flutter_rust_bridge.yaml new file mode 100644 index 0000000..c3cca2f --- /dev/null +++ b/native/frb/flutter_rust_bridge.yaml @@ -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 diff --git a/native/frb/src/api.rs b/native/frb/src/api.rs new file mode 100644 index 0000000..cbc8195 --- /dev/null +++ b/native/frb/src/api.rs @@ -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>` 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, + }, + /// 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 = 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> { + static SESSIONS: OnceLock>> = 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>> { + static PENDING: OnceLock>>> = + 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>> { + static CANCELS: OnceLock>>> = 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 { + 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::(); + 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::(); + 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 sessionEvents({required BigInt handle})`. +/// Call it once per handle, right after `connect`. +pub fn session_events(handle: u64, sink: StreamSink) { + 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(handle: u64, f: impl FnOnce(&Session) -> R) -> Option { + 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 { + 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 { + 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>>>; + +/// 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> { + static M: OnceLock>> = 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>> { + static M: OnceLock>>> = OnceLock::new(); + M.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Per-handle "stop" flags for the host reconnect loop. +fn host_cancels() -> &'static Mutex>> { + static M: OnceLock>>> = 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::(); + 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::(); + 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::(); + *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::(); + 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) { + 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, 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); +} diff --git a/native/frb/src/frb_generated.rs b/native/frb/src/frb_generated.rs new file mode 100644 index 0000000..8e88a58 --- /dev/null +++ b/native/frb/src/frb_generated.rs @@ -0,0 +1,1350 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.13.0. + +#![allow( + non_camel_case_types, + unused, + non_snake_case, + clippy::needless_return, + clippy::redundant_closure_call, + clippy::redundant_closure, + clippy::useless_conversion, + clippy::unit_arg, + clippy::unused_unit, + clippy::double_parens, + clippy::let_and_return, + clippy::too_many_arguments, + clippy::match_single_binding, + clippy::clone_on_copy, + clippy::let_unit_value, + clippy::deref_addrof, + clippy::explicit_auto_deref, + clippy::borrow_deref_ref, + clippy::uninlined_format_args, + clippy::needless_borrow, + mismatched_lifetime_syntaxes +)] + +// Section: imports + +use flutter_rust_bridge::for_generated::byteorder::{NativeEndian, ReadBytesExt, WriteBytesExt}; +use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; +use flutter_rust_bridge::{Handler, IntoIntoDart}; + +// Section: boilerplate + +flutter_rust_bridge::frb_generated_boilerplate!( + default_stream_sink_codec = SseCodec, + default_rust_opaque = RustOpaqueMoi, + default_rust_auto_opaque = RustAutoOpaqueMoi, +); +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.13.0"; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 389194750; + +// Section: executor + +flutter_rust_bridge::frb_generated_default_handler!(); + +// Section: wire_funcs + +fn wire__crate__api__connect_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "connect", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_ws_url = ::sse_decode(&mut deserializer); + let api_host_id = ::sse_decode(&mut deserializer); + let api_password = ::sse_decode(&mut deserializer); + let api_totp = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>(crate::api::connect( + api_ws_url, + api_host_id, + api_password, + api_totp, + ))?; + std::result::Result::Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__ctrl_alt_del_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "ctrl_alt_del", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::ctrl_alt_del(api_handle); + })?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__disconnect_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "disconnect", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::disconnect(api_handle); + })?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__host_events_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "host_events", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + let api_sink = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::host_events(api_handle, api_sink); + })?; + std::result::Result::Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__host_gen_id_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "host_gen_id", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>(crate::api::host_gen_id())?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__host_gen_password_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "host_gen_password", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>(crate::api::host_gen_password())?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__host_push_frame_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "host_push_frame", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + let api_data = >::sse_decode(&mut deserializer); + let api_keyframe = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::host_push_frame(api_handle, api_data, api_keyframe); + })?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__host_start_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "host_start", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_ws_url = ::sse_decode(&mut deserializer); + let api_host_id = ::sse_decode(&mut deserializer); + let api_name = ::sse_decode(&mut deserializer); + let api_password = ::sse_decode(&mut deserializer); + let api_width = ::sse_decode(&mut deserializer); + let api_height = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>(crate::api::host_start( + api_ws_url, + api_host_id, + api_name, + api_password, + api_width, + api_height, + ))?; + std::result::Result::Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__host_stop_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "host_stop", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::host_stop(api_handle); + })?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__monitor_switch_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "monitor_switch", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + let api_i = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::monitor_switch(api_handle, api_i); + })?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__send_chat_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "send_chat", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + let api_text = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::send_chat(api_handle, api_text); + })?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__send_key_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "send_key", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + let api_code = ::sse_decode(&mut deserializer); + let api_down = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::send_key(api_handle, api_code, api_down); + })?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__send_mouse_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "send_mouse", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + let api_x = ::sse_decode(&mut deserializer); + let api_y = ::sse_decode(&mut deserializer); + let api_buttons = ::sse_decode(&mut deserializer); + let api_wheel = ::sse_decode(&mut deserializer); + let api_hwheel = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::send_mouse( + api_handle, + api_x, + api_y, + api_buttons, + api_wheel, + api_hwheel, + ); + })?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__session_current_monitor_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "session_current_monitor", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>(crate::api::session_current_monitor(api_handle))?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__session_events_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "session_events", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + let api_sink = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::session_events(api_handle, api_sink); + })?; + std::result::Result::Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__session_monitors_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "session_monitors", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>(crate::api::session_monitors(api_handle))?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__set_clipboard_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "set_clipboard", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + let api_text = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::set_clipboard(api_handle, api_text); + })?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__set_quality_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "set_quality", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + let api_fps = ::sse_decode(&mut deserializer); + let api_bitrate = ::sse_decode(&mut deserializer); + let api_scale = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::set_quality(api_handle, api_fps, api_bitrate, api_scale); + })?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} + +// Section: dart2rust + +impl SseDecode for flutter_rust_bridge::for_generated::anyhow::Error { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return flutter_rust_bridge::for_generated::anyhow::anyhow!("{}", inner); + } +} + +impl SseDecode for StreamSink { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return StreamSink::deserialize(inner); + } +} + +impl SseDecode + for StreamSink +{ + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return StreamSink::deserialize(inner); + } +} + +impl SseDecode for String { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = >::sse_decode(deserializer); + return String::from_utf8(inner).unwrap(); + } +} + +impl SseDecode for bool { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u8().unwrap() != 0 + } +} + +impl SseDecode for f32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_f32::().unwrap() + } +} + +impl SseDecode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_i32::().unwrap() + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = Vec::with_capacity(len_ as usize); + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = Vec::with_capacity(len_ as usize); + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + +impl SseDecode for crate::api::NdEvent { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut tag_ = ::sse_decode(deserializer); + match tag_ { + 0 => { + return crate::api::NdEvent::Connecting; + } + 1 => { + let mut var_w = ::sse_decode(deserializer); + let mut var_h = ::sse_decode(deserializer); + let mut var_e2E = ::sse_decode(deserializer); + let mut var_hostName = ::sse_decode(deserializer); + return crate::api::NdEvent::Connected { + w: var_w, + h: var_h, + e2e: var_e2E, + host_name: var_hostName, + }; + } + 2 => { + let mut var_width = ::sse_decode(deserializer); + let mut var_height = ::sse_decode(deserializer); + let mut var_rgba = >::sse_decode(deserializer); + return crate::api::NdEvent::Frame { + width: var_width, + height: var_height, + rgba: var_rgba, + }; + } + 3 => { + let mut var_reason = ::sse_decode(deserializer); + return crate::api::NdEvent::Ended { reason: var_reason }; + } + 4 => { + let mut var_reason = ::sse_decode(deserializer); + return crate::api::NdEvent::Denied { reason: var_reason }; + } + 5 => { + let mut var_from = ::sse_decode(deserializer); + let mut var_text = ::sse_decode(deserializer); + return crate::api::NdEvent::ChatMessage { + from: var_from, + text: var_text, + }; + } + 6 => { + let mut var_text = ::sse_decode(deserializer); + return crate::api::NdEvent::Clipboard { text: var_text }; + } + _ => { + unimplemented!(""); + } + } + } +} + +impl SseDecode for crate::api::NdHostEvent { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut tag_ = ::sse_decode(deserializer); + match tag_ { + 0 => { + return crate::api::NdHostEvent::Registered; + } + 1 => { + let mut var_e2E = ::sse_decode(deserializer); + return crate::api::NdHostEvent::ViewerConnected { e2e: var_e2E }; + } + 2 => { + return crate::api::NdHostEvent::ViewerLeft; + } + 3 => { + let mut var_x = ::sse_decode(deserializer); + let mut var_y = ::sse_decode(deserializer); + let mut var_buttons = ::sse_decode(deserializer); + let mut var_wheel = ::sse_decode(deserializer); + return crate::api::NdHostEvent::Mouse { + x: var_x, + y: var_y, + buttons: var_buttons, + wheel: var_wheel, + }; + } + 4 => { + let mut var_code = ::sse_decode(deserializer); + let mut var_down = ::sse_decode(deserializer); + return crate::api::NdHostEvent::Key { + code: var_code, + down: var_down, + }; + } + 5 => { + return crate::api::NdHostEvent::KeyframeRequested; + } + 6 => { + let mut var_fps = ::sse_decode(deserializer); + let mut var_bitrate = ::sse_decode(deserializer); + let mut var_scale = ::sse_decode(deserializer); + return crate::api::NdHostEvent::Quality { + fps: var_fps, + bitrate: var_bitrate, + scale: var_scale, + }; + } + 7 => { + return crate::api::NdHostEvent::Reconnecting; + } + 8 => { + let mut var_reason = ::sse_decode(deserializer); + return crate::api::NdHostEvent::Ended { reason: var_reason }; + } + _ => { + unimplemented!(""); + } + } + } +} + +impl SseDecode for crate::api::NdMonitor { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_i = ::sse_decode(deserializer); + let mut var_w = ::sse_decode(deserializer); + let mut var_h = ::sse_decode(deserializer); + return crate::api::NdMonitor { + i: var_i, + w: var_w, + h: var_h, + }; + } +} + +impl SseDecode for u32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u32::().unwrap() + } +} + +impl SseDecode for u64 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u64::().unwrap() + } +} + +impl SseDecode for u8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u8().unwrap() + } +} + +impl SseDecode for () { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {} +} + +fn pde_ffi_dispatcher_primary_impl( + func_id: i32, + port: flutter_rust_bridge::for_generated::MessagePort, + ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len: i32, + data_len: i32, +) { + // Codec=Pde (Serialization + dispatch), see doc to use other codecs + match func_id { + 1 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len), + 4 => wire__crate__api__host_events_impl(port, ptr, rust_vec_len, data_len), + 8 => wire__crate__api__host_start_impl(port, ptr, rust_vec_len, data_len), + 15 => wire__crate__api__session_events_impl(port, ptr, rust_vec_len, data_len), + _ => unreachable!(), + } +} + +fn pde_ffi_dispatcher_sync_impl( + func_id: i32, + ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len: i32, + data_len: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + // Codec=Pde (Serialization + dispatch), see doc to use other codecs + match func_id { + 2 => wire__crate__api__ctrl_alt_del_impl(ptr, rust_vec_len, data_len), + 3 => wire__crate__api__disconnect_impl(ptr, rust_vec_len, data_len), + 5 => wire__crate__api__host_gen_id_impl(ptr, rust_vec_len, data_len), + 6 => wire__crate__api__host_gen_password_impl(ptr, rust_vec_len, data_len), + 7 => wire__crate__api__host_push_frame_impl(ptr, rust_vec_len, data_len), + 9 => wire__crate__api__host_stop_impl(ptr, rust_vec_len, data_len), + 10 => wire__crate__api__monitor_switch_impl(ptr, rust_vec_len, data_len), + 11 => wire__crate__api__send_chat_impl(ptr, rust_vec_len, data_len), + 12 => wire__crate__api__send_key_impl(ptr, rust_vec_len, data_len), + 13 => wire__crate__api__send_mouse_impl(ptr, rust_vec_len, data_len), + 14 => wire__crate__api__session_current_monitor_impl(ptr, rust_vec_len, data_len), + 16 => wire__crate__api__session_monitors_impl(ptr, rust_vec_len, data_len), + 17 => wire__crate__api__set_clipboard_impl(ptr, rust_vec_len, data_len), + 18 => wire__crate__api__set_quality_impl(ptr, rust_vec_len, data_len), + _ => unreachable!(), + } +} + +// Section: rust2dart + +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::NdEvent { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + match self { + crate::api::NdEvent::Connecting => [0.into_dart()].into_dart(), + crate::api::NdEvent::Connected { + w, + h, + e2e, + host_name, + } => [ + 1.into_dart(), + w.into_into_dart().into_dart(), + h.into_into_dart().into_dart(), + e2e.into_into_dart().into_dart(), + host_name.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::NdEvent::Frame { + width, + height, + rgba, + } => [ + 2.into_dart(), + width.into_into_dart().into_dart(), + height.into_into_dart().into_dart(), + rgba.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::NdEvent::Ended { reason } => { + [3.into_dart(), reason.into_into_dart().into_dart()].into_dart() + } + crate::api::NdEvent::Denied { reason } => { + [4.into_dart(), reason.into_into_dart().into_dart()].into_dart() + } + crate::api::NdEvent::ChatMessage { from, text } => [ + 5.into_dart(), + from.into_into_dart().into_dart(), + text.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::NdEvent::Clipboard { text } => { + [6.into_dart(), text.into_into_dart().into_dart()].into_dart() + } + _ => { + unimplemented!(""); + } + } + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::NdEvent {} +impl flutter_rust_bridge::IntoIntoDart for crate::api::NdEvent { + fn into_into_dart(self) -> crate::api::NdEvent { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::NdHostEvent { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + match self { + crate::api::NdHostEvent::Registered => [0.into_dart()].into_dart(), + crate::api::NdHostEvent::ViewerConnected { e2e } => { + [1.into_dart(), e2e.into_into_dart().into_dart()].into_dart() + } + crate::api::NdHostEvent::ViewerLeft => [2.into_dart()].into_dart(), + crate::api::NdHostEvent::Mouse { + x, + y, + buttons, + wheel, + } => [ + 3.into_dart(), + x.into_into_dart().into_dart(), + y.into_into_dart().into_dart(), + buttons.into_into_dart().into_dart(), + wheel.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::NdHostEvent::Key { code, down } => [ + 4.into_dart(), + code.into_into_dart().into_dart(), + down.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::NdHostEvent::KeyframeRequested => [5.into_dart()].into_dart(), + crate::api::NdHostEvent::Quality { + fps, + bitrate, + scale, + } => [ + 6.into_dart(), + fps.into_into_dart().into_dart(), + bitrate.into_into_dart().into_dart(), + scale.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::NdHostEvent::Reconnecting => [7.into_dart()].into_dart(), + crate::api::NdHostEvent::Ended { reason } => { + [8.into_dart(), reason.into_into_dart().into_dart()].into_dart() + } + _ => { + unimplemented!(""); + } + } + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::NdHostEvent {} +impl flutter_rust_bridge::IntoIntoDart for crate::api::NdHostEvent { + fn into_into_dart(self) -> crate::api::NdHostEvent { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::NdMonitor { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.i.into_into_dart().into_dart(), + self.w.into_into_dart().into_dart(), + self.h.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::NdMonitor {} +impl flutter_rust_bridge::IntoIntoDart for crate::api::NdMonitor { + fn into_into_dart(self) -> crate::api::NdMonitor { + self + } +} + +impl SseEncode for flutter_rust_bridge::for_generated::anyhow::Error { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(format!("{:?}", self), serializer); + } +} + +impl SseEncode for StreamSink { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + unimplemented!("") + } +} + +impl SseEncode + for StreamSink +{ + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + unimplemented!("") + } +} + +impl SseEncode for String { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >::sse_encode(self.into_bytes(), serializer); + } +} + +impl SseEncode for bool { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u8(self as _).unwrap(); + } +} + +impl SseEncode for f32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_f32::(self).unwrap(); + } +} + +impl SseEncode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_i32::(self).unwrap(); + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + +impl SseEncode for crate::api::NdEvent { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + match self { + crate::api::NdEvent::Connecting => { + ::sse_encode(0, serializer); + } + crate::api::NdEvent::Connected { + w, + h, + e2e, + host_name, + } => { + ::sse_encode(1, serializer); + ::sse_encode(w, serializer); + ::sse_encode(h, serializer); + ::sse_encode(e2e, serializer); + ::sse_encode(host_name, serializer); + } + crate::api::NdEvent::Frame { + width, + height, + rgba, + } => { + ::sse_encode(2, serializer); + ::sse_encode(width, serializer); + ::sse_encode(height, serializer); + >::sse_encode(rgba, serializer); + } + crate::api::NdEvent::Ended { reason } => { + ::sse_encode(3, serializer); + ::sse_encode(reason, serializer); + } + crate::api::NdEvent::Denied { reason } => { + ::sse_encode(4, serializer); + ::sse_encode(reason, serializer); + } + crate::api::NdEvent::ChatMessage { from, text } => { + ::sse_encode(5, serializer); + ::sse_encode(from, serializer); + ::sse_encode(text, serializer); + } + crate::api::NdEvent::Clipboard { text } => { + ::sse_encode(6, serializer); + ::sse_encode(text, serializer); + } + _ => { + unimplemented!(""); + } + } + } +} + +impl SseEncode for crate::api::NdHostEvent { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + match self { + crate::api::NdHostEvent::Registered => { + ::sse_encode(0, serializer); + } + crate::api::NdHostEvent::ViewerConnected { e2e } => { + ::sse_encode(1, serializer); + ::sse_encode(e2e, serializer); + } + crate::api::NdHostEvent::ViewerLeft => { + ::sse_encode(2, serializer); + } + crate::api::NdHostEvent::Mouse { + x, + y, + buttons, + wheel, + } => { + ::sse_encode(3, serializer); + ::sse_encode(x, serializer); + ::sse_encode(y, serializer); + ::sse_encode(buttons, serializer); + ::sse_encode(wheel, serializer); + } + crate::api::NdHostEvent::Key { code, down } => { + ::sse_encode(4, serializer); + ::sse_encode(code, serializer); + ::sse_encode(down, serializer); + } + crate::api::NdHostEvent::KeyframeRequested => { + ::sse_encode(5, serializer); + } + crate::api::NdHostEvent::Quality { + fps, + bitrate, + scale, + } => { + ::sse_encode(6, serializer); + ::sse_encode(fps, serializer); + ::sse_encode(bitrate, serializer); + ::sse_encode(scale, serializer); + } + crate::api::NdHostEvent::Reconnecting => { + ::sse_encode(7, serializer); + } + crate::api::NdHostEvent::Ended { reason } => { + ::sse_encode(8, serializer); + ::sse_encode(reason, serializer); + } + _ => { + unimplemented!(""); + } + } + } +} + +impl SseEncode for crate::api::NdMonitor { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.i, serializer); + ::sse_encode(self.w, serializer); + ::sse_encode(self.h, serializer); + } +} + +impl SseEncode for u32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u32::(self).unwrap(); + } +} + +impl SseEncode for u64 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u64::(self).unwrap(); + } +} + +impl SseEncode for u8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u8(self).unwrap(); + } +} + +impl SseEncode for () { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {} +} + +#[cfg(not(target_family = "wasm"))] +mod io { + // This file is automatically generated, so please do not edit it. + // @generated by `flutter_rust_bridge`@ 2.13.0. + + // Section: imports + + use super::*; + use flutter_rust_bridge::for_generated::byteorder::{ + NativeEndian, ReadBytesExt, WriteBytesExt, + }; + use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; + use flutter_rust_bridge::{Handler, IntoIntoDart}; + + // Section: boilerplate + + flutter_rust_bridge::frb_generated_boilerplate_io!(); +} +#[cfg(not(target_family = "wasm"))] +pub use io::*; + +/// cbindgen:ignore +#[cfg(target_family = "wasm")] +mod web { + // This file is automatically generated, so please do not edit it. + // @generated by `flutter_rust_bridge`@ 2.13.0. + + // Section: imports + + use super::*; + use flutter_rust_bridge::for_generated::byteorder::{ + NativeEndian, ReadBytesExt, WriteBytesExt, + }; + use flutter_rust_bridge::for_generated::wasm_bindgen; + use flutter_rust_bridge::for_generated::wasm_bindgen::prelude::*; + use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; + use flutter_rust_bridge::{Handler, IntoIntoDart}; + + // Section: boilerplate + + flutter_rust_bridge::frb_generated_boilerplate_web!(); +} +#[cfg(target_family = "wasm")] +pub use web::*; diff --git a/native/frb/src/lib.rs b/native/frb/src/lib.rs new file mode 100644 index 0000000..5b03352 --- /dev/null +++ b/native/frb/src/lib.rs @@ -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; diff --git a/native/host/Cargo.toml b/native/host/Cargo.toml new file mode 100644 index 0000000..d7d9ad7 --- /dev/null +++ b/native/host/Cargo.toml @@ -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" diff --git a/native/host/src/annot.rs b/native/host/src/annot.rs new file mode 100644 index 0000000..17010e7 --- /dev/null +++ b/native/host/src/annot.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>> = OnceLock::new(); +static ORIGIN: OnceLock<(i32, i32)> = OnceLock::new(); // virtual-desktop top-left + +fn strokes() -> &'static Mutex> { + 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, + dirty: Arc, + hwnd: Arc, + thread: Option>, +} + +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, dirty: Arc, hwnd_out: Arc) { + 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); + } +} diff --git a/native/host/src/audio.rs b/native/host/src/audio.rs new file mode 100644 index 0000000..ba097ac --- /dev/null +++ b/native/host/src/audio.rs @@ -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, + active: impl Fn() -> bool, + mut on_packet: impl FnMut(Vec), +) { + // 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, + active: impl Fn() -> bool, + source: Arc, + mut on_packet: impl FnMut(Vec), +) { + 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), +) -> 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 = 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) { + 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, rx: std::sync::mpsc::Receiver>) { + 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>, +) -> 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 = VecDeque::with_capacity(buf_frames as usize * 2); + let mut scratch: Vec = 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, +) { + 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 = 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()); + } +} diff --git a/native/host/src/bin/encbench.rs b/native/host/src/bin/encbench.rs new file mode 100644 index 0000000..506fd37 --- /dev/null +++ b/native/host/src/bin/encbench.rs @@ -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 { + 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 { + 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::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, 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(()) +} diff --git a/native/host/src/bin/engine.rs b/native/host/src/bin/engine.rs new file mode 100644 index 0000000..2585cc7 --- /dev/null +++ b/native/host/src/bin/engine.rs @@ -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(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, + } +} diff --git a/native/host/src/bin/privacy_probe.rs b/native/host/src/bin/privacy_probe.rs new file mode 100644 index 0000000..54b7fe8 --- /dev/null +++ b/native/host/src/bin/privacy_probe.rs @@ -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> { + 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"); + } + } +} diff --git a/native/host/src/bin/probe.rs b/native/host/src/bin/probe.rs new file mode 100644 index 0000000..afe94f7 --- /dev/null +++ b/native/host/src/bin/probe.rs @@ -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> = 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(()) +} diff --git a/native/host/src/capture.rs b/native/host/src/capture.rs new file mode 100644 index 0000000..c26d976 --- /dev/null +++ b/native/host/src/capture.rs @@ -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, +} + +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 { + let mut out = Vec::new(); + unsafe { + let mut device: Option = 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 { + unsafe { + let mut device: Option = None; + let mut context: Option = 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>> { + unsafe { + let mut info = DXGI_OUTDUPL_FRAME_INFO::default(); + let mut resource: Option = 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 = 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> { + 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> { + 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::() 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 { + 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>> { + Ok(Some(gdi_capture(self.left, self.top, self.width, self.height)?)) + } + pub fn grab_seed(&mut self) -> Result> { + 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 { + 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>> { + match self { + Capturer::Dxgi(c) => c.grab(timeout_ms), + Capturer::VirtualAll(c) => c.grab(timeout_ms), + } + } + pub fn grab_seed(&mut self) -> Result> { + 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()); + } +} diff --git a/native/host/src/color.rs b/native/host/src/color.rs new file mode 100644 index 0000000..fff3abb --- /dev/null +++ b/native/host/src/color.rs @@ -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 (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) +} diff --git a/native/host/src/cursor.rs b/native/host/src/cursor.rs new file mode 100644 index 0000000..6259c58 --- /dev/null +++ b/native/host/src/cursor.rs @@ -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::() 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, + 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 { + 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::() 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> { + let screen = GetDC(None); + let mut bi = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() 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> { + 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, 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 = 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"); + } +} diff --git a/native/host/src/encode.rs b/native/host/src/encode.rs new file mode 100644 index 0000000..209f792 --- /dev/null +++ b/native/host/src/encode.rs @@ -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, + 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 { + 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> { + 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 + } +} diff --git a/native/host/src/encode_mf.rs b/native/host/src/encode_mf.rs new file mode 100644 index 0000000..101c9c3 --- /dev/null +++ b/native/host/src/encode_mf.rs @@ -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, + seq_header: Vec, + 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 { + 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 = 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::() { + 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::() { + 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> { + 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> { + 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) -> 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, + ) -> 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 { + 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> { + let sample_slot: Option = 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::() { + 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 {} diff --git a/native/host/src/encoder.rs b/native/host/src/encoder.rs new file mode 100644 index 0000000..15a5c09 --- /dev/null +++ b/native/host/src/encoder.rs @@ -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 { + #[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 { + #[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> { + 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()); + } +} diff --git a/native/host/src/files.rs b/native/host/src/files.rs new file mode 100644 index 0000000..74dab02 --- /dev/null +++ b/native/host/src/files.rs @@ -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 { + 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> { + 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": "", "s": }`, 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, 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 = 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); + } +} diff --git a/native/host/src/input.rs b/native/host/src/input.rs new file mode 100644 index 0000000..a18c16f --- /dev/null +++ b/native/host/src/input.rs @@ -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::() 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::() 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 { + 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::() { + 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>> = 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 = 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'" + ); + } +} diff --git a/native/host/src/keyhook.rs b/native/host/src/keyhook.rs new file mode 100644 index 0000000..48ad999 --- /dev/null +++ b/native/host/src/keyhook.rs @@ -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> = OnceLock::new(); +static RX: OnceLock>> = 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::() 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"); + } +} diff --git a/native/host/src/lib.rs b/native/host/src/lib.rs new file mode 100644 index 0000000..4ec1bf6 --- /dev/null +++ b/native/host/src/lib.rs @@ -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() {} diff --git a/native/host/src/privacy.rs b/native/host/src/privacy.rs new file mode 100644 index 0000000..cc35f9b --- /dev/null +++ b/native/host/src/privacy.rs @@ -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, + thread: Option>, +} + +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) { + 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"); +} diff --git a/native/host/src/qos.rs b/native/host/src/qos.rs new file mode 100644 index 0000000..3c3326e --- /dev/null +++ b/native/host/src/qos.rs @@ -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); + } +} diff --git a/native/host/src/run.rs b/native/host/src/run.rs new file mode 100644 index 0000000..7cefb9d --- /dev/null +++ b/native/host/src/run.rs @@ -0,0 +1,1994 @@ +//! The native Windows host engine: it captures the screen, encodes H.264 with +//! the best available encoder, serves one viewer at a time over the NetDesk +//! protocol (via `netdesk_core::host::run_host`), injects the viewer's mouse and +//! keyboard, and auto-reconnects so the machine stays reachable. This is the +//! Rust replacement for the Python host engine. + +#![cfg(windows)] + +use crate::capture::{Capturer, DxgiCapturer}; +use crate::encoder::VideoEncoder; +use crate::input::Injector; +use anyhow::{anyhow, Result}; +use netdesk_core::crypto; +use netdesk_core::host::{run_host, HostConfig, HostControl, HostEvent, HostFrame, HostPerms}; +use serde_json::json; +use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, AtomicU64, AtomicU8, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tokio::sync::{mpsc, Notify}; + +/// Engine configuration. `password` is turned into PBKDF2 credentials locally; +/// it never leaves the machine. +#[derive(Clone)] +pub struct EngineConfig { + pub ws_url: String, + pub host_id: String, + pub name: String, + pub password: String, + pub monitor: u32, + pub fps: u32, + pub bitrate: u32, + /// Host-enforced per-session permissions (input/clipboard/files/audio). + /// Enforced in the core engine (events never emitted when disabled) and + /// advertised to viewers in `session.accept` `info.perms`. + pub perms: HostPerms, + /// Approve-before-connect: hold a password-authenticated viewer until the + /// person at this PC clicks Accept (20s timeout denies). Headless hosts + /// should leave this off or nobody can answer the prompt. + pub approve: bool, + /// Optional TOTP 2FA secret (base32); empty disables 2FA. + pub totp_secret: String, + /// Encrypted direct-IP access: when set, the engine ALSO listens on this TCP + /// port and serves a viewer that dials it directly (no relay), running the + /// same challenge/auth + E2E. `None` = relay only. + pub direct_port: Option, + /// CIDR allow-list for direct-IP peers (comma/space separated). Empty = + /// allow any peer that can reach the port. + pub allow_ips: String, + /// Max simultaneous DIRECT-IP viewers (1 = the classic single viewer). >1 + /// enables multiple simultaneous viewers: the first is the CONTROLLER (full + /// input/audio/clipboard), the rest are view-only OBSERVERS of the same + /// capture+encode. Relay sessions are unaffected (still one viewer). + pub max_viewers: u32, +} + +/// Live host status for the UI (unified app) — safe to read from any thread. +#[derive(Clone, Default)] +pub struct EngineStatus { + pub registered: bool, + pub viewer_connected: bool, + pub e2e: bool, + pub encoder: String, + pub last_error: Option, +} + +/// Start/stop + status handle for a running engine. Clone it to share between +/// the UI and the engine thread. +#[derive(Clone)] +pub struct EngineHandle { + stop: Arc, + notify: Arc, + pub status: Arc>, +} + +impl Default for EngineHandle { + fn default() -> Self { + Self::new() + } +} + +impl EngineHandle { + pub fn new() -> Self { + Self { + stop: Arc::new(AtomicBool::new(false)), + notify: Arc::new(Notify::new()), + status: Arc::new(Mutex::new(EngineStatus::default())), + } + } + + /// Ask the engine to stop and return promptly. + pub fn stop(&self) { + self.stop.store(true, Ordering::Relaxed); + self.notify.notify_waiters(); + } + + pub fn is_stopping(&self) -> bool { + self.stop.load(Ordering::Relaxed) + } + + pub fn snapshot(&self) -> EngineStatus { + self.status.lock().unwrap().clone() + } +} + +/// Shared control between the async event loop and the capture/encode thread. +/// How to map viewer frame-pixel coordinates to absolute virtual-desktop +/// coordinates for input injection. The viewer sends input in the frame space +/// it sees (session.info `w/h` = the encoded size); the host maps each coord to +/// the captured monitor's pixels (frame -> monitor) and adds that monitor's +/// virtual-desktop origin. Without this, input on a secondary monitor lands on +/// the primary (missing origin) and is wrong under a <1 quality scale. +#[derive(Clone, Copy)] +struct InputMap { + origin_x: i32, + origin_y: i32, + mon_w: u32, + mon_h: u32, + frame_w: u32, + frame_h: u32, +} + +impl InputMap { + /// Map a viewer frame coord to an absolute virtual-desktop pixel. + fn to_virtual(&self, x: i32, y: i32) -> (i32, i32) { + let fw = self.frame_w.max(1) as i64; + let fh = self.frame_h.max(1) as i64; + let vx = self.origin_x as i64 + (x as i64 * self.mon_w as i64) / fw; + let vy = self.origin_y as i64 + (y as i64 * self.mon_h as i64) / fh; + (vx as i32, vy as i32) + } +} + +struct Control { + streaming: AtomicBool, + want_keyframe: AtomicBool, + target_fps: AtomicU32, + /// Quality scale * 1000 (1000 = full resolution). + target_scale: AtomicU32, + /// Target encode bitrate in bps (driven live by the adaptive QoS + the + /// manual quality lever). The encode thread re-inits the encoder when it + /// changes materially. + target_bitrate: AtomicU32, + /// Whether the connected viewer advertised VP9 decode (from `session.begin`). + /// The capture loop only uses VP9 when this is set. + viewer_vp9: AtomicBool, + /// Whether the viewer can decode VP9 profile-1 4:4:4 (crisp text). The + /// capture loop only sends 4:4:4 to such viewers, and only if NETDESK_I444=1. + viewer_i444: AtomicBool, + /// The monitor index the capture loop should be capturing. The viewer moves + /// it via `monitor.switch`; the loop re-opens the capturer when it changes. + target_monitor: AtomicU32, + /// Viewer-requested codec pin: -1 = auto (default), 0 = H.264, 1 = VP9. + /// Overrides auto-selection (used so recording stays on the H.264-only muxer). + pin_codec: AtomicI32, + /// Frame->virtual-desktop input mapping, kept current by the capture loop as + /// the monitor / scale changes; read by the event loop to inject input. + input_map: Mutex, + stop: Arc, + status: Arc>, +} + +type FrameSlot = Arc>>>; +type CtlSlot = Arc>>>; + +/// An in-progress viewer->host upload (written to a `.part`, renamed on +/// completion). +struct UploadState { + file: std::fs::File, + final_path: std::path::PathBuf, + part: std::path::PathBuf, + received: u64, + size: u64, + sid: String, + fid: u32, +} + +/// Run the host engine until `handle.stop()` is called. Captures `monitor`, +/// encodes with the best encoder, and serves viewers with auto-reconnect. +pub async fn run_engine(cfg: EngineConfig, handle: EngineHandle) -> Result<()> { + // Per-monitor-DPI-aware FIRST, before any DXGI/GetSystemMetrics/enumerate, so + // monitor enumeration, capture, the virtual-screen metrics used for input, and + // SendInput ALL agree in physical pixels. Setting it late (in the capture + // thread) made enumerate() return DPI-scaled LOGICAL sizes while capture + + // input ran physical — so input on secondary monitors / scaled displays + // landed on the wrong pixel. Process-wide + idempotent. + crate::set_dpi_aware(); + + // Derive the password verifier locally (never sent). + let (salt_b64, iters, verifier_hex) = crypto::new_host_credentials(&cfg.password); + + // Enumerate all displays so the viewer gets the full monitor list + its + // switch control; also gives us the chosen monitor's dimensions for the + // advertised HostConfig. Falls back to a throwaway single-monitor probe if + // enumeration is unavailable. + let all_monitors = crate::capture::DxgiCapturer::enumerate(); + let mut monitors: Vec<(u32, u32, u32)> = + all_monitors.iter().map(|m| (m.index, m.width, m.height)).collect(); + // Offer an all-in-one "all displays" source (the whole virtual desktop) + // when there is more than one monitor. capture::MON_ALL is advertised as + // monitor -1 and captured via GDI. + if all_monitors.len() > 1 { + let (_l, _t, vw, vh) = crate::capture::virtual_desktop_rect(); + if vw > 0 && vh > 0 { + monitors.push((crate::capture::MON_ALL, vw, vh)); + } + } + let (width, height) = match all_monitors.iter().find(|m| m.index == cfg.monitor) { + Some(m) => (m.width, m.height), + None => { + let cap = DxgiCapturer::new(cfg.monitor) + .map_err(|e| anyhow!("cannot open monitor {}: {e}", cfg.monitor))?; + (cap.width(), cap.height()) + } + }; + log::info!( + "host engine: {} monitor(s); capturing {} ({}x{})", + monitors.len().max(1), + cfg.monitor, + width, + height + ); + log::info!( + "privileges: {}", + if crate::sys::is_elevated() { "ADMINISTRATOR (elevated)" } else { "standard user" } + ); + + let control = Arc::new(Control { + streaming: AtomicBool::new(false), + want_keyframe: AtomicBool::new(false), + target_fps: AtomicU32::new(cfg.fps.clamp(1, 120)), + target_scale: AtomicU32::new(1000), + target_bitrate: AtomicU32::new(cfg.bitrate), + viewer_vp9: AtomicBool::new(false), + viewer_i444: AtomicBool::new(false), + target_monitor: AtomicU32::new(cfg.monitor), + pin_codec: AtomicI32::new(-1), + input_map: Mutex::new(InputMap { + origin_x: 0, + origin_y: 0, + mon_w: width, + mon_h: height, + frame_w: width, + frame_h: height, + }), + stop: handle.stop.clone(), + status: handle.status.clone(), + }); + let frame_slot: FrameSlot = Arc::new(Mutex::new(None)); + let ctl_slot: CtlSlot = Arc::new(Mutex::new(None)); + // Host<->viewer clipboard sync: the last clipboard text we've seen, shared + // between the watcher thread (pushes host copies to the viewer) and the + // viewer-apply handler (so a viewer-pushed clip doesn't echo straight back). + let last_clip = Arc::new(Mutex::new(String::new())); + // Host audio source the viewer selects at runtime (0=off, 1=system, 2=mic). + let audio_source = Arc::new(AtomicU8::new(0)); + + // Capture + encode thread (blocking DXGI + encoder live entirely in-thread + // so none of the !Send COM objects cross a thread boundary). + let cap_control = control.clone(); + let cap_slot = frame_slot.clone(); + let cap_ctl = ctl_slot.clone(); + let cap_cfg = cfg.clone(); + let capture_thread = std::thread::Builder::new() + .name("netdesk-capture".into()) + .spawn(move || capture_loop(cap_cfg, width, height, cap_control, cap_slot, cap_ctl)) + .map_err(|e| anyhow!("spawn capture thread: {e}"))?; + + // Optional system-audio capture (WASAPI loopback -> Opus -> CHAN_AUDIO). + // Off unless NETDESK_AUDIO=1, so a normal session is never affected. Lives + // for the whole engine (like capture_loop); sends via the current ctl_slot + // and only encodes while a viewer is streaming. + // Audio capture thread (on-demand): the viewer picks the source via + // `audio.set` (0=off, 1=system "what you hear", 2=microphone); capture only + // runs while a source is selected AND a viewer streams. Env NETDESK_AUDIO=1 + // starts it on "system" for headless/testing. + #[cfg(windows)] + { + if std::env::var("NETDESK_AUDIO").as_deref() == Ok("1") && cfg.perms.audio { + audio_source.store(1, Ordering::Relaxed); + } + let a_stop = control.stop.clone(); + let a_ctl = ctl_slot.clone(); + let a_ctrl = control.clone(); + let a_src = audio_source.clone(); + let _ = std::thread::Builder::new() + .name("netdesk-audio".into()) + .spawn(move || { + crate::audio::run_audio( + a_stop, + move || a_ctrl.streaming.load(Ordering::Relaxed), + a_src, + move |pkt| { + if let Some(tx) = a_ctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::AudioFrame(pkt)); + } + }, + ); + }); + } + + // Host -> viewer clipboard sync: watch the host OS clipboard and push text + // changes to the connected viewer (origin "host"), so text copied on this PC + // (Ctrl+C / right-click Copy) lands in the viewer's local clipboard, + // RustDesk/TeamViewer-style. Only while a viewer streams; skips clips the + // viewer itself just applied (guarded by last_clip). + { + let w_stop = control.stop.clone(); + let w_ctrl = control.clone(); + let w_ctl = ctl_slot.clone(); + let w_last = last_clip.clone(); + let w_perm = cfg.perms.clipboard; + let _ = std::thread::Builder::new() + .name("netdesk-clip".into()) + .spawn(move || { + if !w_perm { + // Clipboard perm off: never watch or push the host clipboard + // (the viewer->host direction is gated in the core engine). + log::info!("clipboard sync disabled by perms"); + return; + } + let mut cb = match arboard::Clipboard::new() { + Ok(c) => c, + Err(e) => { + log::warn!("clipboard watch unavailable: {e}"); + return; + } + }; + // Last image sent, deduped by a cheap hash of its RGBA bytes so a + // static image isn't re-sent every poll. + let mut last_img_hash: u64 = 0; + while !w_stop.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(400)); + if !w_ctrl.streaming.load(Ordering::Relaxed) { + continue; + } + // Text first (cheap); fall through to image when there's no text. + match cb.get_text() { + Ok(text) if !text.is_empty() => { + let changed = { + let mut last = w_last.lock().unwrap(); + if *last == text { false } else { *last = text.clone(); true } + }; + if changed { + if let Some(tx) = w_ctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Clipboard { text }); + } + } + continue; + } + _ => {} + } + // Image on the clipboard (copied a screenshot / image): push it + // to the viewer as PNG. get_image errs fast when none present. + if let Ok(img) = cb.get_image() { + if img.width > 0 && img.height > 0 { + // Cheap FNV-1a over the RGBA bytes for change detection. + let mut hash: u64 = 0xcbf29ce484222325; + for b in img.bytes.iter() { + hash ^= *b as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + if hash != last_img_hash { + last_img_hash = hash; + if let Some(png) = crate::cursor::encode_png( + &img.bytes, img.width as u32, img.height as u32, + ) { + if let Some(tx) = w_ctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::ClipboardImage { png }); + } + } + } + } + } + } + }); + } + + // Host metrics -> portal: sample CPU/RAM/disk/battery every 5s and push a + // `host.telemetry` control message to the SERVER (Raw is sent verbatim with + // no session gate), so the portal shows live monitoring for this machine. + { + let t_stop = control.stop.clone(); + let t_ctl = ctl_slot.clone(); + let _ = std::thread::Builder::new() + .name("netdesk-telemetry".into()) + .spawn(move || { + let mut tel = crate::telemetry::Telemetry::new(); + let _ = tel.sample(); // prime the CPU baseline + std::thread::sleep(Duration::from_millis(600)); + while !t_stop.load(Ordering::Relaxed) { + let v = tel.sample(); + if let Some(tx) = t_ctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Raw { json: v.to_string() }); + } + std::thread::sleep(Duration::from_secs(5)); + } + }); + } + + // LAN discovery responder: answer UDP broadcast queries so viewers on the + // same network can find this host by id/name (RustDesk-style). Default on; + // NETDESK_LAN=0 disables it. direct_port is 0 for now (direct-IP connect is + // a later item) — the discovered id is what a viewer uses to connect. + if std::env::var("NETDESK_LAN").as_deref() != Ok("0") { + let l_stop = control.stop.clone(); + let l_id = cfg.host_id.clone(); + let l_name = cfg.name.clone(); + let _ = std::thread::Builder::new() + .name("netdesk-lan".into()) + .spawn(move || { + if let Err(e) = netdesk_core::lan::respond_to_queries( + netdesk_core::lan::LAN_PORT, + l_id, + l_name, + 0, + l_stop, + ) { + log::info!("lan discovery responder unavailable: {e}"); + } + }); + } + + // Event loop: inject input, drive streaming/keyframe/quality state. + let (ev_tx, mut ev_rx) = mpsc::unbounded_channel::(); + let ev_control = control.clone(); + let ev_ctl = ctl_slot.clone(); + let ev_last_clip = last_clip.clone(); + let ev_audio_source = audio_source.clone(); + // Voice call: the viewer's microphone arrives on CHAN_VOICE; a dedicated + // thread decodes it and plays it on the host's default render device. The + // thread lives for the whole engine (idle = silence) and ends when its + // sender drops. Windows-only (WASAPI render). + #[cfg(windows)] + let ev_voice_tx: Option>> = { + let (vtx, vrx) = std::sync::mpsc::channel::>(); + let v_stop = control.stop.clone(); + let _ = std::thread::Builder::new() + .name("netdesk-voice".into()) + .spawn(move || crate::audio::run_voice_playback(v_stop, vrx)); + Some(vtx) + }; + #[cfg(not(windows))] + let ev_voice_tx: Option>> = None; + let event_task = tokio::spawn(async move { + let mut injector = Injector::new(); + // sys.* toggle state; restored on session end so we never leave the host + // input-blocked or wallpaper-hidden. + let mut block_on = false; + let mut wp_hidden = false; + let mut wp_orig = String::new(); + // Privacy mode: a capture-excluded black overlay (Some while active). + #[cfg(windows)] + let mut privacy: Option = None; + // Collaborative annotation overlay (Some once the first stroke lands). + #[cfg(windows)] + let mut annot: Option = None; + // Adaptive QoS: the manual quality (fps + bitrate in kbps) is the ceiling; + // net telemetry trims below it under congestion. Default on; NETDESK_QOS=0 + // keeps the manual lever authoritative. The Qos works in kbps; the encoder + // atomic is bps. + let qos_enabled = std::env::var("NETDESK_QOS").as_deref() != Ok("0"); + let mut qos = crate::qos::Qos::new(crate::qos::QosConfig::new( + cfg.fps, + cfg.bitrate / 1000, + )); + // In-progress uploads keyed by fid. + let mut uploads: std::collections::HashMap = + std::collections::HashMap::new(); + // In-progress ranged downloads: fid -> bytes the viewer has acked + // (file.ack). Shared with the per-download streamer threads for + // IDM-style windowed flow control; entries are removed by the streamer + // when its segment finishes. + let dl_acks: Arc>>> = + Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())); + // Open TCP tunnels: tid -> a sender feeding that socket's write half. + // Dropping the sender ends the writer task; the read pump sends bytes + // back as HostControl::TunnelData and a TunnelClosed on EOF. + let mut tunnels: std::collections::HashMap>> = + std::collections::HashMap::new(); + // Open interactive terminals (ConPTY) keyed by tid. + let mut terminals: std::collections::HashMap = + std::collections::HashMap::new(); + let send_raw = |obj: serde_json::Value| { + if let Some(tx) = ev_ctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Raw { json: obj.to_string() }); + } + }; + while let Some(ev) = ev_rx.recv().await { + match ev { + HostEvent::Registered => { + log::info!("registered; reachable"); + let mut s = ev_control.status.lock().unwrap(); + s.registered = true; + s.last_error = None; + } + HostEvent::ViewerCaps { vp9, i444 } => { + log::info!("viewer caps update (vp9={vp9}, i444={i444})"); + ev_control.viewer_vp9.store(vp9, Ordering::Relaxed); + ev_control.viewer_i444.store(i444, Ordering::Relaxed); + } + HostEvent::ViewerConnected { e2e, vp9, i444 } => { + log::info!("viewer connected (e2e={e2e}, vp9={vp9}, i444={i444})"); + ev_control.viewer_vp9.store(vp9, Ordering::Relaxed); + ev_control.viewer_i444.store(i444, Ordering::Relaxed); + ev_control.want_keyframe.store(true, Ordering::Relaxed); + ev_control.streaming.store(true, Ordering::Relaxed); + let mut s = ev_control.status.lock().unwrap(); + s.viewer_connected = true; + s.e2e = e2e; + } + HostEvent::ViewerLeft => { + log::info!("viewer left"); + ev_control.streaming.store(false, Ordering::Relaxed); + injector.release_buttons(); + if block_on { + crate::sys::set_block_input(false); + block_on = false; + } + if wp_hidden { + crate::sys::wallpaper_set(&wp_orig); + wp_hidden = false; + } + #[cfg(windows)] + { + privacy = None; // restore the screen if privacy was on + annot = None; // remove any annotation overlay + } + ev_control.status.lock().unwrap().viewer_connected = false; + } + HostEvent::Mouse { x, y, buttons, wheel } => { + // Map the viewer's frame coords to the captured monitor's + // absolute virtual-desktop pixels (origin + frame->monitor + // scale), so input lands on the right monitor. + let (vx, vy) = { + let m = ev_control.input_map.lock().unwrap(); + m.to_virtual(x, y) + }; + injector.mouse(vx, vy, buttons, wheel); + } + HostEvent::MouseRel { dx, dy, buttons, wheel } => { + // Relative (game / pointer-lock) motion: injected raw (no + // InputMap), so apps reading raw mouse input get the deltas. + injector.mouse_rel(dx, dy, buttons, wheel); + } + HostEvent::Text { text } => injector.text(&text), + HostEvent::Annot { json } => { + #[cfg(windows)] + { + let v: serde_json::Value = + serde_json::from_str(&json).unwrap_or(serde_json::Value::Null); + match v.get("op").and_then(|x| x.as_str()).unwrap_or("") { + "clear" => { + if let Some(a) = &annot { + a.clear(); + } + } + "stroke" => { + let color = v.get("color").and_then(|x| x.as_u64()) + .unwrap_or(0xFF_3333) as u32; + let width = v.get("width").and_then(|x| x.as_i64()) + .unwrap_or(3) as i32; + let pts: Vec<(i32, i32)> = v.get("pts") + .and_then(|x| x.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|p| { + let a = p.as_array()?; + Some(( + a.first()?.as_i64()? as i32, + a.get(1)?.as_i64()? as i32, + )) + }) + .collect() + }) + .unwrap_or_default(); + let mapped: Vec<(i32, i32)> = { + let m = ev_control.input_map.lock().unwrap(); + pts.iter().map(|&(x, y)| m.to_virtual(x, y)).collect() + }; + if !mapped.is_empty() { + if annot.is_none() { + annot = Some(crate::annot::Annotations::start()); + } + if let Some(a) = &annot { + a.add_stroke(crate::annot::Stroke { + color, + width, + points: mapped, + }); + } + } + } + _ => {} + } + } + #[cfg(not(windows))] + { + let _ = &json; + } + } + HostEvent::Key { code, down } => injector.key(&code, down), + HostEvent::Clipboard { text } => { + // Apply the viewer's clipboard to this machine (RustDesk-style + // viewer -> host clipboard sync). + if let Ok(mut cb) = arboard::Clipboard::new() { + let _ = cb.set_text(text.clone()); + } + // Remember it so the watcher doesn't echo this straight back. + *ev_last_clip.lock().unwrap() = text; + } + HostEvent::ClipboardImage { png } => { + // Apply a viewer-pushed image to this machine's clipboard + // (decode PNG -> RGBA, hand arboard an ImageData). + if let Some((rgba, w, h)) = crate::cursor::decode_png(&png) { + if let Ok(mut cb) = arboard::Clipboard::new() { + let _ = cb.set_image(arboard::ImageData { + width: w as usize, + height: h as usize, + bytes: std::borrow::Cow::Owned(rgba), + }); + } + } + } + HostEvent::Sys { json } => { + let v: serde_json::Value = + serde_json::from_str(&json).unwrap_or(serde_json::Value::Null); + let a = v.get("a").and_then(|x| x.as_str()).unwrap_or(""); + let sid = v.get("sid").and_then(|x| x.as_str()).unwrap_or("").to_string(); + match a { + "processes" => send_raw(json!({ + "t":"sys.res","sid":sid,"ok":true,"kind":"processes", + "items": crate::sys::process_list() + })), + "kill" => { + let pid = v.get("pid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let (ok, msg) = crate::sys::process_kill(pid); + send_raw(json!({"t":"sys.res","sid":sid,"ok":ok,"kind":"kill","msg":msg})); + } + "refresh_screen" => { + ev_control.want_keyframe.store(true, Ordering::Relaxed); + send_raw(json!({"t":"sys.res","sid":sid,"ok":true,"kind":"refresh_screen"})); + } + "block_input" => { + block_on = !block_on; + let ok = crate::sys::set_block_input(block_on); + send_raw(json!({ + "t":"sys.res","sid":sid,"ok":ok,"kind":"block_input","on":block_on, + "msg": if block_on {"local input blocked"} else {"local input restored"} + })); + } + "privacy" => { + // Driver-free privacy: a capture-excluded, click-through + // black overlay blanks the local monitor(s) while the + // remote keeps capturing + controlling the real desktop. + #[cfg(windows)] + { + if privacy.is_some() { + privacy = None; // Drop tears down the overlay + } else { + privacy = Some(crate::privacy::Privacy::start()); + } + let on = privacy.is_some(); + send_raw(json!({ + "t":"sys.res","sid":sid,"ok":true,"kind":"privacy","on":on, + "msg": if on {"privacy mode on — local screen blanked"} else {"privacy mode off"} + })); + } + #[cfg(not(windows))] + { + send_raw(json!({ + "t":"sys.res","sid":sid,"ok":false,"kind":"privacy","on":false, + "msg":"privacy unsupported on this platform" + })); + } + } + "wallpaper" => { + if wp_hidden { + crate::sys::wallpaper_set(&wp_orig); + wp_hidden = false; + } else { + wp_orig = crate::sys::wallpaper_get(); + crate::sys::wallpaper_set(""); + wp_hidden = true; + } + send_raw(json!({ + "t":"sys.res","sid":sid,"ok":true,"kind":"wallpaper","on":wp_hidden, + "msg": if wp_hidden {"wallpaper hidden"} else {"wallpaper restored"} + })); + } + "run" => { + // Run CMD / PowerShell on the host. Executes with the + // host's privileges (Administrator/SYSTEM when the host + // is elevated). Off the event loop so a slow command + // never stalls input/video; result streamed back. + let cmd = v.get("cmd").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let elevated = crate::sys::is_elevated(); + let rctl = ev_ctl.clone(); + std::thread::spawn(move || { + let (ok, outp) = crate::sys::run_command(&cmd); + if let Some(tx) = rctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Raw { + json: json!({ + "t":"sys.res","sid":sid,"ok":ok,"kind":"run", + "elevated":elevated,"msg":outp + }) + .to_string(), + }); + } + }); + } + "lock" => { + let ok = crate::sys::lock_workstation(); + send_raw(json!({"t":"sys.res","sid":sid,"ok":ok,"kind":"lock", + "msg": if ok {"workstation locked"} else {"lock failed"}})); + } + "logoff" => { + let ok = crate::sys::logoff(); + send_raw(json!({"t":"sys.res","sid":sid,"ok":ok,"kind":"logoff", + "msg": if ok {"logging off"} else {"logoff failed"}})); + } + "reboot" => { + let ok = crate::sys::reboot(); + send_raw(json!({"t":"sys.res","sid":sid,"ok":ok,"kind":"reboot", + "msg": if ok {"restarting"} else {"restart failed"}})); + } + "shutdown" => { + let ok = crate::sys::shutdown(); + send_raw(json!({"t":"sys.res","sid":sid,"ok":ok,"kind":"shutdown", + "msg": if ok {"shutting down"} else {"shutdown failed"}})); + } + "privinfo" => { + send_raw(json!({"t":"sys.res","sid":sid,"ok":true,"kind":"privinfo", + "elevated": crate::sys::is_elevated()})); + } + "cad" => { + // Ctrl+Alt+Del to the active session (needs an elevated + // host + the SAS policy that --install-admin-task sets). + let ok = crate::sys::send_cad(); + send_raw(json!({"t":"sys.res","sid":sid,"ok":ok,"kind":"cad", + "msg": if ok {"Ctrl+Alt+Del sent"} else {"CAD needs an elevated host + SAS policy"}})); + } + "uac_visible" => { + // Show UAC prompts on the normal desktop so a remote + // viewer can approve them ("enter admin pass"). on=false + // restores the secure desktop. Needs an elevated host. + let on = v.get("on").and_then(|x| x.as_bool()).unwrap_or(true); + let ok = crate::sys::set_uac_visible(on); + send_raw(json!({"t":"sys.res","sid":sid,"ok":ok,"kind":"uac_visible","on":on, + "msg": if ok { + if on {"UAC prompts now appear on the desktop (remotely approvable)"} + else {"UAC restored to the secure desktop"} + } else {"needs an elevated host"}})); + } + "wol" => { + // Wake another PC on THIS host's LAN (the server routed + // the request here because this host shares the target's + // network). Broadcasts the magic packet for {mac}. + let mac = v.get("mac").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let (ok, msg) = match crate::wol::send_wol(&mac) { + Ok(n) => (true, format!("magic packet sent ({n} broadcast(s)) for {mac}")), + Err(e) => (false, format!("wol failed: {e}")), + }; + send_raw(json!({"t":"sys.res","sid":sid,"ok":ok,"kind":"wol","msg":msg})); + } + other => send_raw(json!({ + "t":"sys.res","sid":sid,"ok":false, + "msg": format!("unsupported sys command: {other}") + })), + } + } + HostEvent::File { json } => { + let v: serde_json::Value = + serde_json::from_str(&json).unwrap_or(serde_json::Value::Null); + let t = v.get("t").and_then(|x| x.as_str()).unwrap_or(""); + let sid = v.get("sid").and_then(|x| x.as_str()).unwrap_or("").to_string(); + match t { + "file.roots" => send_raw(json!({ + "t":"file.roots.res","sid":sid,"path":"","items": crate::files::roots() + })), + "file.list" => { + let path = v.get("path").and_then(|x| x.as_str()).unwrap_or(""); + match crate::files::list_dir(path) { + Ok(items) => send_raw(json!({ + "t":"file.list.res","sid":sid,"path":path,"items":items + })), + Err(e) => send_raw(json!({ + "t":"file.err","sid":sid,"msg": format!("list failed: {e}") + })), + } + } + "file.walk" => { + // Recursively enumerate a folder so the viewer can + // download the whole tree (RustDesk-style folder + // transfer). Off the event loop — a deep tree can take + // a moment — so a big walk never stalls input/video. + let path = v.get("path").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let wctl = ev_ctl.clone(); + std::thread::spawn(move || { + let (items, truncated) = crate::files::walk_dir(&path) + .unwrap_or_else(|_| (Vec::new(), false)); + if let Some(tx) = wctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Raw { + json: json!({ + "t":"file.walk.res","sid":sid,"path":path, + "items":items,"truncated":truncated + }) + .to_string(), + }); + } + }); + } + "file.get" => { + let path = v.get("path").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let fid = v.get("fid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + // Optional byte range (IDM-style segmented download): + // offset+len let the viewer fetch N segments of one + // file in parallel and resume missing ranges. A + // ranged request opts in to file.ack flow control; + // a legacy plain get streams exactly as before. + let start = v.get("offset").and_then(|x| x.as_u64()).unwrap_or(0); + let max_len = v.get("len").and_then(|x| x.as_u64()); + let windowed = v.get("offset").is_some() || v.get("len").is_some(); + let acked = Arc::new(AtomicU64::new(0)); + if windowed { + dl_acks.lock().unwrap().insert(fid, acked.clone()); + } + // Stream the file on a worker thread so a big download + // never stalls the event loop. + let dctl = ev_ctl.clone(); + let reg = dl_acks.clone(); + std::thread::spawn(move || { + download_file(dctl, &sid, fid, &path, start, max_len, windowed, acked, reg); + }); + } + "file.ack" => { + // Viewer's cumulative received-bytes for a ranged + // download segment — advances the sender's window. + let fid = v.get("fid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let rcvd = v.get("rcvd").and_then(|x| x.as_u64()).unwrap_or(0); + if let Some(a) = dl_acks.lock().unwrap().get(&fid) { + a.store(rcvd, Ordering::Relaxed); + } + } + "file.put" => { + let path = v.get("path").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let size = v.get("size").and_then(|x| x.as_u64()).unwrap_or(0); + let fid = v.get("fid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let final_path = crate::files::resolve_upload_path(&path); + if let Some(parent) = final_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let part = crate::files::part_path(&final_path.to_string_lossy()); + match std::fs::File::create(&part) { + Ok(file) => { + uploads.insert(fid, UploadState { + file, final_path, part, received: 0, size, sid, fid, + }); + // Zero-byte file completes immediately. + if size == 0 { + if let Some(u) = uploads.remove(&fid) { + send_raw(finish_upload(u)); + } + } + } + Err(e) => send_raw(json!({ + "t":"file.err","sid":sid,"msg": format!("create failed: {e}") + })), + } + } + _ => {} + } + } + HostEvent::FileChunk { fid, offset, data } => { + if let Some(u) = uploads.get_mut(&fid) { + use std::io::{Seek, SeekFrom, Write}; + let _ = u.file.seek(SeekFrom::Start(offset)); + if u.file.write_all(&data).is_ok() { + u.received += data.len() as u64; + if u.received >= u.size { + if let Some(u) = uploads.remove(&fid) { + send_raw(finish_upload(u)); + } + } + } + } + } + HostEvent::Voice { opus } => { + // Viewer microphone (voice call): hand the Opus packet to the + // render thread, which decodes + plays it on this PC. + if let Some(tx) = ev_voice_tx.as_ref() { + let _ = tx.send(opus); + } + } + HostEvent::ApproveRequest { sid, peer, user } => { + // Approve-before-connect: ask the person at this PC. The + // prompt runs on its own thread (MessageBox blocks) and the + // engine's 20s timeout denies if nobody answers. + // NETDESK_APPROVE_TEST=accept|deny answers headlessly (e2e). + log::info!("approve: prompt for viewer {peer} {user} (sid={sid})"); + let rctl = ev_ctl.clone(); + std::thread::spawn(move || { + let accept = match std::env::var("NETDESK_APPROVE_TEST").as_deref() { + Ok("accept") => true, + Ok("deny") => false, + _ => crate::sys::approve_prompt(&peer, &user), + }; + log::info!( + "approve: decision = {}", + if accept { "ACCEPT" } else { "REJECT" } + ); + if let Some(tx) = rctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::ApproveReply { sid, accept }); + } + }); + } + HostEvent::ViewOnly { on } => { + // Host-enforced view-only flipped: input is already dropped in + // the core engine; make sure nothing stays held down. + log::info!("view-only (host-enforced): {}", if on { "ON" } else { "off" }); + if on { + injector.release_buttons(); + } + } + HostEvent::TunnelOpen { tid, thost, port } => { + // Open a local TCP connection for a port-forward and pump it + // both ways over the host ws (via the ctl channel). Runs off + // the event loop so a slow connect never stalls input/video. + log::info!("tunnel {tid}: open -> {thost}:{port}"); + let (wtx, mut wrx) = mpsc::unbounded_channel::>(); + tunnels.insert(tid, wtx); + let tctl = ev_ctl.clone(); + tokio::spawn(async move { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let stream = match tokio::net::TcpStream::connect((thost.as_str(), port)).await { + Ok(s) => s, + Err(e) => { + if let Some(tx) = tctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Raw { + json: json!({"t":"tunnel.err","tid":tid,"msg":e.to_string()}).to_string(), + }); + } + return; + } + }; + let _ = stream.set_nodelay(true); + if let Some(tx) = tctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Raw { + json: json!({"t":"tunnel.ok","tid":tid}).to_string(), + }); + } + let (mut rd, mut wr) = stream.into_split(); + // socket -> server (read pump) + let rctl = tctl.clone(); + let reader = tokio::spawn(async move { + let mut buf = vec![0u8; 32 * 1024]; + loop { + match rd.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + if let Some(tx) = rctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::TunnelData { + tid, + data: buf[..n].to_vec(), + }); + } else { + break; + } + } + } + } + }); + // server -> socket (write pump); ends when the sender drops + // (tunnel.close) or the socket errors. + while let Some(chunk) = wrx.recv().await { + if wr.write_all(&chunk).await.is_err() { + break; + } + } + reader.abort(); + let _ = wr.shutdown().await; + if let Some(tx) = tctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::TunnelClosed { tid }); + } + }); + } + HostEvent::TunnelData { tid, data } => { + if let Some(tx) = tunnels.get(&tid) { + if tx.send(data).is_err() { + tunnels.remove(&tid); + } + } + } + HostEvent::TunnelClose { tid } => { + log::info!("tunnel {tid}: close"); + tunnels.remove(&tid); // drops the write sender -> ends the task + } + HostEvent::TermOpen { tid } => { + log::info!("terminal {tid}: open (ConPTY)"); + let octl = ev_ctl.clone(); + let xctl = ev_ctl.clone(); + match crate::term::Terminal::spawn( + 80, + 25, + move |bytes| { + // Terminal output -> viewer (VT bytes as lossy UTF-8). + if let Some(tx) = octl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::TermData { + tid, + data: String::from_utf8_lossy(&bytes).into_owned(), + }); + } + }, + move || { + if let Some(tx) = xctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::TermExit { tid }); + } + }, + ) { + Ok(term) => { + terminals.insert(tid, term); + } + Err(e) => { + log::warn!("terminal {tid}: spawn failed: {e}"); + send_raw(json!({"t":"term.exit","sid":"","tid":tid})); + } + } + } + HostEvent::TermInput { tid, data } => { + if let Some(t) = terminals.get(&tid) { + t.write_input(data.as_bytes()); + } + } + HostEvent::TermResize { tid, cols, rows } => { + if let Some(t) = terminals.get(&tid) { + t.resize(cols, rows); + } + } + HostEvent::TermClose { tid } => { + log::info!("terminal {tid}: close"); + terminals.remove(&tid); // Drop kills the shell + closes the PTY + } + HostEvent::KeyframeRequested => { + ev_control.want_keyframe.store(true, Ordering::Relaxed) + } + HostEvent::SwitchMonitor { mon } => { + // Viewer picked a different display; the capture loop re-opens + // the capturer on this index and the size change re-inits the + // encoder (which tells the viewer the new resolution). + log::info!("viewer switched to monitor {mon}"); + ev_control.target_monitor.store(mon, Ordering::Relaxed); + ev_control.want_keyframe.store(true, Ordering::Relaxed); + } + HostEvent::Quality { fps, bitrate, scale } => { + // Manual lever: sets the adaptive CEILING and applies now. fps + // + bitrate are the QoS ceiling (reset so a raise takes effect + // immediately); scale stays a direct manual lever. + log::info!("quality (manual ceiling): fps={fps} bitrate={bitrate}kbps scale={scale}"); + let fps = fps.clamp(1, 120); + qos = crate::qos::Qos::new(crate::qos::QosConfig::new(fps, bitrate.max(1))); + ev_control.target_fps.store(fps, Ordering::Relaxed); + ev_control + .target_bitrate + .store(bitrate.saturating_mul(1000).max(100_000), Ordering::Relaxed); + let s = (scale.clamp(0.1, 1.0) * 1000.0) as u32; + ev_control.target_scale.store(s, Ordering::Relaxed); + } + HostEvent::NetStat { rtt_ms, loss } => { + // Feed the adaptive controller; it trims fps/bitrate below the + // manual ceiling under congestion and restores when healthy. + if qos_enabled { + let t = qos.on_sample(rtt_ms, loss); + ev_control.target_fps.store(t.fps.clamp(1, 120), Ordering::Relaxed); + ev_control + .target_bitrate + .store(t.bitrate.saturating_mul(1000).max(100_000), Ordering::Relaxed); + log::debug!( + "qos: rtt={rtt_ms}ms loss={loss:.2} -> fps={} bitrate={}kbps", + t.fps, t.bitrate + ); + } + } + HostEvent::AudioSource { src } => { + // Viewer picked the host audio source (0=off, 1=system, 2=mic). + log::info!("audio source -> {}", match src { 1 => "system", 2 => "microphone", _ => "off" }); + ev_audio_source.store(src, Ordering::Relaxed); + } + HostEvent::PinCodec { codec } => { + // Viewer pinned the codec (e.g. H.264 while recording). The + // capture loop reads this and forces it; a keyframe makes the + // switch take effect promptly. + log::info!("codec pin -> {}", match codec { 0 => "H.264", 1 => "VP9", _ => "auto" }); + ev_control.pin_codec.store(codec, Ordering::Relaxed); + ev_control.want_keyframe.store(true, Ordering::Relaxed); + } + HostEvent::Ended { reason } => { + log::info!("link ended: {reason}"); + ev_control.streaming.store(false, Ordering::Relaxed); + injector.release_buttons(); + if block_on { + crate::sys::set_block_input(false); + block_on = false; + } + if wp_hidden { + crate::sys::wallpaper_set(&wp_orig); + wp_hidden = false; + } + #[cfg(windows)] + { + privacy = None; // restore the screen if privacy was on + annot = None; // remove any annotation overlay + } + let mut s = ev_control.status.lock().unwrap(); + s.viewer_connected = false; + s.registered = false; + } + } + } + }); + + // Reconnect loop: a fresh frame channel each dial; run_host returns on drop. + let cfg_core = HostConfig { + ws_url: cfg.ws_url.clone(), + host_id: cfg.host_id.clone(), + name: cfg.name.clone(), + salt_b64, + iters, + verifier_hex, + width, + height, + // System-audio streaming (WASAPI loopback -> Opus). Off unless opted in + // via NETDESK_AUDIO=1; see audio::run_loopback_opus. + audio: true, // capability advertised; actual capture is on-demand (audio.set) + monitors: monitors.clone(), + monitor: cfg.monitor, + perms: cfg.perms, + approve: cfg.approve, + totp_secret: cfg.totp_secret.clone(), + }; + let mut backoff = 1u64; + while !handle.stop.load(Ordering::Relaxed) { + let (ftx, mut frx) = mpsc::unbounded_channel::(); + let (ctx, crx) = mpsc::unbounded_channel::(); + *frame_slot.lock().unwrap() = Some(ftx); + *ctl_slot.lock().unwrap() = Some(ctx); + if cfg.direct_port.is_none() { + log::info!("connecting to {}", cfg.ws_url); + } + // Race the host link against a stop request so stopping is prompt even + // while idle (dropping the run_host future closes the socket). With + // direct_port set, serve a viewer that dials this host's IP:port over a + // raw TCP socket (encrypted direct-IP) instead of the relay. + tokio::select! { + r = async { + if let Some(port) = cfg.direct_port { + use std::sync::atomic::{AtomicBool as AB, AtomicU32 as AU32}; + use tokio::net::TcpListener; + // Direct-IP uses per-viewer control channels, not the + // per-iteration one created above. + drop(crx); + let listener = TcpListener::bind((std::net::Ipv4Addr::UNSPECIFIED, port)) + .await + .map_err(|e| netdesk_core::NdError::Connect(format!("direct bind :{port}: {e}")))?; + log::info!( + "direct-IP: listening on 0.0.0.0:{port} (max {} viewer(s))", + cfg.max_viewers + ); + // Fan the ONE capture+encode out to every viewer session; each + // seals the same plaintext frame with its own E2E key. + let (btx, _) = tokio::sync::broadcast::channel::(16); + { + let bt = btx.clone(); + tokio::spawn(async move { + while let Some(f) = frx.recv().await { + let _ = bt.send(f); + } + }); + } + let active = Arc::new(AU32::new(0)); + let has_controller = Arc::new(AB::new(false)); + loop { + let (stream, peer) = listener + .accept() + .await + .map_err(|e| netdesk_core::NdError::Connect(format!("direct accept: {e}")))?; + if !netdesk_core::cidr::allowed(&peer.ip(), &cfg.allow_ips) { + log::warn!("direct-IP: {peer} rejected (not in allow-list)"); + continue; + } + if active.load(Ordering::Relaxed) >= cfg.max_viewers { + log::warn!("direct-IP: at max {} viewer(s); rejecting {peer}", cfg.max_viewers); + continue; // stream dropped -> connection closed + } + // First viewer with no controller is the CONTROLLER (full + // perms + the shared audio/clipboard/sys via ctl_slot); the + // rest are view-only OBSERVERS of the same stream. + let controller = !has_controller.swap(true, Ordering::Relaxed); + active.fetch_add(1, Ordering::Relaxed); + log::info!( + "direct-IP: viewer {peer} connected as {} ({} active)", + if controller { "controller" } else { "observer" }, + active.load(Ordering::Relaxed) + ); + // Per-viewer frames: broadcast -> mpsc (run_host_with takes mpsc). + let mut brx = btx.subscribe(); + let (vftx, vfrx) = mpsc::unbounded_channel::(); + tokio::spawn(async move { + loop { + match brx.recv().await { + Ok(f) => { + if vftx.send(f).is_err() { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(_) => break, + } + } + }); + let (vctx, vcrx) = mpsc::unbounded_channel::(); + if controller { + *ctl_slot.lock().unwrap() = Some(vctx); + } + let mut vcfg = cfg_core.clone(); + if !controller { + vcfg.perms = netdesk_core::host::HostPerms { + input: false, + clipboard: false, + files: false, + audio: false, + }; + } + let transport = netdesk_core::transport::DirectTcp::new(stream); + let ev = ev_tx.clone(); + let active2 = active.clone(); + let hc2 = has_controller.clone(); + tokio::spawn(async move { + let _ = + netdesk_core::host::run_host_with(vcfg, vfrx, ev, vcrx, Box::new(transport)) + .await; + active2.fetch_sub(1, Ordering::Relaxed); + if controller { + hc2.store(false, Ordering::Relaxed); + } + log::info!("direct-IP: a viewer left ({} active)", active2.load(Ordering::Relaxed)); + }); + } + } else { + run_host(cfg_core.clone(), frx, ev_tx.clone(), crx).await + } + } => { + match r { + Ok(()) => backoff = 1, + Err(e) => { + log::warn!("host link error: {e}"); + control.status.lock().unwrap().last_error = Some(e.to_string()); + } + } + } + _ = handle.notify.notified() => { + log::info!("stop requested"); + } + } + control.streaming.store(false, Ordering::Relaxed); + { + let mut s = control.status.lock().unwrap(); + s.registered = false; + s.viewer_connected = false; + } + *frame_slot.lock().unwrap() = None; + *ctl_slot.lock().unwrap() = None; + if handle.stop.load(Ordering::Relaxed) { + break; + } + tokio::time::sleep(Duration::from_secs(backoff)).await; + backoff = (backoff * 2).min(5); + } + + handle.stop.store(true, Ordering::Relaxed); + let _ = capture_thread.join(); + event_task.abort(); + log::info!("engine stopped"); + Ok(()) +} + +/// Choose the video codec for the current link, with hysteresis so it does not +/// flap as the adaptive-QoS bitrate drifts. +/// +/// * `forced` pins a codec (env `NETDESK_CODEC`): VP9 still requires viewer +/// support, so a forced VP9 against a non-VP9 viewer falls back to H.264. +/// * Otherwise it is automatic: switch to VP9 below `VP9_BELOW` (a constrained +/// link, where VP9's per-bitrate quality wins) and back to hardware H.264 +/// above `H264_ABOVE` (where H.264 is near-free and lower-latency). Between +/// the two thresholds the current codec is kept (the hysteresis band). +/// +/// Returns a `proto::VideoCodec` id: 0 = H.264, 1 = VP9. +/// Resolve the effective forced codec: a live viewer pin (`pin`: -1 = none, +/// 0 = H.264, 1 = VP9) wins over the env `NETDESK_CODEC` override; otherwise the +/// env override (or None for auto) stands. Used so recording can force H.264. +fn effective_forced(pin: i32, env_forced: Option) -> Option { + match pin { + 0 => Some(0), + 1 => Some(1), + _ => env_forced, + } +} + +fn select_codec(current: u8, bitrate_bps: u32, viewer_vp9: bool, forced: Option) -> u8 { + const VP9_BELOW: u32 = 1_500_000; // < 1.5 Mbps -> VP9 + const H264_ABOVE: u32 = 3_000_000; // > 3 Mbps -> H.264 + // An explicit NETDESK_CODEC is honored as-is: it's the operator asserting + // their viewers' capability. (The signaling server strips custom handshake + // fields, so the viewer_vp9 advertisement isn't reliably delivered yet — + // auto-mode therefore stays on H.264 until that's wired server-side or via + // a post-connect caps message.) + if let Some(f) = forced { + return f; + } + if !viewer_vp9 { + return 0; // can't confirm the viewer decodes VP9 -> safe H.264 + } + if bitrate_bps < VP9_BELOW { + 1 + } else if bitrate_bps > H264_ABOVE { + 0 + } else { + current + } +} + +/// The capture/encode hot loop. Opens its own capturer + encoder, waits while no +/// viewer is connected, and otherwise grabs -> encodes -> sends at the target +/// fps. Recreates the capturer on desktop switches (ACCESS_LOST). +fn capture_loop( + cfg: EngineConfig, + width: u32, + height: u32, + control: Arc, + slot: FrameSlot, + ctl: CtlSlot, +) { + crate::set_dpi_aware(); + // Unattended / lock-screen mode (set by the SYSTEM-service session agent): + // follow the active INPUT desktop each frame and capture via GDI, so the + // lock screen, Windows sign-in screen and UAC are captured + controllable + // (DXGI can't duplicate the secure desktop). Off for the normal host. + let follow = std::env::var("NETDESK_FOLLOW_DESKTOP").as_deref() == Ok("1"); + // Base capture dimensions + the currently-open monitor. All three change + // when the viewer switches monitors (control.target_monitor). + let mut width = width; + let mut height = height; + let mut cur_monitor = cfg.monitor; + let mut cap = match Capturer::open(cur_monitor) { + Ok(c) => c, + Err(e) => { + log::error!("capture: cannot open monitor: {e}"); + return; + } + }; + // Codec policy. By default the host picks the codec automatically per link + // quality (see `select_codec`): hardware H.264 on fast links (near-free, + // low-latency), VP9 on constrained links (far better per-bitrate). VP9 is + // only ever used once a VP9-capable viewer has negotiated it (session.begin + // -> control.viewer_vp9). NETDESK_CODEC=vp9|h264 pins one codec (still + // subject to viewer capability for VP9). + let forced: Option = match std::env::var("NETDESK_CODEC").as_deref() { + Ok("vp9") => Some(1), + Ok("h264") => Some(0), + _ => None, + }; + // Opt-in 4:4:4 chroma (crisp text). Default off: it ~doubles chroma data, + // so it is used only for text-heavy workloads AND only to i444-capable viewers. + let i444_enabled = std::env::var("NETDESK_I444").as_deref() == Ok("1"); + // Start on H.264 (no viewer has negotiated yet); the loop re-inits to VP9 + // on the first iteration after a VP9 viewer connects. + let mut enc = match VideoEncoder::best(width as usize, height as usize, cfg.fps, cfg.bitrate) { + Ok(e) => { + log::info!("capture: {}", e.label()); + control.status.lock().unwrap().encoder = e.label(); + e + } + Err(e) => { + log::error!("capture: no encoder: {e}"); + control.status.lock().unwrap().last_error = Some(format!("no encoder: {e}")); + return; + } + }; + // Codec the current encoder was built for (0 = H.264, 1 = VP9). Tracked + // separately from enc.codec_id() so a VP9 init that fell back to H.264 does + // not trigger a re-init every frame. + let mut enc_codec: u8 = enc.codec_id(); + let mut enc_i444: bool = false; // H.264 start is never 4:4:4 + + let mut last_frame: Option> = None; + // Current encode dimensions (change when the viewer moves the scale lever). + let mut enc_w = width; + let mut enc_h = height; + // Current encode bitrate (bps); the adaptive QoS moves target_bitrate and we + // re-init the encoder when it changes materially. + let mut enc_bitrate = cfg.bitrate; + let mut scaled: Vec = Vec::new(); + // After any encoder re-init (scale change / forced keyframe), encode a few + // frames even on a static screen so the fresh IDR actually flushes out (the + // hardware MFT emits nothing on its first frame while it renegotiates). + let mut kf_flush: u32 = 0; + // Remote cursor overlay state. Origin moves when the monitor switches. + let (mut mon_left, mut mon_top) = cap.origin(); + // Seed the input mapping for the initial monitor (frame == full at startup). + *control.input_map.lock().unwrap() = InputMap { + origin_x: mon_left, + origin_y: mon_top, + mon_w: width, + mon_h: height, + frame_w: enc_w, + frame_h: enc_h, + }; + let mut last_cur_h: u64 = 0; + let mut last_cur_pos: (i32, i32) = (i32::MIN, i32::MIN); + // Handles we've already extracted + sent a custom-cursor bitmap for, so each + // app cursor is captured once (the viewer caches shapes by id). + let mut cursor_bitmaps_sent: std::collections::HashSet = std::collections::HashSet::new(); + // Startup FPS ramp: the clock starts when streaming begins and resets when a + // viewer leaves, so each fresh session slow-starts from qos::INIT_FPS and + // climbs to the target over qos::RAMP_MS (applied to the pacing fps below). + let mut stream_since: Option = None; + let mut last_ramp_fps: u32 = 0; // for a sparse debug trace of the ramp + while !control.stop.load(Ordering::Relaxed) { + if !control.streaming.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(40)); + last_frame = None; + stream_since = None; + continue; + } + + // Monitor switch (viewer sent monitor.switch): re-open the capturer on + // the chosen output. The new base size re-inits the encoder below and + // that path sends session.info with the new resolution; want_keyframe + // (set by the event) flushes a fresh IDR of the new display. + let target_mon = control.target_monitor.load(Ordering::Relaxed); + if target_mon != cur_monitor { + match Capturer::open(target_mon) { + Ok(c) => { + cap = c; + cur_monitor = target_mon; + width = cap.width(); + height = cap.height(); + let (l, t) = cap.origin(); + mon_left = l; + mon_top = t; + { + let mut m = control.input_map.lock().unwrap(); + m.origin_x = mon_left; + m.origin_y = mon_top; + m.mon_w = width; + m.mon_h = height; + } + last_frame = None; + kf_flush = 3; + log::info!("capture: now on monitor {cur_monitor} ({width}x{height})"); + } + Err(e) => { + log::warn!("capture: monitor {target_mon} open failed: {e}; staying on {cur_monitor}"); + control.target_monitor.store(cur_monitor, Ordering::Relaxed); + } + } + } + + // Startup slow-start: cap the pacing fps on a ramp from qos::INIT_FPS up + // to the (qos-adjusted) target over the first qos::RAMP_MS of the session. + let target_fps = control.target_fps.load(Ordering::Relaxed).max(1); + let ramp_ms = stream_since.get_or_insert_with(Instant::now).elapsed().as_millis() as u64; + let fps = crate::qos::startup_fps(ramp_ms, target_fps); + if ramp_ms < crate::qos::RAMP_MS && fps.abs_diff(last_ramp_fps) >= 3 { + log::debug!("startup ramp: {fps}/{target_fps} fps @ {ramp_ms}ms"); + last_ramp_fps = fps; + } + let frame_budget = Duration::from_micros(1_000_000 / fps as u64); + let t0 = Instant::now(); + + // Honor the quality scale lever + the adaptive QoS bitrate: re-init the + // encoder on a size change OR a material bitrate change (>10%). The first + // frame after re-init is a keyframe, so the viewer's decoder re-negotiates + // cleanly from the new SPS. QoS bitrate steps are confirmed + coarse, so + // re-inits stay infrequent (a keyframe per step, not per frame). + let scale = control.target_scale.load(Ordering::Relaxed) as f32 / 1000.0; + let (tw, th) = crate::color::scaled_even(width, height, scale); + let target_br = control.target_bitrate.load(Ordering::Relaxed).max(100_000); + let br_changed = target_br.abs_diff(enc_bitrate) > enc_bitrate / 10; + // Effective codec: auto per link quality (or the env override), gated by + // the viewer's advertised VP9 capability. Hysteresis in select_codec + // keeps it from flapping as the QoS bitrate drifts. + // A live viewer pin (e.g. H.264 while recording) overrides the env + // override and auto-selection; -1 means no pin. + let eff_forced = effective_forced(control.pin_codec.load(Ordering::Relaxed), forced); + let eff_codec = select_codec( + enc_codec, + target_br, + control.viewer_vp9.load(Ordering::Relaxed), + eff_forced, + ); + // 4:4:4 only when the effective codec is VP9, the viewer advertised + // i444 decode, and the operator opted in (NETDESK_I444=1). + let use_i444 = eff_codec == 1 + && i444_enabled + && control.viewer_i444.load(Ordering::Relaxed); + if (tw, th) != (enc_w, enc_h) || br_changed || eff_codec != enc_codec || use_i444 != enc_i444 { + match VideoEncoder::with_codec(eff_codec, use_i444, tw as usize, th as usize, fps, target_br) { + Ok(e) => { + log::info!( + "capture: re-init {}x{} @ {} kbps (scale {:.2}, codec {})", + tw, th, target_br / 1000, scale, e.label() + ); + enc = e; + enc_codec = eff_codec; + enc_i444 = use_i444; + enc_w = tw; + enc_h = th; + enc_bitrate = target_br; + kf_flush = 3; + // The viewer now sends input in this new frame size; keep the + // input map's frame (+ monitor, in case of a switch) in sync. + { + let mut m = control.input_map.lock().unwrap(); + m.frame_w = tw; + m.frame_h = th; + m.mon_w = width; + m.mon_h = height; + m.origin_x = mon_left; + m.origin_y = mon_top; + } + // Tell the viewer the authoritative new frame size (it trusts + // session.info, since the decoder does not re-report dims). + if let Some(tx) = ctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Info { w: tw, h: th }); + } + } + Err(e) => log::warn!("capture: re-init at {tw}x{th} failed: {e}"), + } + } + + // Grab; on no-change reuse the last frame so motion after a pause still + // flushes, but skip sending if we have nothing yet. + let grabbed = if follow { + // Unattended mode: attach to the active input desktop (Default, or + // Winlogon when locked / at sign-in / during UAC) and GDI-capture it + // — the one path that works on the secure desktop. Always yields the + // current pixels, so a remote viewer can see and unlock the machine. + crate::capture::attach_input_desktop(); + match crate::capture::gdi_capture(mon_left, mon_top, width, height) { + Ok(buf) => { + last_frame = Some(buf); + true + } + Err(_) => false, + } + } else { + match cap.grab(frame_budget.as_millis().min(20) as u32) { + Ok(Some(buf)) => { + last_frame = Some(buf); + true + } + Ok(None) => false, + Err(e) => { + log::warn!("capture: {e}; reopening"); + match Capturer::open(cur_monitor) { + Ok(c) => cap = c, + Err(e2) => { + log::error!("capture: reopen failed: {e2}"); + std::thread::sleep(Duration::from_millis(200)); + } + } + continue; + } + } + }; + + let want_kf = control.want_keyframe.swap(false, Ordering::Relaxed); + if want_kf { + // Force an IDR in place on every encoder, HW included (ICodecAPI + // CODECAPI_AVEncVideoForceKeyFrame) — no more whole-encoder rebuild, + // so a viewer join / refresh / QoS step no longer stalls the stream. + enc.request_keyframe(); + kf_flush = 3; + } + + // A long-running DXGI duplication returns WAIT_TIMEOUT on a static + // screen, so `last_frame` is None at viewer-connect and a keyframe flush + // would have nothing to encode -> the viewer sits on a black "waiting + // for first frame" forever (seen on idle hosts whose captured monitor + // has no animation, e.g. the clock/taskbar living on the other display). + // Seed the CURRENT desktop (a brief DXGI retry, then a GDI BitBlt + // fallback that always returns the live pixels) so the IDR has real + // content to flush. Retried next tick if it fails. + if kf_flush > 0 && last_frame.is_none() { + match cap.grab_seed() { + Ok(buf) => last_frame = Some(buf), + Err(e) => log::warn!("capture: keyframe seed failed: {e}"), + } + } + + // Encode on new content, or while flushing a fresh IDR after a re-init. + if grabbed || kf_flush > 0 { + if let Some(frame) = last_frame.as_deref() { + // Downscale into the scratch buffer when the scale lever is < 1. + let to_encode: &[u8] = if (enc_w, enc_h) == (width, height) { + frame + } else { + scaled.resize((enc_w * enc_h * 4) as usize, 0); + crate::color::resize_bgra( + frame, + width as usize, + height as usize, + &mut scaled, + enc_w as usize, + enc_h as usize, + ); + &scaled + }; + match enc.encode(to_encode) { + Ok(aus) => { + let codec = enc.codec_id(); + let guard = slot.lock().unwrap(); + if let Some(tx) = guard.as_ref() { + for au in aus { + let _ = tx.send(HostFrame { + data: au.data, + keyframe: au.keyframe, + codec, + }); + } + } + } + Err(e) => log::warn!("encode: {e}"), + } + // Spend the flush budget ONLY when a frame was actually encoded. + // Decrementing outside this guard (as before) burned all 3 on a + // static screen with no frame in hand, so the fresh encoder was + // never fed and no IDR was ever emitted -> permanent black. + kf_flush = kf_flush.saturating_sub(1); + } + } + + // Remote cursor overlay: report position (frame-pixel coords) and shape + // changes on the control channel so idle screens still track the cursor. + if let Some((h, gx, gy)) = crate::cursor::cursor_state() { + let sx = enc_w as f32 / width as f32; + let sy = enc_h as f32 / height as f32; + let cx = ((gx - mon_left) as f32 * sx) as i32; + let cy = ((gy - mon_top) as f32 * sy) as i32; + if h != last_cur_h { + last_cur_h = h; + let guard = ctl.lock().unwrap(); + if let Some(tx) = guard.as_ref() { + if h == 0 { + let _ = tx.send(HostControl::Cursor { id: 0, x: cx, y: cy, hidden: true }); + } else if let Some(css) = crate::cursor::cursor_css(h) { + let _ = tx.send(HostControl::CursorShape { + id: h, css: Some(css.to_string()), + png: Vec::new(), w: 0, h: 0, hx: 0, hy: 0, + }); + } else if cursor_bitmaps_sent.insert(h) { + // App-custom cursor: extract its bitmap once + send it so + // the viewer shows the real shape instead of a fallback. + if let Some(b) = crate::cursor::cursor_bitmap(h) { + let _ = tx.send(HostControl::CursorShape { + id: h, css: None, + png: b.png, w: b.w, h: b.h, hx: b.hx, hy: b.hy, + }); + } + } + } + } + if h != 0 && (cx, cy) != last_cur_pos { + last_cur_pos = (cx, cy); + if let Some(tx) = ctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Cursor { id: h, x: cx, y: cy, hidden: false }); + } + } + } + + // Pace to the target fps. + let spent = t0.elapsed(); + if spent < frame_budget { + std::thread::sleep(frame_budget - spent); + } + } + log::info!("capture loop stopped"); +} + +/// Finalize an upload: flush + close the `.part`, rename it into place, and +/// return the viewer reply (`file.ok` or `file.err`). +fn finish_upload(u: UploadState) -> serde_json::Value { + use std::io::Write; + let UploadState { + mut file, + final_path, + part, + sid, + .. + } = u; + let _ = file.flush(); + drop(file); // close before rename (Windows refuses a rename of an open file) + match std::fs::rename(&part, &final_path) { + Ok(()) => json!({"t":"file.ok","sid":sid,"refresh":true,"msg":"uploaded"}), + Err(e) => { + let _ = std::fs::remove_file(&part); + json!({"t":"file.err","sid":sid,"msg": format!("finalize failed: {e}")}) + } + } +} + +/// How many sent-but-unacked bytes a ranged download may have in flight before +/// the streamer pauses (per segment). Keeps a huge file from ballooning the +/// send queue and adapts the pace to the link, like a TCP window. +const DL_WINDOW: u64 = 8 * 1024 * 1024; + +/// Stream a file — or a byte RANGE of it — to the viewer as 0xf1 download +/// chunks, then `file.done{fid,offset,len,size}`. Runs on its own thread; sends +/// through the current control sender. A ranged request (`windowed`) is +/// flow-controlled by the viewer's `file.ack` (IDM-style parallel segments + +/// resume); a legacy plain get streams unwindowed exactly as before. +#[allow(clippy::too_many_arguments)] +fn download_file( + ctl: CtlSlot, + sid: &str, + fid: u32, + path: &str, + start: u64, + max_len: Option, + windowed: bool, + acked: Arc, + registry: Arc>>>, +) { + use std::io::{Read, Seek, SeekFrom}; + const CHUNK: usize = 256 * 1024; + let cleanup = |r: &Arc>>>| { + r.lock().unwrap().remove(&fid); + }; + let mut file = match std::fs::File::open(path) { + Ok(f) => f, + Err(e) => { + if let Some(tx) = ctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Raw { + json: json!({"t":"file.err","sid":sid,"fid":fid,"msg":format!("open failed: {e}")}) + .to_string(), + }); + } + cleanup(®istry); + return; + } + }; + let size = file.metadata().map(|m| m.len()).unwrap_or(0); + if start > 0 && file.seek(SeekFrom::Start(start)).is_err() { + if let Some(tx) = ctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Raw { + json: json!({"t":"file.err","sid":sid,"fid":fid,"msg":"seek failed"}).to_string(), + }); + } + cleanup(®istry); + return; + } + let mut remaining = max_len.unwrap_or(u64::MAX); + let mut offset = start; + let mut sent: u64 = 0; + let mut buf = vec![0u8; CHUNK]; + loop { + if remaining == 0 { + break; + } + let want = (CHUNK as u64).min(remaining) as usize; + let n = match file.read(&mut buf[..want]) { + Ok(0) => break, + Ok(n) => n, + Err(_) => break, + }; + match ctl.lock().unwrap().as_ref() { + Some(tx) => { + let _ = tx.send(HostControl::FileData { + fid, + offset, + data: buf[..n].to_vec(), + }); + } + None => { + cleanup(®istry); + return; // session ended; stop streaming + } + } + offset += n as u64; + sent += n as u64; + remaining = remaining.saturating_sub(n as u64); + // Window: wait for the viewer's acks before queuing more. + while windowed && sent.saturating_sub(acked.load(Ordering::Relaxed)) > DL_WINDOW { + std::thread::sleep(std::time::Duration::from_millis(15)); + if ctl.lock().unwrap().is_none() { + cleanup(®istry); + return; + } + } + } + if let Some(tx) = ctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Raw { + json: json!({"t":"file.done","sid":sid,"fid":fid,"path":path,"size":size, + "offset":start,"len":sent}) + .to_string(), + }); + } + cleanup(®istry); +} + +#[cfg(test)] +mod codec_select_tests { + use super::{effective_forced, select_codec}; + + #[test] + fn pin_overrides_env_and_auto() { + // No pin (-1): the env override (or None) stands. + assert_eq!(effective_forced(-1, None), None); + assert_eq!(effective_forced(-1, Some(1)), Some(1)); + // A pin wins over the env override and over auto-selection. + assert_eq!(effective_forced(0, Some(1)), Some(0)); // record pins H.264 over env VP9 + assert_eq!(effective_forced(1, None), Some(1)); + // And a pinned H.264 then forces H.264 even on a VP9-favorable link. + assert_eq!(select_codec(1, 500_000, true, effective_forced(0, None)), 0); + } + + #[test] + fn forced_codec_is_honored() { + // An explicit NETDESK_CODEC pin is the operator's choice and wins + // regardless of the (server-stripped, unreliable) viewer advertisement. + assert_eq!(select_codec(0, 8_000_000, true, Some(1)), 1); + assert_eq!(select_codec(0, 8_000_000, false, Some(1)), 1); // forced VP9 honored + assert_eq!(select_codec(1, 500_000, true, Some(0)), 0); // pinned H.264 + } + + #[test] + fn no_vp9_when_viewer_cannot_decode() { + assert_eq!(select_codec(1, 300_000, false, None), 0); + } + + #[test] + fn auto_picks_vp9_on_constrained_links() { + // Well below 1.5 Mbps -> VP9. + assert_eq!(select_codec(0, 800_000, true, None), 1); + // Well above 3 Mbps -> H.264. + assert_eq!(select_codec(1, 6_000_000, true, None), 0); + } + + #[test] + fn hysteresis_band_keeps_current() { + // Between the thresholds, keep whatever is running (no flap). + assert_eq!(select_codec(0, 2_000_000, true, None), 0); + assert_eq!(select_codec(1, 2_000_000, true, None), 1); + } +} + +#[cfg(test)] +mod input_map_tests { + use super::InputMap; + + #[test] + fn primary_monitor_identity_at_scale_1() { + let m = InputMap { origin_x: 0, origin_y: 0, mon_w: 1920, mon_h: 1080, frame_w: 1920, frame_h: 1080 }; + assert_eq!(m.to_virtual(0, 0), (0, 0)); + assert_eq!(m.to_virtual(960, 540), (960, 540)); + } + + #[test] + fn secondary_monitor_adds_origin() { + // Monitor 2 is to the right of monitor 1 (virtual origin 1920,0). + let m = InputMap { origin_x: 1920, origin_y: 0, mon_w: 1920, mon_h: 1080, frame_w: 1920, frame_h: 1080 }; + assert_eq!(m.to_virtual(0, 0), (1920, 0)); // top-left of monitor 2 + assert_eq!(m.to_virtual(960, 540), (2880, 540)); // center of monitor 2 + } + + #[test] + fn unscales_frame_to_monitor_pixels() { + // Frame is half the monitor size (quality scale 0.5). + let m = InputMap { origin_x: 0, origin_y: 0, mon_w: 1920, mon_h: 1080, frame_w: 960, frame_h: 540 }; + assert_eq!(m.to_virtual(480, 270), (960, 540)); // frame center -> monitor center + } + + #[test] + fn secondary_monitor_scaled() { + let m = InputMap { origin_x: 1920, origin_y: 0, mon_w: 1920, mon_h: 1080, frame_w: 960, frame_h: 540 }; + assert_eq!(m.to_virtual(480, 270), (2880, 540)); + } + + #[test] + fn tall_secondary_monitor_above() { + // Monitor above the primary (negative origin), different resolution. + let m = InputMap { origin_x: 0, origin_y: -1200, mon_w: 1600, mon_h: 1200, frame_w: 1600, frame_h: 1200 }; + assert_eq!(m.to_virtual(800, 600), (800, -600)); + } +} + +#[cfg(all(test, windows))] +mod dpi_diag { + use crate::capture::DxgiCapturer; + use windows::Win32::UI::WindowsAndMessaging::{ + GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, + SM_YVIRTUALSCREEN, + }; + + fn vscreen() -> (i32, i32, i32, i32) { + unsafe { + ( + GetSystemMetrics(SM_XVIRTUALSCREEN), + GetSystemMetrics(SM_YVIRTUALSCREEN), + GetSystemMetrics(SM_CXVIRTUALSCREEN), + GetSystemMetrics(SM_CYVIRTUALSCREEN), + ) + } + } + + #[test] + fn print_coordinate_spaces() { + println!("=== BEFORE set_dpi_aware ==="); + for m in DxgiCapturer::enumerate() { + println!(" mon #{} {}x{} @({},{})", m.index, m.width, m.height, m.left, m.top); + } + let (l, t, w, h) = vscreen(); + println!(" vscreen left={l} top={t} w={w} h={h}"); + + crate::set_dpi_aware(); + + println!("=== AFTER set_dpi_aware (per-monitor-v2) ==="); + for m in DxgiCapturer::enumerate() { + println!(" mon #{} {}x{} @({},{})", m.index, m.width, m.height, m.left, m.top); + } + let (l, t, w, h) = vscreen(); + println!(" vscreen left={l} top={t} w={w} h={h}"); + println!("DPI_DIAG_DONE"); + } +} + +#[cfg(all(test, windows))] +mod injection_e2e { + use super::InputMap; + use crate::capture::DxgiCapturer; + use crate::input::Injector; + use windows::Win32::Foundation::POINT; + use windows::Win32::UI::WindowsAndMessaging::{GetCursorPos, SetCursorPos}; + + // Real OS-effect test: for EVERY monitor, map a frame coord through InputMap + // and inject a move, then confirm with GetCursorPos that the cursor landed on + // that monitor's physical pixel. Proves the DPI fix + InputMap + SendInput + // path end to end. Moves the real cursor briefly, then restores it. + #[test] + fn injected_moves_land_on_the_right_monitor_pixel() { + crate::set_dpi_aware(); // physical coords, as run_engine now does first + let mons = DxgiCapturer::enumerate(); + assert!(!mons.is_empty(), "no monitors enumerated"); + + let mut orig = POINT::default(); + unsafe { + let _ = GetCursorPos(&mut orig); + } + let mut injector = Injector::new(); + let mut worst = 0i32; + for m in &mons { + // Test at full size (frame == monitor) and at a 0.5 quality scale. + for (fw, fh) in [(m.width, m.height), (m.width / 2, m.height / 2)] { + let im = InputMap { + origin_x: m.left, + origin_y: m.top, + mon_w: m.width, + mon_h: m.height, + frame_w: fw, + frame_h: fh, + }; + let (fx, fy) = ((fw / 2) as i32, (fh / 2) as i32); // frame center + let (vx, vy) = im.to_virtual(fx, fy); + injector.mouse(vx, vy, 0, 0.0); + std::thread::sleep(std::time::Duration::from_millis(25)); + let mut p = POINT::default(); + unsafe { + let _ = GetCursorPos(&mut p); + } + let want = (m.left + (m.width / 2) as i32, m.top + (m.height / 2) as i32); + let (dx, dy) = ((p.x - want.0).abs(), (p.y - want.1).abs()); + worst = worst.max(dx).max(dy); + println!( + "mon #{} frame {}x{}: ({fx},{fy})->virt({vx},{vy})->cursor({},{}) want({},{}) d=({dx},{dy})", + m.index, fw, fh, p.x, p.y, want.0, want.1 + ); + assert!(dx <= 3 && dy <= 3, "mon #{} off by ({dx},{dy})", m.index); + } + } + unsafe { + let _ = SetCursorPos(orig.x, orig.y); + } // restore the user's cursor + println!("INJECTION_E2E_OK monitors={} worst_err_px={worst}", mons.len()); + } +} diff --git a/native/host/src/service.rs b/native/host/src/service.rs new file mode 100644 index 0000000..1297754 --- /dev/null +++ b/native/host/src/service.rs @@ -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 { + 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 { + 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::() 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::() 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); + } +} diff --git a/native/host/src/sys.rs b/native/host/src/sys.rs new file mode 100644 index 0000000..58bdb8c --- /dev/null +++ b/native/host/src/sys.rs @@ -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 { + 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::() 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::() 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 = 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::() 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 (" () 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::>(); + 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 +} diff --git a/native/host/src/telemetry.rs b/native/host/src/telemetry.rs new file mode 100644 index 0000000..cf2eacd --- /dev/null +++ b/native/host/src/telemetry.rs @@ -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::() 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 = "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 } +} diff --git a/native/host/src/term.rs b/native/host/src/term.rs new file mode 100644 index 0000000..3c859b3 --- /dev/null +++ b/native/host/src/term.rs @@ -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, + attr_list: Vec, // 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) + Send + 'static, + on_exit: impl FnOnce() + Send + 'static, + ) -> std::io::Result { + 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::(), + None, + None, + ) + .map_err(|e| std::io::Error::other(format!("UpdateAttr: {e}")))?; + + let mut si = STARTUPINFOEXW { + StartupInfo: STARTUPINFOW { + cb: std::mem::size_of::() as u32, + ..Default::default() + }, + lpAttributeList: attr, + }; + let mut pi = PROCESS_INFORMATION::default(); + // Mutable, NUL-terminated command line for CreateProcessW. + let mut cmd: Vec = "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 + } +} diff --git a/native/host/src/wol.rs b/native/host/src/wol.rs new file mode 100644 index 0000000..63af1b3 --- /dev/null +++ b/native/host/src/wol.rs @@ -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 { + 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"); + } +} diff --git a/native/opus/Cargo.toml b/native/opus/Cargo.toml new file mode 100644 index 0000000..b975783 --- /dev/null +++ b/native/opus/Cargo.toml @@ -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" diff --git a/native/opus/build.rs b/native/opus/build.rs new file mode 100644 index 0000000..6b7a14d --- /dev/null +++ b/native/opus/build.rs @@ -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"); +} diff --git a/native/opus/src/lib.rs b/native/opus/src/lib.rs new file mode 100644 index 0000000..4cce49c --- /dev/null +++ b/native/opus/src/lib.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 { + 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> { + 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 { + 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> { + 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}"); + } +} diff --git a/native/opus/wrapper.h b/native/opus/wrapper.h new file mode 100644 index 0000000..da600ec --- /dev/null +++ b/native/opus/wrapper.h @@ -0,0 +1 @@ +#include diff --git a/native/packaging/README.md b/native/packaging/README.md new file mode 100644 index 0000000..28cab82 --- /dev/null +++ b/native/packaging/README.md @@ -0,0 +1,130 @@ +# NetDesk — Windows Packaging + +Build scripts that turn the Rust `netdesk-viewer` binary into the two Windows +distributions **NetDesk by Akronlogic** ships: + +| Edition | Artifact | Install? | +|---------------|---------------------------------|----------| +| **Portable** | `NetDesk-Portable-win64.zip` | No — unzip and run | +| **Installer** | `NetDesk-Setup-win64.exe` | Yes — Start Menu + Desktop shortcuts, uninstaller | + +Everything in this folder is self-contained; nothing here touches the Rust +sources. All outputs land in **`packaging\dist\`**. + +``` +packaging/ +├── build-portable.ps1 builds + stages the portable folder, zips it +├── build-installer.ps1 stages, runs makensis, signs both artifacts +├── netdesk.nsi NSIS (Modern UI 2) installer definition +├── README.md this file +└── dist/ (generated) + ├── NetDesk-Portable/ staging folder (also fed to the installer) + │ ├── NetDesk.exe = target/release/netdesk-viewer.exe, renamed + │ ├── libstdc++-6.dll MinGW C++ runtime (required at runtime) + │ ├── libgcc_s_seh-1.dll MinGW GCC runtime + │ ├── libwinpthread-1.dll MinGW threading runtime + │ └── README.txt + ├── NetDesk-Portable-win64.zip ← portable artifact + └── NetDesk-Setup-win64.exe ← installer artifact (needs makensis) +``` + +## Prerequisites + +- **Rust (windows-GNU toolchain)** + the MinGW-w64 toolchain at + `C:\mingw-netdesk\mingw64\bin`. The scripts prepend this and `~\.cargo\bin` + to `PATH` before calling cargo, matching the project's manual build step: + + ```sh + export PATH="/c/mingw-netdesk/mingw64/bin:$HOME/.cargo/bin:$PATH" + ``` + +- **NSIS** (for the installer only). If `makensis` is missing, + `build-installer.ps1` still produces the staging folder and prints the + install command: + + ```powershell + winget install --id NSIS.NSIS -e + # or download: https://nsis.sourceforge.io/Download + ``` + +## Produce the PORTABLE zip + +```powershell +powershell -ExecutionPolicy Bypass -File packaging\build-portable.ps1 +``` + +Builds `cargo build --release -p netdesk-viewer`, assembles +`dist\NetDesk-Portable\` (NetDesk.exe + the 3 DLLs + README.txt) and writes +`dist\NetDesk-Portable-win64.zip`. Add `-SkipBuild` to reuse an existing +`target\release\netdesk-viewer.exe`. + +## Produce the INSTALLER exe + +```powershell +powershell -ExecutionPolicy Bypass -File packaging\build-installer.ps1 +``` + +Runs the portable staging, then compiles `netdesk.nsi` with `makensis` into +`dist\NetDesk-Setup-win64.exe`, and signs both `NetDesk.exe` and the setup exe. +Useful flags: `-SkipBuild`, `-NoSign`, `-Thumbprint `. + +Compile the installer by hand (after staging) with: + +```powershell +makensis /DSTAGEDIR=packaging\dist\NetDesk-Portable /DOUTFILE=packaging\dist\NetDesk-Setup-win64.exe packaging\netdesk.nsi +``` + +The installer installs into `%ProgramFiles%\NetDesk`, creates Start Menu and +Desktop shortcuts, registers an uninstaller in Add/Remove Programs, and offers +an **optional, off-by-default** per-user autostart (HKCU `...\Run`). + +## Code signing + +Signing is handled by the project helper `..\certs\sign-release.ps1` +(Akronlogic cert, thumbprint `YOUR_CODE_SIGNING_CERT_THUMBPRINT`, +Sectigo timestamp). `build-installer.ps1`: + +1. invokes `sign-release.ps1` against the release tree, then +2. signs the staged `NetDesk.exe` (a renamed copy) and the finished + `NetDesk-Setup-win64.exe` with the **same** certificate + timestamp + mechanism (that helper is hardcoded to the `target\release` paths, so the + staged/setup files are signed with the identical cert here). + +> **Public trust note:** the bundled Akronlogic certificate is self-issued, so +> signatures are only trusted on machines that ran `certs\trust-akronlogic.cmd`. +> For signatures trusted on **every** Windows PC with no action by the user, a +> **CA-issued code-signing certificate** (Sectigo/DigiCert, chaining to a root +> in the Microsoft Trusted Root Program) is required — pass it to the helper +> with `-Thumbprint ` or `-PfxPath cert.pfx -PfxPassword ...`. +> Self-signed certificates never qualify for public trust. + +Skip signing entirely with `-NoSign` (e.g. for a local test build). + +## Notes + +- The portable and installer editions ship the **same** `NetDesk.exe` plus the + same three MinGW runtime DLLs. The viewer imports `libstdc++-6.dll` at + runtime, so the DLLs must always sit beside the exe. +- Deleting the portable folder fully removes it. The installer edition is + removed from Add/Remove Programs (or `uninstall.exe`). + +## iOS (macOS only) + +`build-ios.sh` (+ the `ios/` CocoaPods templates) builds the signed iOS app on a +Mac: it adds the Rust iOS targets, compiles `netdesk-core` for device + +simulator, assembles `NetDeskCore.xcframework`, wires it into the Flutter +`ui/ios/` project, and runs `flutter build ipa`. It cannot run on Windows (Xcode +is required). One command on the Mac: + +```bash +packaging/build-ios.sh --team +``` + +Full walkthrough — Apple Developer Program, signing, the VideoToolbox decode +path, every command — is in [`../docs/build-ios.md`](../docs/build-ios.md). + +``` +packaging/ios/ +├── netdesk_core.podspec vendors NetDeskCore.xcframework + the VideoToolbox frameworks +└── Podfile Flutter iOS Podfile template with the netdesk_core pod +``` diff --git a/native/packaging/build-android.ps1 b/native/packaging/build-android.ps1 new file mode 100644 index 0000000..934b8f8 --- /dev/null +++ b/native/packaging/build-android.ps1 @@ -0,0 +1,402 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + NetDesk - Android build pipeline. + + Compiles the Rust FFI layer (netdesk-frb, a cdylib that statically links + netdesk-core) into per-ABI .so libraries with cargo-ndk, drops them into + the Flutter jniLibs tree, optionally provisions a release signing keystore, + then produces the release APK, the per-ABI split APKs and the Play Store + App Bundle (.aab). + + Developed by Akronlogic (https://akronlogic.com). + +.DESCRIPTION + The Flutter UI (ui/) binds, via flutter_rust_bridge, to the compiled Rust + library shipped as a shared object inside the APK/AAB under + lib//libnetdesk_frb.so (the Dart loader looks for stem 'netdesk_frb'). + This script is the single entry point that wires the Android toolchain: + + netdesk-frb --cargo-ndk--> ui/android/app/src/main/jniLibs//libnetdesk_frb.so + Flutter --gradle-----> release .apk / split .apk / .aab (signed) + + THE ONE COMMAND (from the repo root): + + powershell -ExecutionPolicy Bypass -File packaging\build-android.ps1 + + First release-signed run (keystore does not exist yet): + + $env:NETDESK_KEYSTORE_PASSWORD = '' + powershell -ExecutionPolicy Bypass -File packaging\build-android.ps1 -GenerateKeystore + +.NOTES + Prerequisites (the script verifies / auto-provisions what it can): + * Rust toolchain on PATH (cargo, rustup). + * cargo-ndk (auto-installed with `cargo install cargo-ndk` if absent). + * rustup Android std (auto-added if absent). + * Android NDK (default C:\dev\android-ndk; override with -NdkHome + or $env:ANDROID_NDK_HOME). REQUIRED for the Rust step. + * Android SDK (default %LOCALAPPDATA%\Android\Sdk). + * Flutter (default C:\dev\flutter\bin\flutter.bat). + * A JDK for keytool / Gradle (Android Studio's JBR is auto-detected). + + SECURITY: no real keystore password is ever stored in a committed file. + key.properties and *.keystore are git-ignored. -GenerateKeystore reads the + password ONLY from -KeystorePassword or $env:NETDESK_KEYSTORE_PASSWORD. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'KeystorePassword', + Justification = 'keytool -storepass/-keypass require plaintext; sourced from an env var and never committed.')] +[CmdletBinding()] +param( + # Root of the Android NDK (contains source.properties + toolchains/llvm). + [string] $NdkHome = $(if ($env:ANDROID_NDK_HOME) { $env:ANDROID_NDK_HOME } else { 'C:\dev\android-ndk' }), + # Android SDK root. + [string] $SdkHome = $(if ($env:ANDROID_HOME) { $env:ANDROID_HOME } elseif ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { Join-Path $env:LOCALAPPDATA 'Android\Sdk' }), + # JDK home for keytool + Gradle. Auto-detected (Android Studio JBR) when empty. + [string] $JavaHome = $env:JAVA_HOME, + # ABIs to build. arm64-v8a is the primary shipping ABI. + [string[]] $Abis = @('arm64-v8a', 'armeabi-v7a', 'x86_64'), + # Android API level the .so links against. Must be <= the Flutter minSdk. + [int] $MinSdk = 21, + # Cargo package compiled into the Android .so (the flutter_rust_bridge layer). + [string] $RustPackage = 'netdesk-frb', + # The crate's [lib] name -> the .so is lib.so (what the Dart loader opens). + [string] $LibName = 'netdesk_frb', + # Which deliverables to produce. + [ValidateSet('all', 'apk', 'split', 'aab', 'rust', 'none')] + [string] $Target = 'all', + # Skip the Rust/cargo-ndk step (reuse whatever .so are already in jniLibs). + [switch] $SkipRust, + # Create (if missing) the release keystore + key.properties before building. + [switch] $GenerateKeystore, + # Keystore / key password. NEVER hard-code this - pass it or set the env var. + [string] $KeystorePassword = $env:NETDESK_KEYSTORE_PASSWORD, + # Key alias inside the keystore. + [string] $KeyAlias = 'netdesk', + # Path to flutter.bat. + [string] $FlutterBat = 'C:\dev\flutter\bin\flutter.bat' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 2.0 + +# --------------------------------------------------------------------------- # +# Paths +# --------------------------------------------------------------------------- # +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$RepoRoot = Split-Path -Parent $ScriptDir +$UiDir = Join-Path $RepoRoot 'ui' +$AndroidDir = Join-Path $UiDir 'android' +$JniLibs = Join-Path $AndroidDir 'app\src\main\jniLibs' +$CorePkg = $RustPackage +$SoName = "lib$LibName.so" + +# Flutter ABI name -> Rust target triple (for reporting / target checks). +$AbiTriple = @{ + 'arm64-v8a' = 'aarch64-linux-android' + 'armeabi-v7a' = 'armv7-linux-androideabi' + 'x86_64' = 'x86_64-linux-android' + 'x86' = 'i686-linux-android' +} + +# --------------------------------------------------------------------------- # +# Logging +# --------------------------------------------------------------------------- # +function Write-Step { param([string]$m) Write-Host "`n==> $m" -ForegroundColor Cyan } +function Write-Info { param([string]$m) Write-Host " $m" -ForegroundColor Gray } +function Write-Ok { param([string]$m) Write-Host " [ok] $m" -ForegroundColor Green } +function Write-Warn2{ param([string]$m) Write-Host " [warn] $m" -ForegroundColor Yellow } +function Die { param([string]$m) Write-Host "`n[FATAL] $m`n" -ForegroundColor Red; exit 1 } + +function Invoke-Native { + param([string]$Exe, [string[]]$ArgList, [string]$WorkDir) + if ($WorkDir) { Push-Location $WorkDir } + try { + Write-Info "$Exe $($ArgList -join ' ')" + & $Exe @ArgList + if ($LASTEXITCODE -ne 0) { Die "'$Exe' exited with code $LASTEXITCODE" } + } finally { + if ($WorkDir) { Pop-Location } + } +} + +$banner = @' + _ _ _ ____ _ _ _ _ _ + | \ | | ___| |_| _ \ ___ ___| | _| | __ _ _ __ __| |_ __ ___ (_) __| | + | \| |/ _ \ __| | | |/ _ \/ __| |/ / | / _` | '_ \ / _` | '__/ _ \| |/ _` | + | |\ | __/ |_| |_| | __/\__ \ <| |___| (_| | | | | (_| | | | (_) | | (_| | + |_| \_|\___|\__|____/ \___||___/_|\_\_____|\__,_|_| |_|\__,_|_| \___/|_|\__,_| +'@ +Write-Host $banner -ForegroundColor Blue +Write-Host " NetDesk Android build pipeline - Akronlogic" -ForegroundColor DarkGray + +# --------------------------------------------------------------------------- # +# Toolchain discovery / environment +# --------------------------------------------------------------------------- # +Write-Step 'Resolving toolchain' + +if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { + Die 'cargo not found on PATH. Install Rust (https://rustup.rs) and re-open the shell.' +} +Write-Ok "cargo: $((cargo --version) 2>&1)" + +# JAVA_HOME: auto-detect Android Studio's bundled JBR when not provided. +if (-not $JavaHome -or -not (Test-Path (Join-Path $JavaHome 'bin'))) { + $jbrCandidates = @( + 'C:\Program Files\Android\Android Studio\jbr', + 'C:\Program Files\Android\Android Studio\jre', + (Join-Path ${env:ProgramFiles} 'Android\Android Studio\jbr') + ) | Where-Object { $_ -and (Test-Path (Join-Path $_ 'bin\java.exe')) } + if ($jbrCandidates) { $JavaHome = $jbrCandidates[0] } +} +if ($JavaHome -and (Test-Path (Join-Path $JavaHome 'bin\java.exe'))) { + $env:JAVA_HOME = $JavaHome + $env:PATH = (Join-Path $JavaHome 'bin') + ';' + $env:PATH + Write-Ok "JAVA_HOME: $JavaHome" +} else { + Write-Warn2 'No JDK found (JAVA_HOME unset and no Android Studio JBR). Gradle/keytool may fail.' +} + +# Android SDK. +if (Test-Path $SdkHome) { + $env:ANDROID_HOME = $SdkHome + $env:ANDROID_SDK_ROOT = $SdkHome + Write-Ok "ANDROID_HOME: $SdkHome" +} else { + Write-Warn2 "Android SDK not found at $SdkHome (Flutter build apk/aab needs it)." +} + +$needRust = (-not $SkipRust) -and ($Target -in @('all', 'apk', 'split', 'aab', 'rust')) + +# --------------------------------------------------------------------------- # +# Android NDK - detection (the script "detects it"; required only for Rust) +# --------------------------------------------------------------------------- # +function Test-Ndk { + param([string]$Path) + if (-not $Path) { return $false } + if (-not (Test-Path (Join-Path $Path 'source.properties'))) { return $false } + if (-not (Test-Path (Join-Path $Path 'toolchains\llvm\prebuilt'))) { return $false } + return $true +} + +$ndkOk = Test-Ndk $NdkHome +if ($needRust) { + Write-Step 'Locating Android NDK' + if ($ndkOk) { + $rev = (Select-String -Path (Join-Path $NdkHome 'source.properties') -Pattern 'Pkg.Revision\s*=\s*(.+)' ).Matches.Groups[1].Value.Trim() + $env:ANDROID_NDK_HOME = $NdkHome + $env:ANDROID_NDK_ROOT = $NdkHome + Write-Ok "NDK $rev at $NdkHome" + } else { + Die @" +Android NDK not found (or incomplete) at: + $NdkHome + +Expected '$NdkHome\source.properties' and '$NdkHome\toolchains\llvm\prebuilt'. +The NDK may still be downloading/extracting. When it is in place, re-run: + + powershell -ExecutionPolicy Bypass -File packaging\build-android.ps1 + +Or point at another location: -NdkHome / `$env:ANDROID_NDK_HOME +Or skip the Rust step to build Flutter only: -SkipRust +"@ + } +} + +# --------------------------------------------------------------------------- # +# Rust -> per-ABI .so via cargo-ndk +# --------------------------------------------------------------------------- # +if ($needRust) { + Write-Step 'Preparing Rust Android targets' + + # cargo-ndk (auto-install if missing). + if (-not (Get-Command cargo-ndk -ErrorAction SilentlyContinue)) { + Write-Warn2 'cargo-ndk not found - installing (cargo install cargo-ndk)...' + Invoke-Native 'cargo' @('install', 'cargo-ndk') + } + Write-Ok "cargo-ndk: $((cargo ndk --version) 2>&1)" + + # rustup std for each requested ABI (auto-add if missing). + $installed = (rustup target list --installed) 2>&1 + foreach ($abi in $Abis) { + $triple = $AbiTriple[$abi] + if (-not $triple) { Die "Unknown ABI '$abi'. Valid: $($AbiTriple.Keys -join ', ')" } + if ($installed -notcontains $triple) { + Write-Warn2 "rustup target '$triple' missing - adding..." + Invoke-Native 'rustup' @('target', 'add', $triple) + } else { + Write-Ok "rust target present: $triple" + } + } + + Write-Step "Building $CorePkg -> $SoName [$($Abis -join ', ')] (API $MinSdk)" + New-Item -ItemType Directory -Force -Path $JniLibs | Out-Null + + # cargo-ndk writes // directly (that IS the "copy"). + # cargo ndk -t ... -o --platform build --release --package --lib + $ndkArgs = @() + foreach ($abi in $Abis) { $ndkArgs += @('-t', $abi) } + $ndkArgs += '-o'; $ndkArgs += $JniLibs + $ndkArgs += '--platform'; $ndkArgs += "$MinSdk" + $ndkArgs += 'build'; $ndkArgs += '--release' + $ndkArgs += '--package'; $ndkArgs += $CorePkg + $ndkArgs += '--lib' + + Invoke-Native 'cargo' (@('ndk') + $ndkArgs) $RepoRoot + + Write-Step 'Verifying .so artifacts' + $allOk = $true + foreach ($abi in $Abis) { + $so = Join-Path $JniLibs "$abi\$SoName" + if (Test-Path $so) { + $kb = [math]::Round((Get-Item $so).Length / 1KB) + Write-Ok "$abi/$SoName ($kb KB)" + } else { + Write-Warn2 "MISSING: $abi/$SoName" + $allOk = $false + } + } + if (-not $allOk) { Die 'One or more ABIs failed to produce a .so (see cargo output above).' } + + # ----------------------------------------------------------------------- # + # Bundle the shared C++ STL beside the engine. The Rust cdylib links + # against libc++_shared (pulled in by the openh264 C++ build), and Android + # devices do NOT ship it — without this copy the app dies at startup with: + # dlopen failed: library "libc++_shared.so" not found + # (needed by .../lib//libnetdesk_frb.so) + # NOTE: the sysroot folder for armeabi-v7a is 'arm-linux-androideabi' + # (NOT the Rust triple 'armv7-linux-androideabi'). + # ----------------------------------------------------------------------- # + Write-Step 'Bundling libc++_shared.so per ABI' + $SysrootLib = Join-Path $NdkHome 'toolchains\llvm\prebuilt\windows-x86_64\sysroot\usr\lib' + $AbiSysDir = @{ + 'arm64-v8a' = 'aarch64-linux-android' + 'armeabi-v7a' = 'arm-linux-androideabi' + 'x86_64' = 'x86_64-linux-android' + } + foreach ($abi in $Abis) { + $src = Join-Path $SysrootLib "$($AbiSysDir[$abi])\libc++_shared.so" + if (-not (Test-Path $src)) { Die "libc++_shared.so not found for ${abi}: $src" } + Copy-Item $src (Join-Path $JniLibs "$abi\libc++_shared.so") -Force + $kb = [math]::Round((Get-Item (Join-Path $JniLibs "$abi\libc++_shared.so")).Length / 1KB) + Write-Ok "$abi/libc++_shared.so bundled ($kb KB)" + } +} else { + Write-Step 'Skipping Rust step (-SkipRust or -Target none)' +} + +if ($Target -eq 'rust' -or $Target -eq 'none') { + Write-Step 'Done (Rust-only target requested).' + exit 0 +} + +# --------------------------------------------------------------------------- # +# Release signing (optional keystore provisioning) +# --------------------------------------------------------------------------- # +$KeyPropsFile = Join-Path $AndroidDir 'key.properties' +$KeystoreFile = Join-Path $AndroidDir 'app\netdesk-release.keystore' + +if ($GenerateKeystore) { + Write-Step 'Provisioning release signing keystore' + if (Test-Path $KeystoreFile) { + Write-Ok "Keystore already exists: $KeystoreFile (not regenerating)" + } else { + if ([string]::IsNullOrWhiteSpace($KeystorePassword)) { + Die @' +-GenerateKeystore was requested but no password was supplied. +Set it WITHOUT hard-coding it anywhere committed, e.g.: + + $env:NETDESK_KEYSTORE_PASSWORD = '' + ... -GenerateKeystore + +or pass -KeystorePassword ''. +'@ + } + $keytool = $null + foreach ($c in @( + (Join-Path $env:JAVA_HOME 'bin\keytool.exe'), + 'C:\Program Files\Android\Android Studio\jbr\bin\keytool.exe')) { + if ($c -and (Test-Path $c)) { $keytool = $c; break } + } + if (-not $keytool) { + $gc = Get-Command keytool -ErrorAction SilentlyContinue + if ($gc) { $keytool = $gc.Source } + } + if (-not $keytool) { Die 'keytool not found (install a JDK or Android Studio).' } + + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $KeystoreFile) | Out-Null + Invoke-Native $keytool @( + '-genkeypair', '-v', + '-keystore', $KeystoreFile, + '-alias', $KeyAlias, + '-keyalg', 'RSA', '-keysize', '4096', + '-validity', '10000', + '-storepass', $KeystorePassword, + '-keypass', $KeystorePassword, + '-dname', 'CN=NetDesk, O=Akronlogic, OU=Release, C=DE' + ) + Write-Ok "Created keystore: $KeystoreFile" + } + + # Write key.properties (git-ignored; holds the local password). + @" +# NetDesk release signing - LOCAL ONLY, never commit (git-ignored). +# Regenerate the keystore with: build-android.ps1 -GenerateKeystore +storePassword=$KeystorePassword +keyPassword=$KeystorePassword +keyAlias=$KeyAlias +storeFile=$($KeystoreFile -replace '\\','/') +"@ | Set-Content -Encoding ASCII -Path $KeyPropsFile + Write-Ok "Wrote $KeyPropsFile" +} + +if (-not (Test-Path $KeyPropsFile)) { + Write-Warn2 'No key.properties found - release artifacts will be DEBUG-signed.' + Write-Warn2 'Run with -GenerateKeystore (and a password) to produce upload-signed builds.' +} + +# --------------------------------------------------------------------------- # +# Flutter build +# --------------------------------------------------------------------------- # +if (-not (Test-Path $FlutterBat)) { Die "flutter.bat not found at $FlutterBat (use -FlutterBat)." } +if (-not (Test-Path $AndroidDir)) { + Die "Flutter android/ project missing at $AndroidDir. Run: flutter create --platforms=android . (in ui/)" +} + +Write-Step 'flutter pub get' +Invoke-Native $FlutterBat @('pub', 'get') $UiDir + +function Invoke-FlutterBuild { + param([string[]]$ArgList, [string]$Label) + Write-Step "flutter build $Label" + Invoke-Native $FlutterBat (@('build') + $ArgList) $UiDir + Write-Ok "$Label done" +} + +switch ($Target) { + 'apk' { Invoke-FlutterBuild @('apk', '--release') 'apk (fat)' } + 'split' { Invoke-FlutterBuild @('apk', '--release', '--split-per-abi') 'apk (split-per-abi)' } + 'aab' { Invoke-FlutterBuild @('appbundle', '--release') 'appbundle (.aab)' } + 'all' { + Invoke-FlutterBuild @('apk', '--release') 'apk (fat)' + Invoke-FlutterBuild @('apk', '--release', '--split-per-abi') 'apk (split-per-abi)' + Invoke-FlutterBuild @('appbundle', '--release') 'appbundle (.aab)' + } +} + +# --------------------------------------------------------------------------- # +# Report artifacts +# --------------------------------------------------------------------------- # +Write-Step 'Artifacts' +$outApk = Join-Path $UiDir 'build\app\outputs\flutter-apk' +$outAab = Join-Path $UiDir 'build\app\outputs\bundle\release' +foreach ($dir in @($outApk, $outAab)) { + if (Test-Path $dir) { + Get-ChildItem $dir -Filter '*.ap*' -ErrorAction SilentlyContinue | ForEach-Object { + $mb = [math]::Round($_.Length / 1MB, 2) + Write-Host (" {0,-34} {1,8} MB" -f $_.Name, $mb) -ForegroundColor Green + Write-Host (" {0}" -f $_.FullName) -ForegroundColor DarkGray + } + } +} +Write-Host "`n==> NetDesk Android build complete.`n" -ForegroundColor Cyan diff --git a/native/packaging/build-brand.ps1 b/native/packaging/build-brand.ps1 new file mode 100644 index 0000000..17f4b6a --- /dev/null +++ b/native/packaging/build-brand.ps1 @@ -0,0 +1,58 @@ +<# + NetDesk by Akronlogic -- white-label / custom-client generator. + + Produces a pre-configured, re-brandable portable client by RENAMING the exe so + it self-configures on first launch (no rebuild, no per-brand signing key + needed — the single signed NetDesk.exe is reused): + + powershell -File build-brand.ps1 -Name AcmeRemote -Server vpn.acme.io -Key ACME9 [-Port 8443] + + Output: dist\brand-\-server=[,port=][,key=].exe + + the runtime DLLs. Ship the folder as-is; on first launch the client writes + the relay + registration key into config.json (see core::brand + + viewer apply_filename_selfconfig). Logo / palette / title theming and a signed + MSI with a reseller cert are the remaining white-label steps (see BUILD-QUEUE). +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][string]$Server, + [string]$Key = "", + [string]$Port = "", + [string]$MingwBin = "C:\mingw-netdesk\mingw64\bin", + [string]$OutDir = "", + [switch]$SkipStage +) +$ErrorActionPreference = "Stop" +$Pkg = $PSScriptRoot +$Stage = Join-Path $Pkg "dist\NetDesk-Portable" + +# 1. stage the signed portable payload unless told to reuse it. +if (-not $SkipStage) { + & (Join-Path $Pkg "build-portable.ps1") -MingwBin $MingwBin -SkipBuild +} +if (-not (Test-Path (Join-Path $Stage "NetDesk.exe"))) { + throw "staged payload missing: $Stage (run build-portable.ps1 first, or omit -SkipStage)" +} + +# 2. compose the self-configuring exe name. +$cfg = "server=$Server" +if ($Port) { $cfg += ",port=$Port" } +if ($Key) { $cfg += ",key=$Key" } +$exeName = "$Name-$cfg.exe" + +if (-not $OutDir) { $OutDir = Join-Path $Pkg ("dist\brand-" + $Name) } +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null + +# 3. the single signed NetDesk.exe is reused verbatim, just renamed; the runtime +# DLLs travel with it. (The signature stays valid — we rename, not patch.) +Copy-Item (Join-Path $Stage "NetDesk.exe") (Join-Path $OutDir $exeName) -Force +foreach ($dll in "libgcc_s_seh-1.dll","libstdc++-6.dll","libwinpthread-1.dll") { + $src = Join-Path $Stage $dll + if (Test-Path $src) { Copy-Item $src (Join-Path $OutDir $dll) -Force } +} + +$scheme = "wss://$Server" +if ($Port) { $scheme = "wss://${Server}:$Port" } +Write-Host "white-label client -> $OutDir\$exeName" -ForegroundColor Green +Write-Host " pre-pointed at $scheme/ws; self-configures config.json on first launch." diff --git a/native/packaging/build-installer.ps1 b/native/packaging/build-installer.ps1 new file mode 100644 index 0000000..e785e9d --- /dev/null +++ b/native/packaging/build-installer.ps1 @@ -0,0 +1,145 @@ +<# + NetDesk by Akronlogic -- INSTALLER build script. + + 1. Runs build-portable.ps1 to build the release viewer and stage the files + (dist\NetDesk-Portable\). + 2. Detects makensis (NSIS). If found, compiles netdesk.nsi into + dist\NetDesk-Setup-win64.exe. If NOT found, prints install instructions + and still leaves the staging folder ready. + 3. Code-signs NetDesk.exe (in the staging folder) and the setup .exe via + certs\sign-release.ps1, when signing is available. + + Usage: + powershell -ExecutionPolicy Bypass -File build-installer.ps1 + powershell -ExecutionPolicy Bypass -File build-installer.ps1 -SkipBuild + powershell -ExecutionPolicy Bypass -File build-installer.ps1 -NoSign + powershell -ExecutionPolicy Bypass -File build-installer.ps1 -Thumbprint +#> +[CmdletBinding()] +param( + [string]$MingwBin = "C:\mingw-netdesk\mingw64\bin", + [string]$Thumbprint = "YOUR_CODE_SIGNING_CERT_THUMBPRINT", + [switch]$SkipBuild, + [switch]$NoSign +) + +$ErrorActionPreference = "Stop" + +# ---- paths ----------------------------------------------------------------- +$PkgDir = $PSScriptRoot +$RootDir = Split-Path -Parent $PkgDir +$DistDir = Join-Path $PkgDir "dist" +$StageDir = Join-Path $DistDir "NetDesk-Portable" +$NsiScript = Join-Path $PkgDir "netdesk.nsi" +$SetupExe = Join-Path $DistDir "NetDesk-Setup-win64.exe" +$SignPs1 = Join-Path $RootDir "certs\sign-release.ps1" +$BrandIco = Join-Path $PkgDir "netdesk.ico" # branded setup-exe icon +$AssetIco = Join-Path $RootDir "assets\icon.ico" # source NetDesk mark + +Write-Host "=== NetDesk installer build (Akronlogic) ===" -ForegroundColor Cyan + +# ---- 1. stage via build-portable.ps1 -------------------------------------- +Write-Host "`n[1/4] staging portable payload" -ForegroundColor Yellow +$portableArgs = @{ MingwBin = $MingwBin } +if ($SkipBuild) { $portableArgs.SkipBuild = $true } +& (Join-Path $PkgDir "build-portable.ps1") @portableArgs +if ($LASTEXITCODE) { throw "build-portable.ps1 failed (exit $LASTEXITCODE)" } +if (-not (Test-Path $StageDir)) { throw "staging folder missing: $StageDir" } + +# ---- sign the staged NetDesk.exe BEFORE packaging it into the installer ---- +function Invoke-Sign([string[]]$Files) { + if ($NoSign) { Write-Host " -NoSign set: skipping signing." -ForegroundColor DarkYellow; return } + if (-not (Test-Path $SignPs1)) { Write-Host " signing helper not found ($SignPs1) -- skipping." -ForegroundColor DarkYellow; return } + $signtool = Get-Command signtool.exe -ErrorAction SilentlyContinue + foreach ($f in $Files) { + if (-not (Test-Path $f)) { continue } + Write-Host " signing: $f" -ForegroundColor Gray + try { + if ($signtool) { + & $signtool.Source sign /sha1 $Thumbprint /fd SHA256 /tr "http://timestamp.sectigo.com" /td SHA256 "$f" + if ($LASTEXITCODE) { Write-Host " signtool returned $LASTEXITCODE (continuing)" -ForegroundColor DarkYellow } + } else { + # Fall back to Set-AuthenticodeSignature via the project helper logic. + $cert = Get-ChildItem Cert:\CurrentUser\My | Where-Object { $_.Thumbprint -eq $Thumbprint } | Select-Object -First 1 + if (-not $cert) { Write-Host " cert $Thumbprint not in CurrentUser\My -- skipping." -ForegroundColor DarkYellow; continue } + $r = Set-AuthenticodeSignature -FilePath $f -Certificate $cert -HashAlgorithm SHA256 -TimestampServer "http://timestamp.sectigo.com" + Write-Host (" sign: {0}" -f $r.Status) -ForegroundColor Gray + } + } catch { + Write-Host " signing failed: $($_.Exception.Message) (continuing)" -ForegroundColor DarkYellow + } + } +} + +Write-Host "`n[2/4] signing staged NetDesk.exe" -ForegroundColor Yellow +# Run the official project helper on the release tree (signs netdesk-viewer.exe +# etc. exactly as Akronlogic ships them). Non-fatal if the cert isn't present. +if (-not $NoSign -and (Test-Path $SignPs1)) { + try { + & powershell -NoProfile -ExecutionPolicy Bypass -File $SignPs1 -Thumbprint $Thumbprint + } catch { + Write-Host " sign-release.ps1 note: $($_.Exception.Message) (continuing)" -ForegroundColor DarkYellow + } +} +# The staged NetDesk.exe is a renamed COPY, so it needs its own signature using +# the same certificate + timestamp mechanism that sign-release.ps1 uses. +Invoke-Sign @( (Join-Path $StageDir "NetDesk.exe") ) + +# Re-zip the portable artifact so it, too, ships the now-signed NetDesk.exe +# (build-portable.ps1 zipped the staging folder before this signing step). +if (-not $NoSign) { + $zip = Join-Path $DistDir "NetDesk-Portable-win64.zip" + if (Test-Path $zip) { Remove-Item $zip -Force } + Compress-Archive -Path (Join-Path $StageDir "*") -DestinationPath $zip -CompressionLevel Optimal + Write-Host " re-zipped portable with signed NetDesk.exe -> $zip" -ForegroundColor Gray +} + +# ---- 3. compile the installer with makensis ------------------------------- +Write-Host "`n[3/4] locating makensis (NSIS)" -ForegroundColor Yellow +$makensis = Get-Command makensis.exe -ErrorAction SilentlyContinue +if (-not $makensis) { + foreach ($p in @("$env:ProgramFiles\NSIS\makensis.exe", "${env:ProgramFiles(x86)}\NSIS\makensis.exe")) { + if (Test-Path $p) { $makensis = Get-Item $p; break } + } +} + +if (-not $makensis) { + Write-Host "" + Write-Host "makensis (NSIS) is NOT installed -- the installer .exe was NOT built." -ForegroundColor Red + Write-Host "The portable zip and the installer staging folder are ready." -ForegroundColor Red + Write-Host "" + Write-Host "Install NSIS, then re-run this script:" -ForegroundColor Yellow + Write-Host " winget install --id NSIS.NSIS -e" + Write-Host " or download from https://nsis.sourceforge.io/Download" + Write-Host "" + Write-Host "After installing, build the installer manually with:" -ForegroundColor Yellow + Write-Host (" makensis /DSTAGEDIR={0} /DOUTFILE={1} `"{2}`"" -f $StageDir, $SetupExe, $NsiScript) + exit 0 +} + +Write-Host " found: $($makensis.Source)" -ForegroundColor Gray +if (Test-Path $SetupExe) { Remove-Item $SetupExe -Force } + +# Brand the setup .exe with the green NetDesk icon (copy the asset next to the +# .nsi and pass its absolute path as /DBRANDICON). +$nsisArgs = @("/DSTAGEDIR=$StageDir", "/DOUTFILE=$SetupExe") +if (Test-Path $AssetIco) { + Copy-Item $AssetIco $BrandIco -Force + $nsisArgs += "/DBRANDICON=$BrandIco" + Write-Host " branding setup icon: $BrandIco" -ForegroundColor Gray +} else { + Write-Host " asset icon not found ($AssetIco) -- setup will use the default NSIS icon." -ForegroundColor DarkYellow +} +$nsisArgs += "$NsiScript" +& $makensis.Source @nsisArgs +if ($LASTEXITCODE) { throw "makensis failed (exit $LASTEXITCODE)" } +if (-not (Test-Path $SetupExe)) { throw "makensis reported success but $SetupExe is missing" } + +# ---- 4. sign the setup .exe ------------------------------------------------ +Write-Host "`n[4/4] signing the setup installer" -ForegroundColor Yellow +Invoke-Sign @( $SetupExe ) + +$si = Get-Item $SetupExe +Write-Host "`nInstaller build complete." -ForegroundColor Green +Write-Host (" setup : {0}" -f $si.FullName) +Write-Host (" size : {0:N0} bytes ({1:N2} MB)" -f $si.Length, ($si.Length/1MB)) diff --git a/native/packaging/build-ios.sh b/native/packaging/build-ios.sh new file mode 100644 index 0000000..8696858 --- /dev/null +++ b/native/packaging/build-ios.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +# ============================================================================= +# NetDesk by Akronlogic -- iOS build (Flutter + Rust core). +# +# Runs on macOS ONLY (needs Xcode + the iOS SDKs). It: +# 1. adds the Rust iOS targets (aarch64-apple-ios + aarch64-apple-ios-sim) +# 2. builds the Rust core static lib for device AND simulator +# 3. assembles NetDeskCore.xcframework (xcodebuild -create-xcframework) +# 4. ensures ui/ios/ exists (flutter create --platforms=ios .) +# 5. wires the xcframework into the Flutter iOS project (CocoaPods podspec) +# 6. flutter build ipa (signed with your Apple team) +# +# The Rust FFI static lib comes from the workspace `core` crate +# (package netdesk-core, lib netdesk_core -> libnetdesk_core.a). When the +# Phase-3 flutter_rust_bridge wrapper crate lands under a different name +# (e.g. "netdesk-frb"), point the script at it with: +# CORE_CRATE=netdesk-frb LIB_NAME=netdesk_frb ./build-ios.sh ... +# +# See docs/build-ios.md for prerequisites (Apple Developer Program, signing +# cert + provisioning profile) and the full walkthrough. +# +# Usage: +# packaging/build-ios.sh --team ABCDE12345 +# packaging/build-ios.sh --team ABCDE12345 --method ad-hoc --org com.akronlogic +# packaging/build-ios.sh --skip-ipa # just build the xcframework +# packaging/build-ios.sh --help +# +# Env overrides: CORE_CRATE LIB_NAME DEVICE_TARGET SIM_TARGET IOS_MIN +# FLUTTER TEAM_ID EXPORT_METHOD FLUTTER_ORG +# ============================================================================= +set -euo pipefail + +# ---- configuration (env-overridable) --------------------------------------- +CORE_CRATE="${CORE_CRATE:-netdesk-core}" # cargo package to build +LIB_NAME="${LIB_NAME:-netdesk_core}" # -> lib.a +DEVICE_TARGET="${DEVICE_TARGET:-aarch64-apple-ios}" +SIM_TARGET="${SIM_TARGET:-aarch64-apple-ios-sim}" # Intel Macs: x86_64-apple-ios +IOS_MIN="${IOS_MIN:-13.0}" +FLUTTER="${FLUTTER:-flutter}" +FLUTTER_ORG="${FLUTTER_ORG:-space.netdesk}" # -> bundle id .netdesk +EXPORT_METHOD="${EXPORT_METHOD:-app-store}" # app-store | ad-hoc | development | enterprise +TEAM_ID="${TEAM_ID:-${DEVELOPMENT_TEAM:-}}" # 10-char Apple Team ID +XCFRAMEWORK="NetDeskCore.xcframework" + +SKIP_IPA=0 + +# ---- args ------------------------------------------------------------------ +usage() { sed -n '3,29p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; } +while [ $# -gt 0 ]; do + case "$1" in + --team) TEAM_ID="${2:?--team needs an Apple Team ID}"; shift 2 ;; + --method) EXPORT_METHOD="${2:?--method needs a value}"; shift 2 ;; + --org) FLUTTER_ORG="${2:?--org needs a value}"; shift 2 ;; + --skip-ipa|--xcframework-only) SKIP_IPA=1; shift ;; + -h|--help) usage 0 ;; + *) echo "unknown argument: $1" >&2; usage 1 ;; + esac +done + +# ---- paths ----------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +UI="$ROOT/ui" +IOSDIR="$UI/ios" +TEMPLATES="$SCRIPT_DIR/ios" +DIST="$SCRIPT_DIR/dist/ios" +DEV_LIB="$ROOT/target/$DEVICE_TARGET/release/lib$LIB_NAME.a" +SIM_LIB="$ROOT/target/$SIM_TARGET/release/lib$LIB_NAME.a" + +say() { printf '\033[36m==> %s\033[0m\n' "$*"; } +ok() { printf '\033[32m %s\033[0m\n' "$*"; } +die() { printf '\033[31mERROR: %s\033[0m\n' "$*" >&2; exit 1; } + +cat <}") +BANNER + +# ---- 0. preflight ---------------------------------------------------------- +say "0/6 preflight" +[ "$(uname -s)" = "Darwin" ] || die "iOS builds require macOS. Run this on the Mac." +command -v rustup >/dev/null || die "rustup not found. Install Rust: https://rustup.rs" +command -v cargo >/dev/null || die "cargo not found on PATH (try: source \$HOME/.cargo/env)" +command -v xcrun >/dev/null || die "xcrun not found. Install Xcode + command line tools (xcode-select --install)." +xcode-select -p >/dev/null 2>&1 || die "no active Xcode. Run: sudo xcode-select -s /Applications/Xcode.app" +command -v "$FLUTTER" >/dev/null || die "flutter not found. Install the Flutter SDK and add it to PATH (or set FLUTTER=/path/to/flutter)." +if [ "$SKIP_IPA" = 0 ] && [ -z "$TEAM_ID" ]; then + die "no Apple Team ID. Pass --team ABCDE12345 (or TEAM_ID=...), or use --skip-ipa to only build the xcframework." +fi +ok "macOS $(sw_vers -productVersion 2>/dev/null || true), Xcode $(xcodebuild -version 2>/dev/null | head -1 || true)" + +# ---- 1. rust ios targets --------------------------------------------------- +say "1/6 rustup targets" +rustup target add "$DEVICE_TARGET" "$SIM_TARGET" +ok "$DEVICE_TARGET, $SIM_TARGET installed" + +# ---- 2. build the rust core static lib for both targets -------------------- +say "2/6 cargo build (release, staticlib) for device + simulator" +# Match Flutter's deployment target so the slices are link-compatible. +export IPHONEOS_DEPLOYMENT_TARGET="$IOS_MIN" +( cd "$ROOT" && cargo build --release -p "$CORE_CRATE" --target "$DEVICE_TARGET" ) +( cd "$ROOT" && cargo build --release -p "$CORE_CRATE" --target "$SIM_TARGET" ) +[ -f "$DEV_LIB" ] || die "expected device lib not found: $DEV_LIB" +[ -f "$SIM_LIB" ] || die "expected simulator lib not found: $SIM_LIB" +ok "device: $DEV_LIB" +ok "sim : $SIM_LIB" + +# ---- 3. assemble the xcframework ------------------------------------------- +say "3/6 assemble $XCFRAMEWORK" +mkdir -p "$DIST" +rm -rf "$DIST/$XCFRAMEWORK" # xcodebuild refuses to overwrite +# No -headers yet: the C ABI header (cbindgen over core/src/ffi.rs) is Phase 3. +# When it exists, add: -headers "$ROOT/target/include" to each -library pair. +xcodebuild -create-xcframework \ + -library "$DEV_LIB" \ + -library "$SIM_LIB" \ + -output "$DIST/$XCFRAMEWORK" +ok "built $DIST/$XCFRAMEWORK" + +if [ "$SKIP_IPA" = 1 ]; then + # Still stage it into ui/ios if that project already exists, so a later + # `flutter build ipa` picks up the fresh binary. + if [ -d "$IOSDIR" ]; then + cp -R "$DIST/$XCFRAMEWORK" "$IOSDIR/" + ok "staged into $IOSDIR/$XCFRAMEWORK" + fi + say "done (--skip-ipa). xcframework at: $DIST/$XCFRAMEWORK" + exit 0 +fi + +# ---- 4. ensure the Flutter iOS project exists ------------------------------ +say "4/6 ensure ui/ios Flutter project" +if [ ! -d "$IOSDIR" ]; then + ok "ui/ios missing — generating with flutter create" + ( cd "$UI" && "$FLUTTER" create --org "$FLUTTER_ORG" --project-name netdesk --platforms=ios . ) +else + ok "ui/ios already present — leaving it in place" +fi +( cd "$UI" && "$FLUTTER" pub get ) + +# ---- 5. wire the xcframework into ui/ios (CocoaPods) ----------------------- +say "5/6 wire xcframework into the iOS project" +cp -R "$DIST/$XCFRAMEWORK" "$IOSDIR/" +ok "copied $XCFRAMEWORK into ui/ios" + +cp "$TEMPLATES/netdesk_core.podspec" "$IOSDIR/netdesk_core.podspec" +ok "installed ui/ios/netdesk_core.podspec" + +# Podfile: install the template if there is none, otherwise inject the pod line. +if [ ! -f "$IOSDIR/Podfile" ]; then + cp "$TEMPLATES/Podfile" "$IOSDIR/Podfile" + ok "installed ui/ios/Podfile (from template)" +else + # Uncomment any commented `platform :ios` line so it matches the xcframework. + if grep -qE '^\s*#\s*platform :ios' "$IOSDIR/Podfile"; then + sed -i '' -E "s/^\s*#\s*platform :ios.*/platform :ios, '$IOS_MIN'/" "$IOSDIR/Podfile" + fi + # Inject the netdesk_core pod into the Runner target if it is not there yet. + if ! grep -q "pod 'netdesk_core'" "$IOSDIR/Podfile"; then + awk ' + /target '\''Runner'\'' do/ && !done { + print + print " # NetDesk Rust core (added by packaging/build-ios.sh)." + print " pod '\''netdesk_core'\'', :path => '\''.'\''" + done = 1 + next + } + { print } + ' "$IOSDIR/Podfile" > "$IOSDIR/Podfile.tmp" && mv "$IOSDIR/Podfile.tmp" "$IOSDIR/Podfile" + ok "injected pod 'netdesk_core' into existing Podfile" + else + ok "Podfile already references netdesk_core" + fi +fi + +# Signing for the archive step: set the team + automatic signing via the +# Flutter xcconfig layer (applies because a fresh Runner target has no explicit +# DEVELOPMENT_TEAM in project.pbxproj). Idempotent. +for cfg in Release Debug; do + XC="$IOSDIR/Flutter/$cfg.xcconfig" + [ -f "$XC" ] || continue + if ! grep -q '^DEVELOPMENT_TEAM' "$XC"; then + { + echo "" + echo "// NetDesk signing (added by packaging/build-ios.sh)" + echo "DEVELOPMENT_TEAM = $TEAM_ID" + echo "CODE_SIGN_STYLE = Automatic" + } >> "$XC" + ok "set DEVELOPMENT_TEAM in Flutter/$cfg.xcconfig" + else + sed -i '' -E "s/^DEVELOPMENT_TEAM.*/DEVELOPMENT_TEAM = $TEAM_ID/" "$XC" + ok "updated DEVELOPMENT_TEAM in Flutter/$cfg.xcconfig" + fi +done + +# ExportOptions.plist consumed by `flutter build ipa` for the export phase. +cat > "$IOSDIR/ExportOptions.plist" < + + + + method$EXPORT_METHOD + teamID$TEAM_ID + signingStyleautomatic + uploadSymbols + stripSwiftSymbols + destinationexport + + +PLIST +ok "wrote ui/ios/ExportOptions.plist (method=$EXPORT_METHOD)" + +# Resolve pods now so any CocoaPods error surfaces before the long archive. +if command -v pod >/dev/null; then + ( cd "$IOSDIR" && pod install ) + ok "pod install complete" +else + ok "CocoaPods 'pod' not on PATH — flutter will run it during the build (sudo gem install cocoapods to install)" +fi + +# ---- 6. flutter build ipa -------------------------------------------------- +say "6/6 flutter build ipa" +( cd "$UI" && "$FLUTTER" build ipa --release --export-options-plist=ios/ExportOptions.plist ) + +IPA="$(ls -t "$UI/build/ios/ipa/"*.ipa 2>/dev/null | head -1 || true)" +echo "" +echo "=== DONE ===" +echo "xcframework : $DIST/$XCFRAMEWORK (also staged in ui/ios/)" +if [ -n "$IPA" ]; then + echo "ipa : $IPA" + echo "" + echo "Upload to App Store Connect / TestFlight with:" + echo " xcrun altool --upload-app -f \"$IPA\" -t ios --apiKey --apiIssuer " + echo "or open ui/build/ios/archive/Runner.xcarchive in Xcode > Organizer > Distribute App." +else + echo "ipa : (not found under ui/build/ios/ipa — check the flutter output above)" +fi diff --git a/native/packaging/build-linux.sh b/native/packaging/build-linux.sh new file mode 100644 index 0000000..b3d523d --- /dev/null +++ b/native/packaging/build-linux.sh @@ -0,0 +1,341 @@ +#!/usr/bin/env bash +# +# NetDesk by Akronlogic -- LINUX build + packaging script. +# +# Builds the release viewer (cargo build --release -p netdesk-viewer) for the +# x86_64-unknown-linux-gnu target, then bundles TWO Linux distributions: +# +# * AppImage (NetDesk--x86_64.AppImage) -- single-file, run anywhere, +# libav* bundled via linuxdeploy; no install needed. +# * .deb (netdesk__amd64.deb) -- dpkg package with a +# .desktop launcher + hicolor icons; apt/dpkg install. +# +# The Linux decode path (core/src/decode_vaapi.rs) hardware-decodes H.264 with +# VAAPI through FFmpeg/libavcodec, so BUILDING needs the system FFmpeg + VAAPI +# development libraries present (see docs/build-linux.md). At RUNTIME VAAPI is +# optional -- the core falls back to the openh264 software decoder when no GPU +# decoder is available. +# +# Outputs land in packaging/dist/linux/ . +# +# Usage: +# export PATH="$HOME/.cargo/bin:$PATH" +# packaging/build-linux.sh # build + AppImage + .deb +# packaging/build-linux.sh --skip-build # reuse an existing binary +# packaging/build-linux.sh --no-appimage # .deb only +# packaging/build-linux.sh --no-deb # AppImage only +# packaging/build-linux.sh --target +# +# (c) Akronlogic. All rights reserved. + +set -euo pipefail + +# --------------------------------------------------------------------------- +# product / packaging identity +# --------------------------------------------------------------------------- +APP_NAME="NetDesk" +BIN_NAME="netdesk-viewer" # cargo [[bin]] name +APP_ID="com.akronlogic.netdesk" # == viewer ViewportBuilder::with_app_id +PKG_NAME="netdesk" # dpkg package name +TARGET="x86_64-unknown-linux-gnu" +DEB_ARCH="amd64" +MAINTAINER="Akronlogic " +HOMEPAGE="https://akronlogic.com" + +SKIP_BUILD=0 +DO_APPIMAGE=1 +DO_DEB=1 + +# --------------------------------------------------------------------------- +# tiny coloured logging helpers +# --------------------------------------------------------------------------- +say() { printf '\033[36m%s\033[0m\n' "$*"; } # cyan (headline) +step() { printf '\033[33m%s\033[0m\n' "$*"; } # yellow(step) +ok() { printf '\033[32m%s\033[0m\n' "$*"; } # green (success) +warn() { printf '\033[33m%s\033[0m\n' "WARN: $*" >&2; } +die() { printf '\033[31m%s\033[0m\n' "ERROR: $*" >&2; exit 1; } + +usage() { + sed -n '2,40p' "$0" | sed 's/^#\s\{0,1\}//' +} + +# --------------------------------------------------------------------------- +# args +# --------------------------------------------------------------------------- +while [ $# -gt 0 ]; do + case "$1" in + --skip-build) SKIP_BUILD=1 ;; + --no-appimage) DO_APPIMAGE=0 ;; + --no-deb) DO_DEB=0 ;; + --target) shift; [ $# -gt 0 ] || die "--target needs a value"; TARGET="$1" ;; + --target=*) TARGET="${1#*=}" ;; + -h|--help) usage; exit 0 ;; + *) die "unknown argument: $1 (try --help)" ;; + esac + shift +done + +# --------------------------------------------------------------------------- +# paths +# --------------------------------------------------------------------------- +PKG_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$PKG_DIR")" +ASSETS_DIR="$ROOT_DIR/assets" +DIST_DIR="$PKG_DIR/dist/linux" +TOOLS_DIR="$PKG_DIR/dist/.tools" + +VERSION="$(grep -m1 '^version' "$ROOT_DIR/Cargo.toml" | sed -E 's/.*"([^"]+)".*/\1/')" +[ -n "$VERSION" ] || VERSION="0.0.0" + +BIN="$ROOT_DIR/target/$TARGET/release/$BIN_NAME" +DESKTOP_SRC="$DIST_DIR/$APP_ID.desktop" + +say "=== NetDesk Linux build (Akronlogic) ===" +echo " root : $ROOT_DIR" +echo " version : $VERSION" +echo " target : $TARGET" +echo " dist : $DIST_DIR" +echo " outputs : $([ $DO_APPIMAGE -eq 1 ] && echo -n 'AppImage ')$([ $DO_DEB -eq 1 ] && echo -n '.deb')" + +# --------------------------------------------------------------------------- +# preflight +# --------------------------------------------------------------------------- +[ "$(uname -s)" = "Linux" ] || die "this script must run on Linux (building the x86_64-unknown-linux-gnu viewer)." + +# make sure a rustup/cargo install under ~/.cargo is reachable +case ":$PATH:" in *":$HOME/.cargo/bin:"*) : ;; *) PATH="$HOME/.cargo/bin:$PATH" ;; esac +export PATH + +if [ "$SKIP_BUILD" -eq 0 ]; then + command -v cargo >/dev/null 2>&1 || die "cargo not found on PATH. Install Rust (https://rustup.rs) and/or 'export PATH=\$HOME/.cargo/bin:\$PATH'." + + # The decode_vaapi.rs backend links FFmpeg via the ffmpeg-next crate; its + # build script (ffmpeg-sys-next) locates libav* with pkg-config and needs + # libclang (bindgen). Fail fast with a copy-pasteable install hint. + if ! command -v pkg-config >/dev/null 2>&1 || ! pkg-config --exists libavcodec 2>/dev/null; then + warn "FFmpeg development libraries (libavcodec) were not found via pkg-config." + echo " Debian/Ubuntu:" + echo " sudo apt install -y build-essential clang pkg-config nasm \\" + echo " libavcodec-dev libavformat-dev libavutil-dev libswscale-dev \\" + echo " libavfilter-dev libavdevice-dev libswresample-dev \\" + echo " libva-dev libgl1-mesa-dev" + echo " Fedora:" + echo " sudo dnf install -y gcc gcc-c++ clang pkgconf-pkg-config nasm \\" + echo " ffmpeg-devel libva-devel mesa-libGL-devel" + die "install the build dependencies above, then re-run (see docs/build-linux.md)." + fi + command -v clang >/dev/null 2>&1 || warn "clang/libclang not found -- ffmpeg-sys-next bindgen will fail. Install 'clang'." +fi + +mkdir -p "$DIST_DIR" "$TOOLS_DIR" + +# --------------------------------------------------------------------------- +# 1. build the release viewer +# --------------------------------------------------------------------------- +if [ "$SKIP_BUILD" -eq 0 ]; then + step "[1] cargo build --release -p $BIN_NAME --target $TARGET" + # add the target when rustup is in use; a no-op / harmless on a native host. + command -v rustup >/dev/null 2>&1 && rustup target add "$TARGET" >/dev/null 2>&1 || true + ( cd "$ROOT_DIR" && cargo build --release -p "$BIN_NAME" --target "$TARGET" ) +else + step "[1] --skip-build: reusing existing binary" +fi +[ -x "$BIN" ] || die "viewer binary not found: $BIN (remove --skip-build to build it)." +echo " binary : $BIN ($(du -h "$BIN" | cut -f1))" + +# --------------------------------------------------------------------------- +# 2. shared .desktop launcher (used by BOTH the AppImage and the .deb) +# --------------------------------------------------------------------------- +# The .desktop filename and the Icon= key both use the app-id so GNOME/KDE +# associate the running window (winit sets WM_CLASS / Wayland app_id from +# with_app_id("com.akronlogic.netdesk")) with this launcher + its icon. +step "[2] writing $APP_ID.desktop" +cat > "$DESKTOP_SRC" <<'DESKTOP' +[Desktop Entry] +Type=Application +Version=1.0 +Name=NetDesk +GenericName=Remote Desktop +Comment=Secure remote desktop viewer by Akronlogic +Exec=netdesk-viewer +TryExec=netdesk-viewer +Icon=com.akronlogic.netdesk +Terminal=false +Categories=Network;RemoteAccess; +Keywords=remote;desktop;screen;share;vnc;rdp;netdesk; +StartupNotify=true +StartupWMClass=com.akronlogic.netdesk +DESKTOP + +ARTIFACTS=() + +# =========================================================================== +# 3. .deb (built first -- needs no network and no FUSE) +# =========================================================================== +if [ "$DO_DEB" -eq 1 ]; then + step "[3] building .deb" + command -v dpkg-deb >/dev/null 2>&1 || die "dpkg-deb not found (install the 'dpkg' package) -- cannot build the .deb. Use --no-deb to skip." + + DEB_ROOT="$DIST_DIR/deb" + rm -rf "$DEB_ROOT" + + # payload ---------------------------------------------------------------- + install -Dm755 "$BIN" "$DEB_ROOT/usr/bin/$BIN_NAME" + ln -sf "$BIN_NAME" "$DEB_ROOT/usr/bin/netdesk" # convenience alias + install -Dm644 "$DESKTOP_SRC" "$DEB_ROOT/usr/share/applications/$APP_ID.desktop" + install -Dm644 "$ASSETS_DIR/icon-512.png" "$DEB_ROOT/usr/share/icons/hicolor/512x512/apps/$APP_ID.png" + install -Dm644 "$ASSETS_DIR/icon.png" "$DEB_ROOT/usr/share/icons/hicolor/256x256/apps/$APP_ID.png" + install -Dm644 "$ASSETS_DIR/logo.svg" "$DEB_ROOT/usr/share/icons/hicolor/scalable/apps/$APP_ID.svg" + + # copyright -------------------------------------------------------------- + install -d "$DEB_ROOT/usr/share/doc/$PKG_NAME" + cat > "$DEB_ROOT/usr/share/doc/$PKG_NAME/copyright" <<'COPYRIGHT' +NetDesk -- native remote desktop viewer. +Developed by Akronlogic (https://akronlogic.com). + +Copyright (c) Akronlogic. All rights reserved. +License: Proprietary. Full IP ownership, no AGPL. +COPYRIGHT + + # Depends ----------------------------------------------------------------- + # Curated runtime dependency set. NOTE: libGL, libva, libX11/xcb, Wayland and + # libxkbcommon are dlopen()ed at runtime (by glutin/winit and by libavcodec's + # VAAPI backend), so ldd/dpkg-shlibdeps would MISS them -- they are listed + # explicitly here. FFmpeg sonames differ per release, so each libav* uses + # alternatives ending in the generic 'ffmpeg' package as a catch-all. + BASE_DEPS="libc6, libgcc-s1, libstdc++6" + FFMPEG_DEPS="libavcodec60 | libavcodec59 | libavcodec58 | ffmpeg, libavutil58 | libavutil57 | libavutil56 | ffmpeg, libswscale7 | libswscale6 | libswscale5 | ffmpeg" + DLOPEN_DEPS="libgl1, libva2, libva-drm2, libva-x11-2, libxkbcommon0, libx11-6, libxcb1" + DEPENDS="$BASE_DEPS, $FFMPEG_DEPS, $DLOPEN_DEPS" + + INSTALLED_KB="$(du -sk "$DEB_ROOT" | cut -f1)" + + install -d "$DEB_ROOT/DEBIAN" + cat > "$DEB_ROOT/DEBIAN/control" < "$DEB_ROOT/DEBIAN/postinst" <<'POSTINST' +#!/bin/sh +set -e +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database -q /usr/share/applications 2>/dev/null || true +fi +if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor 2>/dev/null || true +fi +exit 0 +POSTINST + + cat > "$DEB_ROOT/DEBIAN/postrm" <<'POSTRM' +#!/bin/sh +set -e +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database -q /usr/share/applications 2>/dev/null || true +fi +if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor 2>/dev/null || true +fi +exit 0 +POSTRM + chmod 0755 "$DEB_ROOT/DEBIAN/postinst" "$DEB_ROOT/DEBIAN/postrm" + + OUT_DEB="$DIST_DIR/${PKG_NAME}_${VERSION}_${DEB_ARCH}.deb" + rm -f "$OUT_DEB" + # --root-owner-group => root:root ownership with no fakeroot (dpkg >= 1.19). + if ! dpkg-deb --build --root-owner-group "$DEB_ROOT" "$OUT_DEB" 2>/dev/null; then + warn "dpkg-deb --root-owner-group unavailable; building without it (files owned by the current uid)." + dpkg-deb --build "$DEB_ROOT" "$OUT_DEB" + fi + [ -f "$OUT_DEB" ] || die "dpkg-deb reported success but $OUT_DEB is missing." + ARTIFACTS+=("$OUT_DEB") + ok " .deb : $OUT_DEB ($(du -h "$OUT_DEB" | cut -f1))" +fi + +# =========================================================================== +# 4. AppImage (via linuxdeploy; bundles libav* etc.) +# =========================================================================== +if [ "$DO_APPIMAGE" -eq 1 ]; then + step "[4] building AppImage" + + # 4a. obtain linuxdeploy (cache under dist/.tools, or set LINUXDEPLOY=/path) + LINUXDEPLOY="${LINUXDEPLOY:-$TOOLS_DIR/linuxdeploy-x86_64.AppImage}" + if [ ! -x "$LINUXDEPLOY" ]; then + url="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage" + echo " fetching linuxdeploy -> $LINUXDEPLOY" + if command -v curl >/dev/null 2>&1; then + curl -fL --retry 3 -o "$LINUXDEPLOY" "$url" || die "failed to download linuxdeploy (set LINUXDEPLOY=/path to a local copy, or --no-appimage)." + elif command -v wget >/dev/null 2>&1; then + wget -q -O "$LINUXDEPLOY" "$url" || die "failed to download linuxdeploy (set LINUXDEPLOY=/path to a local copy, or --no-appimage)." + else + die "need curl or wget to download linuxdeploy. Install one, set LINUXDEPLOY=/path, or pass --no-appimage." + fi + chmod +x "$LINUXDEPLOY" + fi + # run AppImage tools without FUSE (CI / containers / minimal hosts). + export APPIMAGE_EXTRACT_AND_RUN=1 + + # 4b. assemble the AppDir + APPDIR="$DIST_DIR/AppDir" + rm -rf "$APPDIR" + install -Dm755 "$BIN" "$APPDIR/usr/bin/$BIN_NAME" + install -Dm644 "$DESKTOP_SRC" "$APPDIR/usr/share/applications/$APP_ID.desktop" + install -Dm644 "$ASSETS_DIR/icon-512.png" "$APPDIR/usr/share/icons/hicolor/512x512/apps/$APP_ID.png" + install -Dm644 "$ASSETS_DIR/icon.png" "$APPDIR/usr/share/icons/hicolor/256x256/apps/$APP_ID.png" + install -Dm644 "$ASSETS_DIR/logo.svg" "$APPDIR/usr/share/icons/hicolor/scalable/apps/$APP_ID.svg" + # top-level icon + .DirIcon + desktop that appimagetool expects at the root + cp "$ASSETS_DIR/icon-512.png" "$APPDIR/$APP_ID.png" + cp "$DESKTOP_SRC" "$APPDIR/$APP_ID.desktop" + ( cd "$APPDIR" && ln -sf "$APP_ID.png" .DirIcon ) + + # 4c. run linuxdeploy: it ldd-scans usr/bin, copies the app's shared libs + # (libav*, etc.) into the AppDir, writes AppRun, and emits the AppImage. + # libGL / libX11 / Wayland / libva are intentionally NOT bundled (they are + # dlopen()ed and must come from the host graphics stack). + OUT_APPIMAGE="$DIST_DIR/${APP_NAME}-${VERSION}-x86_64.AppImage" + rm -f "$OUT_APPIMAGE" + if ( cd "$DIST_DIR" && OUTPUT="$(basename "$OUT_APPIMAGE")" VERSION="$VERSION" \ + "$LINUXDEPLOY" \ + --appdir "$APPDIR" \ + -e "$APPDIR/usr/bin/$BIN_NAME" \ + -d "$APPDIR/usr/share/applications/$APP_ID.desktop" \ + -i "$APPDIR/usr/share/icons/hicolor/512x512/apps/$APP_ID.png" \ + --icon-filename "$APP_ID" \ + --output appimage ) && [ -f "$OUT_APPIMAGE" ]; then + chmod +x "$OUT_APPIMAGE" + ARTIFACTS+=("$OUT_APPIMAGE") + ok " AppImage: $OUT_APPIMAGE ($(du -h "$OUT_APPIMAGE" | cut -f1))" + else + warn "linuxdeploy did not produce an AppImage (network / FUSE / tool error). The .deb above is unaffected." + fi +fi + +# --------------------------------------------------------------------------- +# summary +# --------------------------------------------------------------------------- +echo +if [ "${#ARTIFACTS[@]}" -gt 0 ]; then + ok "Linux build complete. Artifacts:" + for a in "${ARTIFACTS[@]}"; do echo " $a ($(du -h "$a" | cut -f1))"; done +else + die "no artifacts were produced." +fi diff --git a/native/packaging/build-macos.sh b/native/packaging/build-macos.sh new file mode 100644 index 0000000..ccd5628 --- /dev/null +++ b/native/packaging/build-macos.sh @@ -0,0 +1,364 @@ +#!/usr/bin/env bash +# +# NetDesk by Akronlogic -- macOS DESKTOP build script. +# +# Builds the SAME egui viewer/host-panel binary the Windows editions ship +# (crate `netdesk-viewer`) for macOS, as a signed + notarized universal app: +# +# 1. rustup: add the aarch64-apple-darwin + x86_64-apple-darwin std targets. +# 2. cargo build --release -p netdesk-viewer for BOTH targets. +# 3. lipo the two binaries into one universal (arm64 + x86_64) executable. +# 4. iconutil: turn assets/icon*.png into NetDesk.icns. +# 5. Assemble NetDesk.app (Contents/MacOS + Info.plist + Resources/.icns). +# 6. codesign with a "Developer ID Application" identity (hardened runtime). +# 7. notarize the .app via `xcrun notarytool submit --wait`, then staple it. +# 8. Build a .dmg from the stapled .app, sign + notarize + staple the .dmg. +# +# On macOS the H.264 stream is decoded on the Apple video engine through the +# VideoToolbox path (core/src/decode_vt.rs, VTDecompressionSession); it falls +# back to the portable openh264 software decoder if the hardware path can't +# initialize. Nothing here is Mac-specific beyond packaging -- the viewer code +# is identical to Windows. See docs/build-macos.md. +# +# THIS SCRIPT RUNS ON macOS (the user's M4 Mac), NOT on the Windows dev box. +# All outputs land in packaging/dist/. +# +# Usage (from the repo root or anywhere): +# bash packaging/build-macos.sh --notary-profile netdesk +# bash packaging/build-macos.sh --skip-build # reuse existing targets +# bash packaging/build-macos.sh --no-sign # unsigned .app + .dmg +# bash packaging/build-macos.sh --no-notarize # sign but don't notarize +# bash packaging/build-macos.sh --identity "Developer ID Application: Akronlogic (TEAMID)" \ +# --apple-id you@akronlogic.com --team-id TEAMID +# +# Notarization credentials (pick ONE): +# --notary-profile a keychain profile created once with: +# xcrun notarytool store-credentials --apple-id --team-id --password +# --apple-id --team-id + env NETDESK_NOTARY_PASSWORD= +# +set -euo pipefail + +# --------------------------------------------------------------------------- +# defaults / args +# --------------------------------------------------------------------------- +BUNDLE_ID="com.akronlogic.netdesk" +APP_NAME="NetDesk" +MIN_MACOS="11.0" # Big Sur: oldest OS the universal app targets +CATEGORY="public.app-category.utilities" +COPYRIGHT="© Akronlogic. All rights reserved." + +IDENTITY="" # "Developer ID Application: ... (TEAMID)" (empty = auto-detect) +APPLE_ID="" +TEAM_ID="" +NOTARY_PROFILE="" +ENTITLEMENTS="" # optional path to an entitlements .plist +ICON_SRC="" # optional path to a master PNG (>= 512px, ideally 1024) +VERSION="" # override the Cargo workspace version + +SKIP_BUILD=0 +NO_SIGN=0 +NO_NOTARIZE=0 +STAPLE_APP=1 # also notarize+staple the standalone .app (not just the .dmg) + +while [ $# -gt 0 ]; do + case "$1" in + --identity) IDENTITY="$2"; shift 2 ;; + --apple-id) APPLE_ID="$2"; shift 2 ;; + --team-id) TEAM_ID="$2"; shift 2 ;; + --notary-profile) NOTARY_PROFILE="$2"; shift 2 ;; + --entitlements) ENTITLEMENTS="$2"; shift 2 ;; + --icon) ICON_SRC="$2"; shift 2 ;; + --version) VERSION="$2"; shift 2 ;; + --skip-build) SKIP_BUILD=1; shift ;; + --no-sign) NO_SIGN=1; shift ;; + --no-notarize) NO_NOTARIZE=1; shift ;; + --no-staple-app) STAPLE_APP=0; shift ;; + -h|--help) + sed -n '2,46p' "$0"; exit 0 ;; + *) echo "unknown option: $1" >&2; exit 2 ;; + esac +done + +# --------------------------------------------------------------------------- +# pretty output +# --------------------------------------------------------------------------- +if [ -t 1 ]; then + C_CYAN='\033[36m'; C_YEL='\033[33m'; C_GRN='\033[32m'; C_RED='\033[31m'; C_DIM='\033[2m'; C_OFF='\033[0m' +else + C_CYAN=''; C_YEL=''; C_GRN=''; C_RED=''; C_DIM=''; C_OFF='' +fi +say() { printf "%b%s%b\n" "$C_CYAN" "$*" "$C_OFF"; } +step() { printf "\n%b%s%b\n" "$C_YEL" "$*" "$C_OFF"; } +ok() { printf "%b%s%b\n" "$C_GRN" "$*" "$C_OFF"; } +dim() { printf "%b %s%b\n" "$C_DIM" "$*" "$C_OFF"; } +warn() { printf "%b%s%b\n" "$C_RED" "$*" "$C_OFF"; } +die() { printf "%b%s%b\n" "$C_RED" "$*" "$C_OFF" >&2; exit 1; } + +# --------------------------------------------------------------------------- +# paths +# --------------------------------------------------------------------------- +PKG_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$PKG_DIR/.." && pwd)" +DIST_DIR="$PKG_DIR/dist" +ASSETS_DIR="$ROOT_DIR/assets" + +APP_DIR="$DIST_DIR/$APP_NAME.app" +MACOS_DIR="$APP_DIR/Contents/MacOS" +RES_DIR="$APP_DIR/Contents/Resources" +PLIST="$APP_DIR/Contents/Info.plist" +EXE_OUT="$MACOS_DIR/$APP_NAME" + +TARGET_ARM="aarch64-apple-darwin" +TARGET_X64="x86_64-apple-darwin" +BIN_ARM="$ROOT_DIR/target/$TARGET_ARM/release/netdesk-viewer" +BIN_X64="$ROOT_DIR/target/$TARGET_X64/release/netdesk-viewer" + +DMG_STAGE="$DIST_DIR/dmg-stage" +DMG_OUT="$DIST_DIR/$APP_NAME-macOS-universal.dmg" +APP_ZIP="$DIST_DIR/$APP_NAME-macOS-universal.zip" + +say "=== NetDesk macOS build (Akronlogic) ===" +dim "root : $ROOT_DIR" +dim "dist : $DIST_DIR" + +# --------------------------------------------------------------------------- +# preflight +# --------------------------------------------------------------------------- +[ "$(uname -s)" = "Darwin" ] || die "This script must run on macOS (uname is '$(uname -s)'). Build on the Mac, not the Windows box." + +need() { command -v "$1" >/dev/null 2>&1 || die "required tool not found on PATH: $1"; } +need cargo +need rustup +need lipo +need iconutil +need sips +need hdiutil +need codesign +need xcrun + +xcode-select -p >/dev/null 2>&1 || die "Xcode command-line tools missing. Run: xcode-select --install" + +# version from the Cargo workspace unless overridden +if [ -z "$VERSION" ]; then + VERSION="$(awk -F'"' '/^\[workspace.package\]/{f=1} f&&/^version[[:space:]]*=/{print $2; exit}' "$ROOT_DIR/Cargo.toml")" + [ -n "$VERSION" ] || VERSION="0.0.0" +fi +dim "version : $VERSION" +dim "bundle : $BUNDLE_ID" + +mkdir -p "$DIST_DIR" + +# --------------------------------------------------------------------------- +# 1 + 2. rust targets + release builds (both arches) +# --------------------------------------------------------------------------- +if [ "$SKIP_BUILD" -eq 0 ]; then + step "[1/8] rustup target add ($TARGET_ARM, $TARGET_X64)" + rustup target add "$TARGET_ARM" "$TARGET_X64" + + step "[2/8] cargo build --release -p netdesk-viewer (both arches)" + ( cd "$ROOT_DIR" && cargo build --release -p netdesk-viewer --target "$TARGET_ARM" ) + ( cd "$ROOT_DIR" && cargo build --release -p netdesk-viewer --target "$TARGET_X64" ) +else + step "[1-2/8] --skip-build: reusing existing per-target binaries" +fi +[ -f "$BIN_ARM" ] || die "missing arm64 binary: $BIN_ARM" +[ -f "$BIN_X64" ] || die "missing x86_64 binary: $BIN_X64" + +# --------------------------------------------------------------------------- +# 3. lipo -> universal binary +# --------------------------------------------------------------------------- +step "[3/8] lipo -> universal (arm64 + x86_64)" +rm -rf "$APP_DIR" +mkdir -p "$MACOS_DIR" "$RES_DIR" +lipo -create -output "$EXE_OUT" "$BIN_ARM" "$BIN_X64" +chmod +x "$EXE_OUT" +dim "$(lipo -info "$EXE_OUT")" + +# --------------------------------------------------------------------------- +# 4. icns via iconutil +# --------------------------------------------------------------------------- +step "[4/8] iconutil -> $APP_NAME.icns" +# Prefer the largest master we have: an explicit --icon, else a 1024 PNG if it +# exists, else the 512, else the 256 (assets/icon.png). iconutil needs a full +# .iconset of fixed-named PNGs; sips resizes the master down into each slot. +if [ -z "$ICON_SRC" ]; then + for c in "$ASSETS_DIR/icon-1024.png" "$ASSETS_DIR/icon-512.png" "$ASSETS_DIR/icon.png"; do + if [ -f "$c" ]; then ICON_SRC="$c"; break; fi + done +fi +[ -n "$ICON_SRC" ] && [ -f "$ICON_SRC" ] || die "no icon PNG found (looked for assets/icon-1024.png, icon-512.png, icon.png; or pass --icon)" +dim "icon master: $ICON_SRC" + +ICONSET="$DIST_DIR/$APP_NAME.iconset" +rm -rf "$ICONSET"; mkdir -p "$ICONSET" +# name px +gen() { sips -z "$2" "$2" "$ICON_SRC" --out "$ICONSET/$1" >/dev/null; } +gen icon_16x16.png 16 +gen icon_16x16@2x.png 32 +gen icon_32x32.png 32 +gen icon_32x32@2x.png 64 +gen icon_128x128.png 128 +gen icon_128x128@2x.png 256 +gen icon_256x256.png 256 +gen icon_256x256@2x.png 512 +gen icon_512x512.png 512 +gen icon_512x512@2x.png 1024 +iconutil -c icns "$ICONSET" -o "$RES_DIR/$APP_NAME.icns" +rm -rf "$ICONSET" +dim "wrote $RES_DIR/$APP_NAME.icns" + +# --------------------------------------------------------------------------- +# 5. Info.plist + PkgInfo (assemble the bundle) +# --------------------------------------------------------------------------- +step "[5/8] assemble $APP_NAME.app" +cat > "$PLIST" < + + + + CFBundleName $APP_NAME + CFBundleDisplayName $APP_NAME + CFBundleIdentifier $BUNDLE_ID + CFBundleExecutable $APP_NAME + CFBundleIconFile $APP_NAME + CFBundleVersion $VERSION + CFBundleShortVersionString $VERSION + CFBundlePackageType APPL + CFBundleSignature ???? + CFBundleInfoDictionaryVersion 6.0 + LSMinimumSystemVersion $MIN_MACOS + LSApplicationCategoryType $CATEGORY + NSHighResolutionCapable + NSHumanReadableCopyright $COPYRIGHT + NSSupportsAutomaticGraphicsSwitching + + +PLIST_EOF + +printf 'APPL????' > "$APP_DIR/Contents/PkgInfo" +ok "bundle staged: $APP_DIR" + +# --------------------------------------------------------------------------- +# resolve signing identity +# --------------------------------------------------------------------------- +if [ "$NO_SIGN" -eq 0 ] && [ -z "$IDENTITY" ]; then + # auto-detect a single "Developer ID Application" identity from the keychain + IDENTITY="$(security find-identity -v -p codesigning 2>/dev/null \ + | sed -n 's/.*"\(Developer ID Application: [^"]*\)".*/\1/p' | head -n1 || true)" +fi + +CAN_SIGN=0 +if [ "$NO_SIGN" -eq 0 ] && [ -n "$IDENTITY" ]; then CAN_SIGN=1; fi + +# --------------------------------------------------------------------------- +# 6. codesign (hardened runtime + secure timestamp) +# --------------------------------------------------------------------------- +if [ "$CAN_SIGN" -eq 1 ]; then + step "[6/8] codesign ($IDENTITY)" + CS_ARGS="--force --timestamp --options runtime --sign $IDENTITY" + if [ -n "$ENTITLEMENTS" ]; then + [ -f "$ENTITLEMENTS" ] || die "entitlements file not found: $ENTITLEMENTS" + dim "entitlements: $ENTITLEMENTS" + fi + # single-binary bundle (no nested frameworks/dylibs): sign the executable, + # then the bundle. --options runtime is required for notarization. + if [ -n "$ENTITLEMENTS" ]; then + codesign --force --timestamp --options runtime --entitlements "$ENTITLEMENTS" --sign "$IDENTITY" "$EXE_OUT" + codesign --force --timestamp --options runtime --entitlements "$ENTITLEMENTS" --sign "$IDENTITY" "$APP_DIR" + else + codesign --force --timestamp --options runtime --sign "$IDENTITY" "$EXE_OUT" + codesign --force --timestamp --options runtime --sign "$IDENTITY" "$APP_DIR" + fi + codesign --verify --deep --strict --verbose=2 "$APP_DIR" + dim "signed + verified" +else + step "[6/8] codesign -- SKIPPED" + if [ "$NO_SIGN" -eq 1 ]; then + warn " --no-sign set: the .app and .dmg are UNSIGNED (Gatekeeper will block them on other Macs)." + else + warn " No \"Developer ID Application\" identity found in the keychain -- producing an UNSIGNED bundle." + warn " Install your Developer ID Application cert, or pass --identity \"Developer ID Application: NAME (TEAMID)\"." + fi +fi + +# --------------------------------------------------------------------------- +# notarization helper +# --------------------------------------------------------------------------- +notary_auth_ok=0 +notary_args="" +if [ -n "$NOTARY_PROFILE" ]; then + notary_args="--keychain-profile $NOTARY_PROFILE"; notary_auth_ok=1 +elif [ -n "$APPLE_ID" ] && [ -n "$TEAM_ID" ] && [ -n "${NETDESK_NOTARY_PASSWORD:-}" ]; then + notary_args="--apple-id $APPLE_ID --team-id $TEAM_ID --password $NETDESK_NOTARY_PASSWORD"; notary_auth_ok=1 +fi + +CAN_NOTARIZE=0 +if [ "$CAN_SIGN" -eq 1 ] && [ "$NO_NOTARIZE" -eq 0 ] && [ "$notary_auth_ok" -eq 1 ]; then CAN_NOTARIZE=1; fi + +notarize() { # $1 = path to .zip/.dmg to submit + # shellcheck disable=SC2086 + xcrun notarytool submit "$1" $notary_args --wait +} + +# --------------------------------------------------------------------------- +# 7. notarize + staple the standalone .app (via a zip; stapling needs the app) +# --------------------------------------------------------------------------- +if [ "$CAN_NOTARIZE" -eq 1 ] && [ "$STAPLE_APP" -eq 1 ]; then + step "[7/8] notarize + staple $APP_NAME.app" + rm -f "$APP_ZIP" + /usr/bin/ditto -c -k --keepParent "$APP_DIR" "$APP_ZIP" + notarize "$APP_ZIP" + xcrun stapler staple "$APP_DIR" + xcrun stapler validate "$APP_DIR" + dim "stapled $APP_NAME.app" +else + step "[7/8] notarize + staple .app -- SKIPPED" + if [ "$CAN_SIGN" -eq 0 ]; then + dim "(unsigned bundle cannot be notarized)" + elif [ "$NO_NOTARIZE" -eq 1 ]; then + dim "(--no-notarize set)" + elif [ "$notary_auth_ok" -eq 0 ]; then + warn " No notarization credentials. Pass --notary-profile , or" + warn " --apple-id --team-id with env NETDESK_NOTARY_PASSWORD=." + warn " Create a profile once with: xcrun notarytool store-credentials --apple-id --team-id --password " + fi +fi + +# --------------------------------------------------------------------------- +# 8. build the .dmg, then sign + notarize + staple it +# --------------------------------------------------------------------------- +step "[8/8] build $APP_NAME-macOS-universal.dmg" +rm -rf "$DMG_STAGE"; mkdir -p "$DMG_STAGE" +/usr/bin/ditto "$APP_DIR" "$DMG_STAGE/$APP_NAME.app" +ln -s /Applications "$DMG_STAGE/Applications" +rm -f "$DMG_OUT" +hdiutil create -volname "$APP_NAME" -srcfolder "$DMG_STAGE" -fs HFS+ -format UDZO -ov "$DMG_OUT" >/dev/null +rm -rf "$DMG_STAGE" +dim "wrote $DMG_OUT" + +if [ "$CAN_SIGN" -eq 1 ]; then + codesign --force --timestamp --sign "$IDENTITY" "$DMG_OUT" + dim "signed the .dmg" +fi +if [ "$CAN_NOTARIZE" -eq 1 ]; then + dim "notarizing the .dmg ..." + notarize "$DMG_OUT" + xcrun stapler staple "$DMG_OUT" + xcrun stapler validate "$DMG_OUT" + dim "stapled the .dmg" +fi + +# --------------------------------------------------------------------------- +# summary +# --------------------------------------------------------------------------- +printf "\n" +ok "macOS build complete." +dim "app : $APP_DIR" +[ -f "$DMG_OUT" ] && dim "dmg : $DMG_OUT ($(du -h "$DMG_OUT" | cut -f1))" +if [ "$CAN_SIGN" -eq 0 ]; then + warn "NOTE: artifacts are UNSIGNED -- for distribution, re-run on a Mac with a Developer ID Application cert." +elif [ "$CAN_NOTARIZE" -eq 0 ]; then + warn "NOTE: signed but NOT notarized -- re-run with notarization credentials (see --help) before distributing." +else + ok "Signed, notarized, and stapled -- ready to distribute." +fi diff --git a/native/packaging/build-msi.ps1 b/native/packaging/build-msi.ps1 new file mode 100644 index 0000000..2da3e51 --- /dev/null +++ b/native/packaging/build-msi.ps1 @@ -0,0 +1,73 @@ +<# + NetDesk by Akronlogic -- MSI build script (WiX v4). + + Produces dist\NetDesk.msi for enterprise / unattended deployment (GPO / SCCM / + Intune). The MSI installs the portable payload and runs `NetDesk.exe --config` + to write config.json non-interactively from the deployment properties: + + msiexec /i NetDesk.msi /qn SERVER="wss://relay.corp/ws" REGKEY="CORP1" ` + PASSWORD="Sup3rSecret!" HOSTID="123456789" + + Prerequisite (one time) -- the WiX toolset, which needs the .NET SDK: + winget install Microsoft.DotNet.SDK.8 # or any current .NET SDK + dotnet tool install --global wix + + NOTE: this box has only the .NET RUNTIME (no SDK), so WiX cannot be installed + here and the MSI has not been built/verified in this environment. The + unattended `--config` injection the MSI drives IS proven (viewer + config_cli_tests + a live `NetDesk.exe --config` run). Run this on a build box + that has WiX. + + Usage: + powershell -ExecutionPolicy Bypass -File build-msi.ps1 + powershell -ExecutionPolicy Bypass -File build-msi.ps1 -SkipBuild # reuse staged payload +#> +[CmdletBinding()] +param( + [string]$MingwBin = "C:\mingw-netdesk\mingw64\bin", + [switch]$SkipBuild, + [switch]$NoSign, + [string]$Thumbprint = "YOUR_CODE_SIGNING_CERT_THUMBPRINT" +) +$ErrorActionPreference = "Stop" + +$PkgDir = $PSScriptRoot +$RootDir = Split-Path -Parent $PkgDir +$DistDir = Join-Path $PkgDir "dist" +$StageDir = Join-Path $DistDir "NetDesk-Portable" +$Wxs = Join-Path $PkgDir "netdesk.wxs" +$MsiOut = Join-Path $DistDir "NetDesk.msi" +$SignPs1 = Join-Path $RootDir "certs\sign-release.ps1" + +Write-Host "=== NetDesk MSI build (Akronlogic) ===" -ForegroundColor Cyan + +# 1. stage the signed portable payload (NetDesk.exe + runtime DLLs). +$portableArgs = @{ MingwBin = $MingwBin } +if ($SkipBuild) { $portableArgs.SkipBuild = $true } +& (Join-Path $PkgDir "build-portable.ps1") @portableArgs +if (-not (Test-Path (Join-Path $StageDir "NetDesk.exe"))) { throw "payload missing: $StageDir" } + +# 2. locate WiX. +$wix = Get-Command wix -ErrorAction SilentlyContinue +if (-not $wix) { + throw "WiX toolset not found. Install it (needs the .NET SDK): dotnet tool install --global wix" +} + +# 3. build the MSI, passing the payload dir as the $(Payload) preprocessor var. +New-Item -ItemType Directory -Force -Path $DistDir | Out-Null +& $wix.Source build $Wxs -d "Payload=$StageDir" -arch x64 -o $MsiOut +if ($LASTEXITCODE) { throw "wix build failed (exit $LASTEXITCODE)" } +Write-Host " built: $MsiOut" + +# 4. sign the MSI with the same cert as the portable / installer. +if (-not $NoSign -and (Test-Path $SignPs1)) { + $signtool = Get-Command signtool.exe -ErrorAction SilentlyContinue + if ($signtool) { + & $signtool.Source sign /sha1 $Thumbprint /fd SHA256 /tr "http://timestamp.sectigo.com" /td SHA256 "$MsiOut" + if ($LASTEXITCODE) { Write-Host " signtool returned $LASTEXITCODE (continuing)" -ForegroundColor DarkYellow } + else { Write-Host " signed: $MsiOut" -ForegroundColor Green } + } +} + +Write-Host "`nMSI build complete -> $MsiOut" -ForegroundColor Green +Write-Host "Deploy: msiexec /i NetDesk.msi /qn SERVER=... REGKEY=... PASSWORD=... HOSTID=..." diff --git a/native/packaging/build-portable.ps1 b/native/packaging/build-portable.ps1 new file mode 100644 index 0000000..c4e8e2d --- /dev/null +++ b/native/packaging/build-portable.ps1 @@ -0,0 +1,129 @@ +<# + NetDesk by Akronlogic -- PORTABLE build + staging script. + + Builds the release viewer (cargo build --release -p netdesk-viewer) with the + MinGW GNU toolchain on PATH, then assembles a self-contained portable folder + (NetDesk.exe + NetDeskHost.exe host engine + the 3 MinGW runtime DLLs + + README) and zips it. No install needed -- the user unzips it anywhere and + runs NetDesk.exe from the folder. One app = full host + remote-control viewer. + + Outputs land in packaging\dist\ : + NetDesk-Portable\ (staging folder, also consumed by the installer) + NetDesk-Portable-win64.zip (the portable artifact) + + Usage: + powershell -ExecutionPolicy Bypass -File build-portable.ps1 + powershell -ExecutionPolicy Bypass -File build-portable.ps1 -SkipBuild # reuse existing exe +#> +[CmdletBinding()] +param( + [string]$MingwBin = "C:\mingw-netdesk\mingw64\bin", + [string]$CargoBin = (Join-Path $env:USERPROFILE ".cargo\bin"), + [switch]$SkipBuild +) + +$ErrorActionPreference = "Stop" + +# ---- paths ----------------------------------------------------------------- +$PkgDir = $PSScriptRoot +$RootDir = Split-Path -Parent $PkgDir +$DistDir = Join-Path $PkgDir "dist" +$StageDir = Join-Path $DistDir "NetDesk-Portable" +$ZipPath = Join-Path $DistDir "NetDesk-Portable-win64.zip" +$ExeSrc = Join-Path $RootDir "target\x86_64-pc-windows-gnu\release\netdesk-viewer.exe" + +$Dlls = @("libstdc++-6.dll", "libgcc_s_seh-1.dll", "libwinpthread-1.dll") + +Write-Host "=== NetDesk portable build (Akronlogic) ===" -ForegroundColor Cyan +Write-Host "root : $RootDir" +Write-Host "dist : $DistDir" + +# ---- 1. build -------------------------------------------------------------- +if (-not $SkipBuild) { + if (-not (Test-Path (Join-Path $MingwBin "gcc.exe"))) { + throw "MinGW toolchain not found at '$MingwBin' (no gcc.exe). Pass -MingwBin or install it." + } + $env:PATH = "$MingwBin;$CargoBin;$env:PATH" + Write-Host "`n[1/4] cargo build --release -p netdesk-viewer" -ForegroundColor Yellow + Push-Location $RootDir + try { + & cargo build --release -p netdesk-viewer --target x86_64-pc-windows-gnu + if ($LASTEXITCODE -ne 0) { throw "cargo build failed (exit $LASTEXITCODE)" } + } finally { + Pop-Location + } +} else { + Write-Host "`n[1/4] -SkipBuild set: reusing existing binary" -ForegroundColor Yellow +} + +if (-not (Test-Path $ExeSrc)) { throw "viewer binary not found: $ExeSrc" } + +# ---- 2. assemble staging folder ------------------------------------------- +Write-Host "[2/4] assembling portable folder: $StageDir" -ForegroundColor Yellow +if (Test-Path $StageDir) { Remove-Item $StageDir -Recurse -Force } +New-Item -ItemType Directory -Path $StageDir -Force | Out-Null + +# viewer renamed to the product name +Copy-Item $ExeSrc (Join-Path $StageDir "NetDesk.exe") -Force + +# the 3 MinGW runtime DLLs beside the exe +foreach ($d in $Dlls) { + $src = Join-Path $MingwBin $d + if (-not (Test-Path $src)) { throw "missing runtime DLL: $src" } + Copy-Item $src (Join-Path $StageDir $d) -Force +} + +# NOTE: NetDesk is now ONE pure-Rust binary. Start Service runs the native host +# engine (DXGI capture + hardware H.264 + SendInput) IN-PROCESS, so there is no +# separate NetDeskHost.exe to bundle any more — NetDesk.exe + the 3 runtime DLLs +# are the whole app (host panel + remote-control viewer). + +# ---- 3. README.txt --------------------------------------------------------- +# Built from an array of lines (no here-string: Windows PowerShell 5.1 +# mis-parses here-strings in LF-only script files). Joined with CRLF and +# written BOM-less so Notepad and every editor render it cleanly. +Write-Host "[3/4] writing README.txt" -ForegroundColor Yellow +$readmeLines = @( + 'NetDesk -- Portable Edition (Full Client)', + 'Developed by Akronlogic (https://akronlogic.com)', + '', + 'NetDesk is ONE unified app. It is both the HOST (let someone control this PC)', + 'and the remote-control VIEWER (control another PC) -- a full client, like', + 'TeamViewer. Open it, and the home screen is your host panel; enter a Partner', + 'ID to connect out and it becomes the viewer.', + '', + 'HOW TO RUN', + ' 1. Unzip this folder anywhere you like (Desktop, a USB stick, etc.).', + ' 2. Double-click NetDesk.exe.', + ' There is nothing to install and no admin rights are required. Keep every', + ' file in this folder together -- NetDesk.exe needs the three *.dll files', + ' beside it to run.', + '', + 'WHAT''S IN THIS FOLDER', + ' NetDesk.exe The NetDesk full client (host engine + viewer, all in one).', + ' libstdc++-6.dll MinGW C++ runtime (required).', + ' libgcc_s_seh-1.dll MinGW GCC runtime (required).', + ' libwinpthread-1.dll MinGW threading runtime (required).', + ' README.txt This file.', + '', + 'NOTES', + ' * Connects to the NetDesk server (netdesk.space) using the existing protocol.', + ' * To remove NetDesk, just delete this folder. Nothing is written elsewhere.', + ' * Prefer a Start Menu entry / Desktop shortcut + uninstaller? Use the', + ' installer edition (NetDesk-Setup-win64.exe) instead.', + '', + '(c) Akronlogic. All rights reserved.' +) +$readme = ($readmeLines -join "`r`n") + "`r`n" +[System.IO.File]::WriteAllText((Join-Path $StageDir "README.txt"), $readme, (New-Object System.Text.UTF8Encoding($false))) + +# ---- 4. zip ---------------------------------------------------------------- +Write-Host "[4/4] zipping -> $ZipPath" -ForegroundColor Yellow +if (Test-Path $ZipPath) { Remove-Item $ZipPath -Force } +Compress-Archive -Path (Join-Path $StageDir "*") -DestinationPath $ZipPath -CompressionLevel Optimal + +$zi = Get-Item $ZipPath +Write-Host "`nPortable build complete." -ForegroundColor Green +Write-Host (" zip : {0}" -f $zi.FullName) +Write-Host (" size : {0:N0} bytes ({1:N2} MB)" -f $zi.Length, ($zi.Length/1MB)) +Write-Host (" stage : {0}" -f $StageDir) diff --git a/native/packaging/ios/Podfile b/native/packaging/ios/Podfile new file mode 100644 index 0000000..5932d61 --- /dev/null +++ b/native/packaging/ios/Podfile @@ -0,0 +1,60 @@ +# NetDesk by Akronlogic — iOS Podfile TEMPLATE. +# +# packaging/build-ios.sh installs this as ui/ios/Podfile when `flutter create` +# has not already produced one. It is the stock Flutter iOS Podfile with two +# NetDesk changes: the platform min is uncommented (13.0, to match +# NetDeskCore.xcframework) and the netdesk_core pod is linked into Runner. +# +# If a Podfile already exists, build-ios.sh leaves it in place and only injects +# the `pod 'netdesk_core'` line (and uncomments the platform) rather than +# overwriting it. + +platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + + # NetDesk Rust core — vendored static xcframework assembled by + # packaging/build-ios.sh. Provides VideoToolbox H.264 decode, E2E crypto, + # signaling and transport; the Flutter UI binds to it in Phase 3. + pod 'netdesk_core', :path => '.' + + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/native/packaging/ios/netdesk_core.podspec b/native/packaging/ios/netdesk_core.podspec new file mode 100644 index 0000000..8a4ad48 --- /dev/null +++ b/native/packaging/ios/netdesk_core.podspec @@ -0,0 +1,65 @@ +# +# NetDesk by Akronlogic — CocoaPods spec that links the compiled Rust core +# (NetDeskCore.xcframework) into the Flutter iOS Runner. +# +# This file is a TEMPLATE kept under version control in packaging/ios/. +# packaging/build-ios.sh copies it to ui/ios/netdesk_core.podspec next to the +# freshly-assembled NetDeskCore.xcframework, then adds +# pod 'netdesk_core', :path => '.' +# to ui/ios/Podfile so `flutter build ipa` links it automatically. +# +# The xcframework is a *static* library (libnetdesk_core.a, one slice per +# platform: ios-arm64 device + ios-arm64-simulator). CocoaPods auto-selects the +# right slice per build, so there are no hardcoded slice paths here. +# +Pod::Spec.new do |s| + s.name = 'netdesk_core' + s.version = '0.1.0' + s.summary = 'NetDesk native Rust core (static) for iOS — Akronlogic.' + s.description = <<-DESC + The compiled NetDesk engine: signaling, end-to-end crypto, the media + transport and VideoToolbox H.264 hardware decode (core/src/decode_vt.rs). + Vendored as a static xcframework; the Flutter UI binds to it over dart:ffi / + flutter_rust_bridge in Phase 3. + DESC + s.homepage = 'https://akronlogic.com' + s.license = { :type => 'Proprietary', :text => 'Copyright Akronlogic. All rights reserved.' } + s.author = { 'Akronlogic' => 'https://akronlogic.com' } + s.platform = :ios, '13.0' + s.source = { :path => '.' } + + # The Rust core, assembled by packaging/build-ios.sh (xcodebuild -create-xcframework). + s.vendored_frameworks = 'NetDeskCore.xcframework' + + # System frameworks the VideoToolbox decode path links with + # #[link(kind = "framework")] in core/src/decode_vt.rs. A static Rust lib + # records these in its metadata but does NOT pull them in at the final app + # link, so they must be named here. + # CoreMedia — CMVideoFormatDescription / CMSampleBuffer / CMBlockBuffer + # VideoToolbox — VTDecompressionSession* + # CoreVideo — CVPixelBuffer* + # CoreFoundation — CFDictionary/CFString/CFRelease (core-foundation-sys) + s.frameworks = 'VideoToolbox', 'CoreMedia', 'CoreVideo', 'CoreFoundation' + + # Vendored static xcframework, no public headers yet (the C ABI header is + # generated with cbindgen when Phase 3's core/src/ffi.rs lands — see + # docs/build-ios.md). CocoaPods links the archive normally; once the Phase-3 + # Dart bindings reference the symbols they are retained automatically. + # + # Before those bindings exist, nothing references the archive, so the linker + # may drop it. That is harmless for the build. If you want to *prove* the + # archive links end-to-end before Phase 3, force it in by uncommenting: + # + # s.user_target_xcconfig = { + # 'OTHER_LDFLAGS' => '-force_load "${PODS_XCFRAMEWORKS_BUILD_DIR}/netdesk_core/libnetdesk_core.a"' + # } + # + # (Do not combine -force_load with Phase-3 symbol references, or you may hit + # duplicate-symbol errors — pick one.) + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + # Keep the Rust/FFI symbols discoverable for dart:ffi lookups. + 'STRIP_STYLE' => 'non-global', + } +end diff --git a/native/packaging/netdesk.ico b/native/packaging/netdesk.ico new file mode 100644 index 0000000..1efc53b Binary files /dev/null and b/native/packaging/netdesk.ico differ diff --git a/native/packaging/netdesk.nsi b/native/packaging/netdesk.nsi new file mode 100644 index 0000000..4ff9659 --- /dev/null +++ b/native/packaging/netdesk.nsi @@ -0,0 +1,218 @@ +; ============================================================================ +; NetDesk by Akronlogic -- Windows installer (NSIS / Modern UI 2) +; +; Installs NetDesk.exe + the 3 MinGW runtime DLLs into %ProgramFiles%\NetDesk, +; creates Start Menu + Desktop shortcuts, registers an uninstaller (Add/Remove +; Programs), and offers an OPTIONAL per-user autostart (HKCU Run). +; +; Build (normally driven by build-installer.ps1): +; makensis netdesk.nsi +; makensis /DSTAGEDIR=dist\NetDesk-Portable /DOUTFILE=dist\NetDesk-Setup-win64.exe netdesk.nsi +; ============================================================================ + +Unicode true + +;---------------------------------------------------------------------------- +; Product metadata +;---------------------------------------------------------------------------- +!define PRODUCT "NetDesk" +!define COMPANY "Akronlogic" +!define VERSION "0.1.0" +!define URL "https://akronlogic.com" +!define EXE "NetDesk.exe" +!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT}" +!define AUTORUN_KEY "Software\Microsoft\Windows\CurrentVersion\Run" + +; Staging folder that holds the files to install (produced by build-portable.ps1). +; Overridable from the command line with /DSTAGEDIR=... +!ifndef STAGEDIR + !define STAGEDIR "dist\NetDesk-Portable" +!endif +!ifndef OUTFILE + !define OUTFILE "dist\NetDesk-Setup-win64.exe" +!endif + +;---------------------------------------------------------------------------- +; General +;---------------------------------------------------------------------------- +Name "${PRODUCT} by ${COMPANY}" +OutFile "${OUTFILE}" +InstallDir "$PROGRAMFILES64\${PRODUCT}" +InstallDirRegKey HKLM "Software\${PRODUCT}" "InstallDir" +RequestExecutionLevel admin +SetCompressor /SOLID lzma +BrandingText "${PRODUCT} by ${COMPANY} -- ${URL}" + +; Version info stamped on the setup .exe +VIProductVersion "${VERSION}.0" +VIAddVersionKey "ProductName" "${PRODUCT}" +VIAddVersionKey "CompanyName" "${COMPANY}" +VIAddVersionKey "FileDescription" "${PRODUCT} Setup" +VIAddVersionKey "FileVersion" "${VERSION}.0" +VIAddVersionKey "ProductVersion" "${VERSION}.0" +VIAddVersionKey "LegalCopyright" "(c) ${COMPANY}. All rights reserved." + +;---------------------------------------------------------------------------- +; Modern UI 2 +;---------------------------------------------------------------------------- +!include "MUI2.nsh" +!include "FileFunc.nsh" +!insertmacro GetSize + +!define MUI_ABORTWARNING +; Branded NetDesk icon for the setup .exe and the uninstaller (so Windows Search, +; Explorer and the installer window all show the green NetDesk mark -- not the +; generic NSIS rainbow). Overridable; build-installer.ps1 passes an absolute path +; to packaging\netdesk.ico (copied from assets\icon.ico at build time). +!ifndef BRANDICON + !define BRANDICON "netdesk.ico" +!endif +!define MUI_ICON "${BRANDICON}" +!define MUI_UNICON "${BRANDICON}" + +; Offer to launch NetDesk at the end of setup +!define MUI_FINISHPAGE_RUN "$INSTDIR\${EXE}" +!define MUI_FINISHPAGE_RUN_TEXT "Launch ${PRODUCT} now" + +; Install pages +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_COMPONENTS +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH + +; Uninstall pages +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES + +!insertmacro MUI_LANGUAGE "English" + +;---------------------------------------------------------------------------- +; Clean (re)install: a running NetDesk locks its own files, so close it first. +; Reinstalling then overwrites the existing install in place (same directory), +; upgrading an older build to this one. Full removal is via the uninstaller. +;---------------------------------------------------------------------------- +Function .onInit + ExecWait 'taskkill /F /IM ${EXE} /T' +FunctionEnd + +;---------------------------------------------------------------------------- +; Sections +;---------------------------------------------------------------------------- +Section "!${PRODUCT} (required)" SEC_CORE + SectionIn RO + SetOverwrite on + SetOutPath "$INSTDIR" + + ; --- Clean (re)install: stop a running host so its binaries unlock ---------- + ; NetDeskHost.exe is kept alive two ways: a per-user engine/GUI child, AND the + ; privileged boot-time Scheduled Task "NetDesk" (the host's SERVICE_TASK, + ; created by `NetDeskHost.exe --install`, running as SYSTEM) whose broker + ; respawns a session-agent ~every 2s. This installer is elevated + ; (RequestExecutionLevel admin), so END THE TASK FIRST (stops the respawner), + ; then force-kill every NetDeskHost.exe tree and the GUI. Errors are ignored + ; (nothing running on a fresh install is normal). We only /End the task here + ; (transient) -- never /Disable -- and /Run it again at the end of this + ; section, so unattended autostart is preserved. + nsExec::Exec 'schtasks /End /TN "NetDesk"' + Pop $0 + nsExec::Exec 'taskkill /F /IM NetDeskHost.exe /T' + Pop $0 + nsExec::Exec 'taskkill /F /IM ${EXE} /T' + Pop $0 + + ; Program + the 3 MinGW runtime DLLs that must sit beside it + File "${STAGEDIR}\${EXE}" + File "${STAGEDIR}\libstdc++-6.dll" + File "${STAGEDIR}\libgcc_s_seh-1.dll" + File "${STAGEDIR}\libwinpthread-1.dll" + File "${STAGEDIR}\README.txt" + ; NetDesk is one pure-Rust binary now: the native host engine runs in-process, + ; so there is no separate NetDeskHost.exe to install. + + ; Remember where we installed + WriteRegStr HKLM "Software\${PRODUCT}" "InstallDir" "$INSTDIR" + WriteRegStr HKLM "Software\${PRODUCT}" "Version" "${VERSION}" + + ; Start Menu shortcut + CreateDirectory "$SMPROGRAMS\${PRODUCT}" + CreateShortCut "$SMPROGRAMS\${PRODUCT}\${PRODUCT}.lnk" "$INSTDIR\${EXE}" "" "$INSTDIR\${EXE}" 0 + CreateShortCut "$SMPROGRAMS\${PRODUCT}\Uninstall ${PRODUCT}.lnk" "$INSTDIR\uninstall.exe" + + ; Uninstaller + Add/Remove Programs registration + WriteUninstaller "$INSTDIR\uninstall.exe" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${PRODUCT} by ${COMPANY}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${VERSION}" + WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${COMPANY}" + WriteRegStr HKLM "${UNINST_KEY}" "URLInfoAbout" "${URL}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${EXE}" + WriteRegStr HKLM "${UNINST_KEY}" "InstallLocation" "$INSTDIR" + WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" '"$INSTDIR\uninstall.exe"' + WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" '"$INSTDIR\uninstall.exe" /S' + WriteRegDWORD HKLM "${UNINST_KEY}" "NoModify" 1 + WriteRegDWORD HKLM "${UNINST_KEY}" "NoRepair" 1 + + ; Estimated size for Add/Remove Programs + ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 + IntFmt $0 "0x%08X" $0 + WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0" + + ; --- Bring the host engine back online ------------------------------------ + ; A fresh host `--install` finishes with `schtasks /Run /TN NetDesk`, so do the + ; same: relaunch the now-updated boot-time broker. No-op if there is no task + ; yet (first-time install, before the user enables unattended access); the GUI + ; itself is relaunched by the Finish page's optional "Launch NetDesk now". + nsExec::Exec 'schtasks /Run /TN "NetDesk"' + Pop $0 +SectionEnd + +Section "Desktop shortcut" SEC_DESKTOP + CreateShortCut "$DESKTOP\${PRODUCT}.lnk" "$INSTDIR\${EXE}" "" "$INSTDIR\${EXE}" 0 +SectionEnd + +Section /o "Start ${PRODUCT} when I sign in" SEC_AUTOSTART + ; Per-user autostart (HKCU). Optional, OFF by default. Starts the native host + ; engine headlessly on login so the PC is reachable unattended. + WriteRegStr HKCU "${AUTORUN_KEY}" "${PRODUCT}" '"$INSTDIR\${EXE}" --host' +SectionEnd + +;---------------------------------------------------------------------------- +; Component descriptions +;---------------------------------------------------------------------------- +!insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN + !insertmacro MUI_DESCRIPTION_TEXT ${SEC_CORE} "The ${PRODUCT} application and its required runtime files." + !insertmacro MUI_DESCRIPTION_TEXT ${SEC_DESKTOP} "Place a ${PRODUCT} shortcut on your Desktop." + !insertmacro MUI_DESCRIPTION_TEXT ${SEC_AUTOSTART} "Launch ${PRODUCT} automatically every time you sign in (current user only)." +!insertmacro MUI_FUNCTION_DESCRIPTION_END + +;---------------------------------------------------------------------------- +; Uninstaller +;---------------------------------------------------------------------------- +Section "Uninstall" + ; Stop the host completely: end the SYSTEM task (stops the broker's respawner), + ; kill the engine and the GUI, then REMOVE the autostart task entirely so no + ; orphaned boot-time task is left pointing at a deleted exe. + nsExec::Exec 'schtasks /End /TN "NetDesk"' + Pop $0 + ExecWait 'taskkill /F /IM NetDeskHost.exe /T' + ExecWait 'taskkill /F /IM ${EXE} /T' + nsExec::Exec 'schtasks /Delete /TN "NetDesk" /F' + Pop $0 + Delete "$INSTDIR\${EXE}" + Delete "$INSTDIR\NetDeskHost.exe" + Delete "$INSTDIR\libstdc++-6.dll" + Delete "$INSTDIR\libgcc_s_seh-1.dll" + Delete "$INSTDIR\libwinpthread-1.dll" + Delete "$INSTDIR\README.txt" + Delete "$INSTDIR\uninstall.exe" + RMDir "$INSTDIR" + + Delete "$SMPROGRAMS\${PRODUCT}\${PRODUCT}.lnk" + Delete "$SMPROGRAMS\${PRODUCT}\Uninstall ${PRODUCT}.lnk" + RMDir "$SMPROGRAMS\${PRODUCT}" + Delete "$DESKTOP\${PRODUCT}.lnk" + + DeleteRegValue HKCU "${AUTORUN_KEY}" "${PRODUCT}" + DeleteRegKey HKLM "${UNINST_KEY}" + DeleteRegKey HKLM "Software\${PRODUCT}" +SectionEnd diff --git a/native/packaging/netdesk.wxs b/native/packaging/netdesk.wxs new file mode 100644 index 0000000..6aa0996 --- /dev/null +++ b/native/packaging/netdesk.wxs @@ -0,0 +1,81 @@ + + + + + + + + + + + + + diff --git a/native/third_party/ucrt64/include/opus/opus.h b/native/third_party/ucrt64/include/opus/opus.h new file mode 100644 index 0000000..c053736 --- /dev/null +++ b/native/third_party/ucrt64/include/opus/opus.h @@ -0,0 +1,1175 @@ +/* Copyright (c) 2010-2011 Xiph.Org Foundation, Skype Limited + Written by Jean-Marc Valin and Koen Vos */ +/* + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + - Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + - Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER + OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +/** + * @file opus.h + * @brief Opus reference implementation API + */ + +#ifndef OPUS_H +#define OPUS_H + +#include "opus_types.h" +#include "opus_defines.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @mainpage Opus + * + * The Opus codec is designed for interactive speech and audio transmission over the Internet. + * It is designed by the IETF Codec Working Group and incorporates technology from + * Skype's SILK codec and Xiph.Org's CELT codec. + * + * The Opus codec is designed to handle a wide range of interactive audio applications, + * including Voice over IP, videoconferencing, in-game chat, and even remote live music + * performances. It can scale from low bit-rate narrowband speech to very high quality + * stereo music. Its main features are: + + * @li Sampling rates from 8 to 48 kHz + * @li Bit-rates from 6 kb/s to 510 kb/s + * @li Support for both constant bit-rate (CBR) and variable bit-rate (VBR) + * @li Audio bandwidth from narrowband to full-band + * @li Support for speech and music + * @li Support for mono and stereo + * @li Support for multichannel (up to 255 channels) + * @li Frame sizes from 2.5 ms to 60 ms + * @li Good loss robustness and packet loss concealment (PLC) + * @li Floating point and fixed-point implementation + * + * Documentation sections: + * @li @ref opus_encoder + * @li @ref opus_decoder + * @li @ref opus_repacketizer + * @li @ref opus_multistream + * @li @ref opus_libinfo + * @li @ref opus_custom + */ + +/** @defgroup opus_encoder Opus Encoder + * @{ + * + * @brief This page describes the process and functions used to encode Opus. + * + * Since Opus is a stateful codec, the encoding process starts with creating an encoder + * state. This can be done with: + * + * @code + * int error; + * OpusEncoder *enc; + * enc = opus_encoder_create(Fs, channels, application, &error); + * @endcode + * + * From this point, @c enc can be used for encoding an audio stream. An encoder state + * @b must @b not be used for more than one stream at the same time. Similarly, the encoder + * state @b must @b not be re-initialized for each frame. + * + * While opus_encoder_create() allocates memory for the state, it's also possible + * to initialize pre-allocated memory: + * + * @code + * int size; + * int error; + * OpusEncoder *enc; + * size = opus_encoder_get_size(channels); + * enc = malloc(size); + * error = opus_encoder_init(enc, Fs, channels, application); + * @endcode + * + * where opus_encoder_get_size() returns the required size for the encoder state. Note that + * future versions of this code may change the size, so no assumptions should be made about it. + * + * The encoder state is always continuous in memory and only a shallow copy is sufficient + * to copy it (e.g. memcpy()) + * + * It is possible to change some of the encoder's settings using the opus_encoder_ctl() + * interface. All these settings already default to the recommended value, so they should + * only be changed when necessary. The most common settings one may want to change are: + * + * @code + * opus_encoder_ctl(enc, OPUS_SET_BITRATE(bitrate)); + * opus_encoder_ctl(enc, OPUS_SET_COMPLEXITY(complexity)); + * opus_encoder_ctl(enc, OPUS_SET_SIGNAL(signal_type)); + * @endcode + * + * where + * + * @arg bitrate is in bits per second (b/s) + * @arg complexity is a value from 1 to 10, where 1 is the lowest complexity and 10 is the highest + * @arg signal_type is either OPUS_AUTO (default), OPUS_SIGNAL_VOICE, or OPUS_SIGNAL_MUSIC + * + * See @ref opus_encoderctls and @ref opus_genericctls for a complete list of parameters that can be set or queried. Most parameters can be set or changed at any time during a stream. + * + * To encode a frame, opus_encode() or opus_encode_float() must be called with exactly one frame (2.5, 5, 10, 20, 40 or 60 ms) of audio data: + * @code + * len = opus_encode(enc, audio_frame, frame_size, packet, max_packet); + * @endcode + * + * where + *
    + *
  • audio_frame is the audio data in opus_int16 (or float for opus_encode_float())
  • + *
  • frame_size is the duration of the frame in samples (per channel)
  • + *
  • packet is the byte array to which the compressed data is written
  • + *
  • max_packet is the maximum number of bytes that can be written in the packet (4000 bytes is recommended). + * Do not use max_packet to control VBR target bitrate, instead use the #OPUS_SET_BITRATE CTL.
  • + *
+ * + * opus_encode() and opus_encode_float() return the number of bytes actually written to the packet. + * The return value can be negative, which indicates that an error has occurred. If the return value + * is 2 bytes or less, then the packet does not need to be transmitted (DTX). + * + * Once the encoder state if no longer needed, it can be destroyed with + * + * @code + * opus_encoder_destroy(enc); + * @endcode + * + * If the encoder was created with opus_encoder_init() rather than opus_encoder_create(), + * then no action is required aside from potentially freeing the memory that was manually + * allocated for it (calling free(enc) for the example above) + * + */ + +/** Opus encoder state. + * This contains the complete state of an Opus encoder. + * It is position independent and can be freely copied. + * @see opus_encoder_create,opus_encoder_init + */ +typedef struct OpusEncoder OpusEncoder; + +/** Gets the size of an OpusEncoder structure. + * @param[in] channels int: Number of channels. + * This must be 1 or 2. + * @returns The size in bytes. + * @note Since this function does not take the application as input, it will overestimate + * the size required for OPUS_APPLICATION_RESTRICTED_SILK and OPUS_APPLICATION_RESTRICTED_CELT. + * That is generally not a problem, except when trying to know the size to use for a copy. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_encoder_get_size(int channels); + +/** + */ + +/** Allocates and initializes an encoder state. + * There are three coding modes: + * + * @ref OPUS_APPLICATION_VOIP gives best quality at a given bitrate for voice + * signals. It enhances the input signal by high-pass filtering and + * emphasizing formants and harmonics. Optionally it includes in-band + * forward error correction to protect against packet loss. Use this + * mode for typical VoIP applications. Because of the enhancement, + * even at high bitrates the output may sound different from the input. + * + * @ref OPUS_APPLICATION_AUDIO gives best quality at a given bitrate for most + * non-voice signals like music. Use this mode for music and mixed + * (music/voice) content, broadcast, and applications requiring less + * than 15 ms of coding delay. + * + * @ref OPUS_APPLICATION_RESTRICTED_LOWDELAY configures low-delay mode that + * disables the speech-optimized mode in exchange for slightly reduced delay. + * This mode can only be set on an newly initialized or freshly reset encoder + * because it changes the codec delay. + * + * This is useful when the caller knows that the speech-optimized modes will not be needed (use with caution). + * @param [in] Fs opus_int32: Sampling rate of input signal (Hz) + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param [in] channels int: Number of channels (1 or 2) in input signal + * @param [in] application int: Coding mode (one of @ref OPUS_APPLICATION_VOIP, @ref OPUS_APPLICATION_AUDIO, or @ref OPUS_APPLICATION_RESTRICTED_LOWDELAY) + * @param [out] error int*: @ref opus_errorcodes + * @note Regardless of the sampling rate and number channels selected, the Opus encoder + * can switch to a lower audio bandwidth or number of channels if the bitrate + * selected is too low. This also means that it is safe to always use 48 kHz stereo input + * and let the encoder optimize the encoding. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusEncoder *opus_encoder_create( + opus_int32 Fs, + int channels, + int application, + int *error +); + +/** Initializes a previously allocated encoder state + * The memory pointed to by st must be at least the size returned by opus_encoder_get_size(). + * This is intended for applications which use their own allocator instead of malloc. + * @see opus_encoder_create(),opus_encoder_get_size() + * To reset a previously initialized state, use the #OPUS_RESET_STATE CTL. + * @param [in] st OpusEncoder*: Encoder state + * @param [in] Fs opus_int32: Sampling rate of input signal (Hz) + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param [in] channels int: Number of channels (1 or 2) in input signal + * @param [in] application int: Coding mode (one of OPUS_APPLICATION_VOIP, OPUS_APPLICATION_AUDIO, or OPUS_APPLICATION_RESTRICTED_LOWDELAY) + * @retval #OPUS_OK Success or @ref opus_errorcodes + */ +OPUS_EXPORT int opus_encoder_init( + OpusEncoder *st, + opus_int32 Fs, + int channels, + int application +) OPUS_ARG_NONNULL(1); + +/** Encodes an Opus frame. + * @param [in] st OpusEncoder*: Encoder state + * @param [in] pcm opus_int16*: Input signal (interleaved if 2 channels). length is frame_size*channels*sizeof(opus_int16) + * @param [in] frame_size int: Number of samples per channel in the + * input signal. + * This must be an Opus frame size for + * the encoder's sampling rate. + * For example, at 48 kHz the permitted + * values are 120, 240, 480, 960, 1920, + * and 2880. + * Passing in a duration of less than + * 10 ms (480 samples at 48 kHz) will + * prevent the encoder from using the LPC + * or hybrid modes. + * @param [out] data unsigned char*: Output payload. + * This must contain storage for at + * least \a max_data_bytes. + * @param [in] max_data_bytes opus_int32: Size of the allocated + * memory for the output + * payload. This may be + * used to impose an upper limit on + * the instant bitrate, but should + * not be used as the only bitrate + * control. Use #OPUS_SET_BITRATE to + * control the bitrate. + * @returns The length of the encoded packet (in bytes) on success or a + * negative error code (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_encode( + OpusEncoder *st, + const opus_int16 *pcm, + int frame_size, + unsigned char *data, + opus_int32 max_data_bytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + +/** Encodes an Opus frame. + * @param [in] st OpusEncoder*: Encoder state + * @param [in] pcm opus_int32*: Input signal (interleaved if 2 channels) representing (or slightly exceeding) 24-bit values. length is frame_size*channels*sizeof(opus_int32) + * @param [in] frame_size int: Number of samples per channel in the + * input signal. + * This must be an Opus frame size for + * the encoder's sampling rate. + * For example, at 48 kHz the permitted + * values are 120, 240, 480, 960, 1920, + * and 2880. + * Passing in a duration of less than + * 10 ms (480 samples at 48 kHz) will + * prevent the encoder from using the LPC + * or hybrid modes. + * @param [out] data unsigned char*: Output payload. + * This must contain storage for at + * least \a max_data_bytes. + * @param [in] max_data_bytes opus_int32: Size of the allocated + * memory for the output + * payload. This may be + * used to impose an upper limit on + * the instant bitrate, but should + * not be used as the only bitrate + * control. Use #OPUS_SET_BITRATE to + * control the bitrate. + * @returns The length of the encoded packet (in bytes) on success or a + * negative error code (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_encode24( + OpusEncoder *st, + const opus_int32 *pcm, + int frame_size, + unsigned char *data, + opus_int32 max_data_bytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + +/** Encodes an Opus frame from floating point input. + * @param [in] st OpusEncoder*: Encoder state + * @param [in] pcm float*: Input in float format (interleaved if 2 channels), with a normal range of +/-1.0. + * Samples with a range beyond +/-1.0 are supported but will + * be clipped by decoders using the integer API and should + * only be used if it is known that the far end supports + * extended dynamic range. + * length is frame_size*channels*sizeof(float) + * @param [in] frame_size int: Number of samples per channel in the + * input signal. + * This must be an Opus frame size for + * the encoder's sampling rate. + * For example, at 48 kHz the permitted + * values are 120, 240, 480, 960, 1920, + * and 2880. + * Passing in a duration of less than + * 10 ms (480 samples at 48 kHz) will + * prevent the encoder from using the LPC + * or hybrid modes. + * @param [out] data unsigned char*: Output payload. + * This must contain storage for at + * least \a max_data_bytes. + * @param [in] max_data_bytes opus_int32: Size of the allocated + * memory for the output + * payload. This may be + * used to impose an upper limit on + * the instant bitrate, but should + * not be used as the only bitrate + * control. Use #OPUS_SET_BITRATE to + * control the bitrate. + * @returns The length of the encoded packet (in bytes) on success or a + * negative error code (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_encode_float( + OpusEncoder *st, + const float *pcm, + int frame_size, + unsigned char *data, + opus_int32 max_data_bytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + +/** Frees an OpusEncoder allocated by opus_encoder_create(). + * @param[in] st OpusEncoder*: State to be freed. + */ +OPUS_EXPORT void opus_encoder_destroy(OpusEncoder *st); + +/** Perform a CTL function on an Opus encoder. + * + * Generally the request and subsequent arguments are generated + * by a convenience macro. + * @param st OpusEncoder*: Encoder state. + * @param request This and all remaining parameters should be replaced by one + * of the convenience macros in @ref opus_genericctls or + * @ref opus_encoderctls. + * @see opus_genericctls + * @see opus_encoderctls + */ +OPUS_EXPORT int opus_encoder_ctl(OpusEncoder *st, int request, ...) OPUS_ARG_NONNULL(1); +/**@}*/ + +/** @defgroup opus_decoder Opus Decoder + * @{ + * + * @brief This page describes the process and functions used to decode Opus. + * + * The decoding process also starts with creating a decoder + * state. This can be done with: + * @code + * int error; + * OpusDecoder *dec; + * dec = opus_decoder_create(Fs, channels, &error); + * @endcode + * where + * @li Fs is the sampling rate and must be 8000, 12000, 16000, 24000, or 48000 + * @li channels is the number of channels (1 or 2) + * @li error will hold the error code in case of failure (or #OPUS_OK on success) + * @li the return value is a newly created decoder state to be used for decoding + * + * While opus_decoder_create() allocates memory for the state, it's also possible + * to initialize pre-allocated memory: + * @code + * int size; + * int error; + * OpusDecoder *dec; + * size = opus_decoder_get_size(channels); + * dec = malloc(size); + * error = opus_decoder_init(dec, Fs, channels); + * @endcode + * where opus_decoder_get_size() returns the required size for the decoder state. Note that + * future versions of this code may change the size, so no assumptions should be made about it. + * + * The decoder state is always continuous in memory and only a shallow copy is sufficient + * to copy it (e.g. memcpy()) + * + * To decode a frame, opus_decode() or opus_decode_float() must be called with a packet of compressed audio data: + * @code + * frame_size = opus_decode(dec, packet, len, decoded, max_size, 0); + * @endcode + * where + * + * @li packet is the byte array containing the compressed data + * @li len is the exact number of bytes contained in the packet + * @li decoded is the decoded audio data in opus_int16 (or float for opus_decode_float()) + * @li max_size is the max duration of the frame in samples (per channel) that can fit into the decoded_frame array + * + * opus_decode() and opus_decode_float() return the number of samples (per channel) decoded from the packet. + * If that value is negative, then an error has occurred. This can occur if the packet is corrupted or if the audio + * buffer is too small to hold the decoded audio. + * + * Opus is a stateful codec with overlapping blocks and as a result Opus + * packets are not coded independently of each other. Packets must be + * passed into the decoder serially and in the correct order for a correct + * decode. Lost packets can be replaced with loss concealment by calling + * the decoder with a null pointer and zero length for the missing packet. + * + * A single codec state may only be accessed from a single thread at + * a time and any required locking must be performed by the caller. Separate + * streams must be decoded with separate decoder states and can be decoded + * in parallel unless the library was compiled with NONTHREADSAFE_PSEUDOSTACK + * defined. + * + */ + +/** Opus decoder state. + * This contains the complete state of an Opus decoder. + * It is position independent and can be freely copied. + * @see opus_decoder_create,opus_decoder_init + */ +typedef struct OpusDecoder OpusDecoder; + +/** Opus DRED decoder. + * This contains the complete state of an Opus DRED decoder. + * It is position independent and can be freely copied. + * @see opus_dred_decoder_create,opus_dred_decoder_init + */ +typedef struct OpusDREDDecoder OpusDREDDecoder; + + +/** Opus DRED state. + * This contains the complete state of an Opus DRED packet. + * It is position independent and can be freely copied. + * @see opus_dred_create,opus_dred_init + */ +typedef struct OpusDRED OpusDRED; + +/** Gets the size of an OpusDecoder structure. + * @param [in] channels int: Number of channels. + * This must be 1 or 2. + * @returns The size in bytes. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_decoder_get_size(int channels); + +/** Allocates and initializes a decoder state. + * @param [in] Fs opus_int32: Sample rate to decode at (Hz). + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param [in] channels int: Number of channels (1 or 2) to decode + * @param [out] error int*: #OPUS_OK Success or @ref opus_errorcodes + * + * Internally Opus stores data at 48000 Hz, so that should be the default + * value for Fs. However, the decoder can efficiently decode to buffers + * at 8, 12, 16, and 24 kHz so if for some reason the caller cannot use + * data at the full sample rate, or knows the compressed data doesn't + * use the full frequency range, it can request decoding at a reduced + * rate. Likewise, the decoder is capable of filling in either mono or + * interleaved stereo pcm buffers, at the caller's request. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusDecoder *opus_decoder_create( + opus_int32 Fs, + int channels, + int *error +); + +/** Initializes a previously allocated decoder state. + * The state must be at least the size returned by opus_decoder_get_size(). + * This is intended for applications which use their own allocator instead of malloc. @see opus_decoder_create,opus_decoder_get_size + * To reset a previously initialized state, use the #OPUS_RESET_STATE CTL. + * @param [in] st OpusDecoder*: Decoder state. + * @param [in] Fs opus_int32: Sampling rate to decode to (Hz). + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param [in] channels int: Number of channels (1 or 2) to decode + * @retval #OPUS_OK Success or @ref opus_errorcodes + */ +OPUS_EXPORT int opus_decoder_init( + OpusDecoder *st, + opus_int32 Fs, + int channels +) OPUS_ARG_NONNULL(1); + +/** Decode an Opus packet. + * @param [in] st OpusDecoder*: Decoder state + * @param [in] data char*: Input payload. Use a NULL pointer to indicate packet loss + * @param [in] len opus_int32: Number of bytes in payload* + * @param [out] pcm opus_int16*: Output signal (interleaved if 2 channels). length + * is frame_size*channels*sizeof(opus_int16) + * @param [in] frame_size Number of samples per channel of available space in \a pcm. + * If this is less than the maximum packet duration (120ms; 5760 for 48kHz), this function will + * not be capable of decoding some packets. In the case of PLC (data==NULL) or FEC (decode_fec=1), + * then frame_size needs to be exactly the duration of audio that is missing, otherwise the + * decoder will not be in the optimal state to decode the next incoming packet. For the PLC and + * FEC cases, frame_size must be a multiple of 2.5 ms. + * @param [in] decode_fec int: Flag (0 or 1) to request that any in-band forward error correction data be + * decoded. If no such data is available, the frame is decoded as if it were lost. + * @returns Number of decoded samples per channel or @ref opus_errorcodes + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_decode( + OpusDecoder *st, + const unsigned char *data, + opus_int32 len, + opus_int16 *pcm, + int frame_size, + int decode_fec +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Decode an Opus packet. + * @param [in] st OpusDecoder*: Decoder state + * @param [in] data char*: Input payload. Use a NULL pointer to indicate packet loss + * @param [in] len opus_int32: Number of bytes in payload* + * @param [out] pcm opus_int32*: Output signal (interleaved if 2 channels) representing (or slightly exceeding) 24-bit values. length + * is frame_size*channels*sizeof(opus_int32) + * @param [in] frame_size Number of samples per channel of available space in \a pcm. + * If this is less than the maximum packet duration (120ms; 5760 for 48kHz), this function will + * not be capable of decoding some packets. In the case of PLC (data==NULL) or FEC (decode_fec=1), + * then frame_size needs to be exactly the duration of audio that is missing, otherwise the + * decoder will not be in the optimal state to decode the next incoming packet. For the PLC and + * FEC cases, frame_size must be a multiple of 2.5 ms. + * @param [in] decode_fec int: Flag (0 or 1) to request that any in-band forward error correction data be + * decoded. If no such data is available, the frame is decoded as if it were lost. + * @returns Number of decoded samples or @ref opus_errorcodes + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_decode24( + OpusDecoder *st, + const unsigned char *data, + opus_int32 len, + opus_int32 *pcm, + int frame_size, + int decode_fec +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Decode an Opus packet with floating point output. + * @param [in] st OpusDecoder*: Decoder state + * @param [in] data char*: Input payload. Use a NULL pointer to indicate packet loss + * @param [in] len opus_int32: Number of bytes in payload + * @param [out] pcm float*: Output signal (interleaved if 2 channels). length + * is frame_size*channels*sizeof(float) + * @param [in] frame_size Number of samples per channel of available space in \a pcm. + * If this is less than the maximum packet duration (120ms; 5760 for 48kHz), this function will + * not be capable of decoding some packets. In the case of PLC (data==NULL) or FEC (decode_fec=1), + * then frame_size needs to be exactly the duration of audio that is missing, otherwise the + * decoder will not be in the optimal state to decode the next incoming packet. For the PLC and + * FEC cases, frame_size must be a multiple of 2.5 ms. + * @param [in] decode_fec int: Flag (0 or 1) to request that any in-band forward error correction data be + * decoded. If no such data is available the frame is decoded as if it were lost. + * @returns Number of decoded samples per channel or @ref opus_errorcodes + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_decode_float( + OpusDecoder *st, + const unsigned char *data, + opus_int32 len, + float *pcm, + int frame_size, + int decode_fec +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Perform a CTL function on an Opus decoder. + * + * Generally the request and subsequent arguments are generated + * by a convenience macro. + * @param st OpusDecoder*: Decoder state. + * @param request This and all remaining parameters should be replaced by one + * of the convenience macros in @ref opus_genericctls or + * @ref opus_decoderctls. + * @see opus_genericctls + * @see opus_decoderctls + */ +OPUS_EXPORT int opus_decoder_ctl(OpusDecoder *st, int request, ...) OPUS_ARG_NONNULL(1); + +/** Frees an OpusDecoder allocated by opus_decoder_create(). + * @param[in] st OpusDecoder*: State to be freed. + */ +OPUS_EXPORT void opus_decoder_destroy(OpusDecoder *st); + +/** Gets the size of an OpusDREDDecoder structure. + * @returns The size in bytes. + */ +OPUS_EXPORT int opus_dred_decoder_get_size(void); + +/** Allocates and initializes an OpusDREDDecoder state. + * @param [out] error int*: #OPUS_OK Success or @ref opus_errorcodes + */ +OPUS_EXPORT OpusDREDDecoder *opus_dred_decoder_create(int *error); + +/** Initializes an OpusDREDDecoder state. + * @param[in] dec OpusDREDDecoder*: State to be initialized. + */ +OPUS_EXPORT int opus_dred_decoder_init(OpusDREDDecoder *dec); + +/** Frees an OpusDREDDecoder allocated by opus_dred_decoder_create(). + * @param[in] dec OpusDREDDecoder*: State to be freed. + */ +OPUS_EXPORT void opus_dred_decoder_destroy(OpusDREDDecoder *dec); + +/** Perform a CTL function on an Opus DRED decoder. + * + * Generally the request and subsequent arguments are generated + * by a convenience macro. + * @param dred_dec OpusDREDDecoder*: DRED Decoder state. + * @param request This and all remaining parameters should be replaced by one + * of the convenience macros in @ref opus_genericctls or + * @ref opus_decoderctls. + * @see opus_genericctls + * @see opus_decoderctls + */ +OPUS_EXPORT int opus_dred_decoder_ctl(OpusDREDDecoder *dred_dec, int request, ...); + +/** Gets the size of an OpusDRED structure. + * @returns The size in bytes. + */ +OPUS_EXPORT int opus_dred_get_size(void); + +/** Allocates and initializes a DRED state. + * @param [out] error int*: #OPUS_OK Success or @ref opus_errorcodes + */ +OPUS_EXPORT OpusDRED *opus_dred_alloc(int *error); + +/** Frees an OpusDRED allocated by opus_dred_create(). + * @param[in] dec OpusDRED*: State to be freed. + */ +OPUS_EXPORT void opus_dred_free(OpusDRED *dec); + +/** Decode an Opus DRED packet. + * @param [in] dred_dec OpusDRED*: DRED Decoder state + * @param [in] dred OpusDRED*: DRED state + * @param [in] data char*: Input payload + * @param [in] len opus_int32: Number of bytes in payload + * @param [in] max_dred_samples opus_int32: Maximum number of DRED samples that may be needed (if available in the packet). + * @param [in] sampling_rate opus_int32: Sampling rate used for max_dred_samples argument. Needs not match the actual sampling rate of the decoder. + * @param [out] dred_end opus_int32*: Number of non-encoded (silence) samples between the DRED timestamp and the last DRED sample. + * @param [in] defer_processing int: Flag (0 or 1). If set to one, the CPU-intensive part of the DRED decoding is deferred until opus_dred_process() is called. + * @returns Offset (positive) of the first decoded DRED samples, zero if no DRED is present, or @ref opus_errorcodes + */ +OPUS_EXPORT int opus_dred_parse(OpusDREDDecoder *dred_dec, OpusDRED *dred, const unsigned char *data, opus_int32 len, opus_int32 max_dred_samples, opus_int32 sampling_rate, int *dred_end, int defer_processing) OPUS_ARG_NONNULL(1); + +/** Finish decoding an Opus DRED packet. The function only needs to be called if opus_dred_parse() was called with defer_processing=1. + * The source and destination will often be the same DRED state. + * @param [in] dred_dec OpusDRED*: DRED Decoder state + * @param [in] src OpusDRED*: Source DRED state to start the processing from. + * @param [out] dst OpusDRED*: Destination DRED state to store the updated state after processing. + * @returns @ref opus_errorcodes + */ +OPUS_EXPORT int opus_dred_process(OpusDREDDecoder *dred_dec, const OpusDRED *src, OpusDRED *dst); + +/** Decode audio from an Opus DRED packet with 16-bit output. + * @param [in] st OpusDecoder*: Decoder state + * @param [in] dred OpusDRED*: DRED state + * @param [in] dred_offset opus_int32: position of the redundancy to decode (in samples before the beginning of the real audio data in the packet). + * @param [out] pcm opus_int16*: Output signal (interleaved if 2 channels). length + * is frame_size*channels*sizeof(opus_int16) + * @param [in] frame_size Number of samples per channel to decode in \a pcm. + * frame_size must be a multiple of 2.5 ms. + * @returns Number of decoded samples or @ref opus_errorcodes + */ +OPUS_EXPORT int opus_decoder_dred_decode(OpusDecoder *st, const OpusDRED *dred, opus_int32 dred_offset, opus_int16 *pcm, opus_int32 frame_size); + +/** Decode audio from an Opus DRED packet with 24-bit output. + * @param [in] st OpusDecoder*: Decoder state + * @param [in] dred OpusDRED*: DRED state + * @param [in] dred_offset opus_int32: position of the redundancy to decode (in samples before the beginning of the real audio data in the packet). + * @param [out] pcm opus_int32*: Output signal (interleaved if 2 channels). length + * is frame_size*channels*sizeof(opus_int16) + * @param [in] frame_size Number of samples per channel to decode in \a pcm. + * frame_size must be a multiple of 2.5 ms. + * @returns Number of decoded samples or @ref opus_errorcodes + */ +OPUS_EXPORT int opus_decoder_dred_decode24(OpusDecoder *st, const OpusDRED *dred, opus_int32 dred_offset, opus_int32 *pcm, opus_int32 frame_size); + +/** Decode audio from an Opus DRED packet with floating point output. + * @param [in] st OpusDecoder*: Decoder state + * @param [in] dred OpusDRED*: DRED state + * @param [in] dred_offset opus_int32: position of the redundancy to decode (in samples before the beginning of the real audio data in the packet). + * @param [out] pcm float*: Output signal (interleaved if 2 channels). length + * is frame_size*channels*sizeof(float) + * @param [in] frame_size Number of samples per channel to decode in \a pcm. + * frame_size must be a multiple of 2.5 ms. + * @returns Number of decoded samples or @ref opus_errorcodes + */ +OPUS_EXPORT int opus_decoder_dred_decode_float(OpusDecoder *st, const OpusDRED *dred, opus_int32 dred_offset, float *pcm, opus_int32 frame_size); + + +/** Parse an opus packet into one or more frames. + * Opus_decode will perform this operation internally so most applications do + * not need to use this function. + * This function does not copy the frames, the returned pointers are pointers into + * the input packet. + * @param [in] data char*: Opus packet to be parsed + * @param [in] len opus_int32: size of data + * @param [out] out_toc char*: TOC pointer + * @param [out] frames char*[48] encapsulated frames + * @param [out] size opus_int16[48] sizes of the encapsulated frames + * @param [out] payload_offset int*: returns the position of the payload within the packet (in bytes) + * @returns number of frames + */ +OPUS_EXPORT int opus_packet_parse( + const unsigned char *data, + opus_int32 len, + unsigned char *out_toc, + const unsigned char *frames[48], + opus_int16 size[48], + int *payload_offset +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(5); + +/** Gets the bandwidth of an Opus packet. + * @param [in] data char*: Opus packet + * @retval OPUS_BANDWIDTH_NARROWBAND Narrowband (4kHz bandpass) + * @retval OPUS_BANDWIDTH_MEDIUMBAND Mediumband (6kHz bandpass) + * @retval OPUS_BANDWIDTH_WIDEBAND Wideband (8kHz bandpass) + * @retval OPUS_BANDWIDTH_SUPERWIDEBAND Superwideband (12kHz bandpass) + * @retval OPUS_BANDWIDTH_FULLBAND Fullband (20kHz bandpass) + * @retval OPUS_INVALID_PACKET The compressed data passed is corrupted or of an unsupported type + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_packet_get_bandwidth(const unsigned char *data) OPUS_ARG_NONNULL(1); + +/** Gets the number of samples per frame from an Opus packet. + * @param [in] data char*: Opus packet. + * This must contain at least one byte of + * data. + * @param [in] Fs opus_int32: Sampling rate in Hz. + * This must be a multiple of 400, or + * inaccurate results will be returned. + * @returns Number of samples per frame. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_packet_get_samples_per_frame(const unsigned char *data, opus_int32 Fs) OPUS_ARG_NONNULL(1); + +/** Gets the number of channels from an Opus packet. + * @param [in] data char*: Opus packet + * @returns Number of channels + * @retval OPUS_INVALID_PACKET The compressed data passed is corrupted or of an unsupported type + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_packet_get_nb_channels(const unsigned char *data) OPUS_ARG_NONNULL(1); + +/** Gets the number of frames in an Opus packet. + * @param [in] packet char*: Opus packet + * @param [in] len opus_int32: Length of packet + * @returns Number of frames + * @retval OPUS_BAD_ARG Insufficient data was passed to the function + * @retval OPUS_INVALID_PACKET The compressed data passed is corrupted or of an unsupported type + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_packet_get_nb_frames(const unsigned char packet[], opus_int32 len) OPUS_ARG_NONNULL(1); + +/** Gets the number of samples of an Opus packet. + * @param [in] packet char*: Opus packet + * @param [in] len opus_int32: Length of packet + * @param [in] Fs opus_int32: Sampling rate in Hz. + * This must be a multiple of 400, or + * inaccurate results will be returned. + * @returns Number of samples + * @retval OPUS_BAD_ARG Insufficient data was passed to the function + * @retval OPUS_INVALID_PACKET The compressed data passed is corrupted or of an unsupported type + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_packet_get_nb_samples(const unsigned char packet[], opus_int32 len, opus_int32 Fs) OPUS_ARG_NONNULL(1); + +/** Checks whether an Opus packet has LBRR. + * @param [in] packet char*: Opus packet + * @param [in] len opus_int32: Length of packet + * @returns 1 is LBRR is present, 0 otherwise + * @retval OPUS_INVALID_PACKET The compressed data passed is corrupted or of an unsupported type + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_packet_has_lbrr(const unsigned char packet[], opus_int32 len); + +/** Gets the number of samples of an Opus packet. + * @param [in] dec OpusDecoder*: Decoder state + * @param [in] packet char*: Opus packet + * @param [in] len opus_int32: Length of packet + * @returns Number of samples + * @retval OPUS_BAD_ARG Insufficient data was passed to the function + * @retval OPUS_INVALID_PACKET The compressed data passed is corrupted or of an unsupported type + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_decoder_get_nb_samples(const OpusDecoder *dec, const unsigned char packet[], opus_int32 len) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2); + +/** Applies soft-clipping to bring a float signal within the [-1,1] range. If + * the signal is already in that range, nothing is done. If there are values + * outside of [-1,1], then the signal is clipped as smoothly as possible to + * both fit in the range and avoid creating excessive distortion in the + * process. + * @param [in,out] pcm float*: Input PCM and modified PCM + * @param [in] frame_size int Number of samples per channel to process + * @param [in] channels int: Number of channels + * @param [in,out] softclip_mem float*: State memory for the soft clipping process (one float per channel, initialized to zero) + */ +OPUS_EXPORT void opus_pcm_soft_clip(float *pcm, int frame_size, int channels, float *softclip_mem); + + +/**@}*/ + +/** @defgroup opus_repacketizer Repacketizer + * @{ + * + * The repacketizer can be used to merge multiple Opus packets into a single + * packet or alternatively to split Opus packets that have previously been + * merged. Splitting valid Opus packets is always guaranteed to succeed, + * whereas merging valid packets only succeeds if all frames have the same + * mode, bandwidth, and frame size, and when the total duration of the merged + * packet is no more than 120 ms. The 120 ms limit comes from the + * specification and limits decoder memory requirements at a point where + * framing overhead becomes negligible. + * + * The repacketizer currently only operates on elementary Opus + * streams. It will not manipulate multistream packets successfully, except in + * the degenerate case where they consist of data from a single stream. + * + * The repacketizing process starts with creating a repacketizer state, either + * by calling opus_repacketizer_create() or by allocating the memory yourself, + * e.g., + * @code + * OpusRepacketizer *rp; + * rp = (OpusRepacketizer*)malloc(opus_repacketizer_get_size()); + * if (rp != NULL) + * opus_repacketizer_init(rp); + * @endcode + * + * Then the application should submit packets with opus_repacketizer_cat(), + * extract new packets with opus_repacketizer_out() or + * opus_repacketizer_out_range(), and then reset the state for the next set of + * input packets via opus_repacketizer_init(). + * + * For example, to split a sequence of packets into individual frames: + * @code + * unsigned char *data; + * int len; + * while (get_next_packet(&data, &len)) + * { + * unsigned char out[1276]; + * opus_int32 out_len; + * int nb_frames; + * int err; + * int i; + * err = opus_repacketizer_cat(rp, data, len); + * if (err != OPUS_OK) + * { + * release_packet(data); + * return err; + * } + * nb_frames = opus_repacketizer_get_nb_frames(rp); + * for (i = 0; i < nb_frames; i++) + * { + * out_len = opus_repacketizer_out_range(rp, i, i+1, out, sizeof(out)); + * if (out_len < 0) + * { + * release_packet(data); + * return (int)out_len; + * } + * output_next_packet(out, out_len); + * } + * opus_repacketizer_init(rp); + * release_packet(data); + * } + * @endcode + * + * Alternatively, to combine a sequence of frames into packets that each + * contain up to TARGET_DURATION_MS milliseconds of data: + * @code + * // The maximum number of packets with duration TARGET_DURATION_MS occurs + * // when the frame size is 2.5 ms, for a total of (TARGET_DURATION_MS*2/5) + * // packets. + * unsigned char *data[(TARGET_DURATION_MS*2/5)+1]; + * opus_int32 len[(TARGET_DURATION_MS*2/5)+1]; + * int nb_packets; + * unsigned char out[1277*(TARGET_DURATION_MS*2/2)]; + * opus_int32 out_len; + * int prev_toc; + * nb_packets = 0; + * while (get_next_packet(data+nb_packets, len+nb_packets)) + * { + * int nb_frames; + * int err; + * nb_frames = opus_packet_get_nb_frames(data[nb_packets], len[nb_packets]); + * if (nb_frames < 1) + * { + * release_packets(data, nb_packets+1); + * return nb_frames; + * } + * nb_frames += opus_repacketizer_get_nb_frames(rp); + * // If adding the next packet would exceed our target, or it has an + * // incompatible TOC sequence, output the packets we already have before + * // submitting it. + * // N.B., The nb_packets > 0 check ensures we've submitted at least one + * // packet since the last call to opus_repacketizer_init(). Otherwise a + * // single packet longer than TARGET_DURATION_MS would cause us to try to + * // output an (invalid) empty packet. It also ensures that prev_toc has + * // been set to a valid value. Additionally, len[nb_packets] > 0 is + * // guaranteed by the call to opus_packet_get_nb_frames() above, so the + * // reference to data[nb_packets][0] should be valid. + * if (nb_packets > 0 && ( + * ((prev_toc & 0xFC) != (data[nb_packets][0] & 0xFC)) || + * opus_packet_get_samples_per_frame(data[nb_packets], 48000)*nb_frames > + * TARGET_DURATION_MS*48)) + * { + * out_len = opus_repacketizer_out(rp, out, sizeof(out)); + * if (out_len < 0) + * { + * release_packets(data, nb_packets+1); + * return (int)out_len; + * } + * output_next_packet(out, out_len); + * opus_repacketizer_init(rp); + * release_packets(data, nb_packets); + * data[0] = data[nb_packets]; + * len[0] = len[nb_packets]; + * nb_packets = 0; + * } + * err = opus_repacketizer_cat(rp, data[nb_packets], len[nb_packets]); + * if (err != OPUS_OK) + * { + * release_packets(data, nb_packets+1); + * return err; + * } + * prev_toc = data[nb_packets][0]; + * nb_packets++; + * } + * // Output the final, partial packet. + * if (nb_packets > 0) + * { + * out_len = opus_repacketizer_out(rp, out, sizeof(out)); + * release_packets(data, nb_packets); + * if (out_len < 0) + * return (int)out_len; + * output_next_packet(out, out_len); + * } + * @endcode + * + * An alternate way of merging packets is to simply call opus_repacketizer_cat() + * unconditionally until it fails. At that point, the merged packet can be + * obtained with opus_repacketizer_out() and the input packet for which + * opus_repacketizer_cat() needs to be re-added to a newly reinitialized + * repacketizer state. + */ + +typedef struct OpusRepacketizer OpusRepacketizer; + +/** Gets the size of an OpusRepacketizer structure. + * @returns The size in bytes. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_repacketizer_get_size(void); + +/** (Re)initializes a previously allocated repacketizer state. + * The state must be at least the size returned by opus_repacketizer_get_size(). + * This can be used for applications which use their own allocator instead of + * malloc(). + * It must also be called to reset the queue of packets waiting to be + * repacketized, which is necessary if the maximum packet duration of 120 ms + * is reached or if you wish to submit packets with a different Opus + * configuration (coding mode, audio bandwidth, frame size, or channel count). + * Failure to do so will prevent a new packet from being added with + * opus_repacketizer_cat(). + * @see opus_repacketizer_create + * @see opus_repacketizer_get_size + * @see opus_repacketizer_cat + * @param rp OpusRepacketizer*: The repacketizer state to + * (re)initialize. + * @returns A pointer to the same repacketizer state that was passed in. + */ +OPUS_EXPORT OpusRepacketizer *opus_repacketizer_init(OpusRepacketizer *rp) OPUS_ARG_NONNULL(1); + +/** Allocates memory and initializes the new repacketizer with + * opus_repacketizer_init(). + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusRepacketizer *opus_repacketizer_create(void); + +/** Frees an OpusRepacketizer allocated by + * opus_repacketizer_create(). + * @param[in] rp OpusRepacketizer*: State to be freed. + */ +OPUS_EXPORT void opus_repacketizer_destroy(OpusRepacketizer *rp); + +/** Add a packet to the current repacketizer state. + * This packet must match the configuration of any packets already submitted + * for repacketization since the last call to opus_repacketizer_init(). + * This means that it must have the same coding mode, audio bandwidth, frame + * size, and channel count. + * This can be checked in advance by examining the top 6 bits of the first + * byte of the packet, and ensuring they match the top 6 bits of the first + * byte of any previously submitted packet. + * The total duration of audio in the repacketizer state also must not exceed + * 120 ms, the maximum duration of a single packet, after adding this packet. + * + * The contents of the current repacketizer state can be extracted into new + * packets using opus_repacketizer_out() or opus_repacketizer_out_range(). + * + * In order to add a packet with a different configuration or to add more + * audio beyond 120 ms, you must clear the repacketizer state by calling + * opus_repacketizer_init(). + * If a packet is too large to add to the current repacketizer state, no part + * of it is added, even if it contains multiple frames, some of which might + * fit. + * If you wish to be able to add parts of such packets, you should first use + * another repacketizer to split the packet into pieces and add them + * individually. + * @see opus_repacketizer_out_range + * @see opus_repacketizer_out + * @see opus_repacketizer_init + * @param rp OpusRepacketizer*: The repacketizer state to which to + * add the packet. + * @param[in] data const unsigned char*: The packet data. + * The application must ensure + * this pointer remains valid + * until the next call to + * opus_repacketizer_init() or + * opus_repacketizer_destroy(). + * @param len opus_int32: The number of bytes in the packet data. + * @returns An error code indicating whether or not the operation succeeded. + * @retval #OPUS_OK The packet's contents have been added to the repacketizer + * state. + * @retval #OPUS_INVALID_PACKET The packet did not have a valid TOC sequence, + * the packet's TOC sequence was not compatible + * with previously submitted packets (because + * the coding mode, audio bandwidth, frame size, + * or channel count did not match), or adding + * this packet would increase the total amount of + * audio stored in the repacketizer state to more + * than 120 ms. + */ +OPUS_EXPORT int opus_repacketizer_cat(OpusRepacketizer *rp, const unsigned char *data, opus_int32 len) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2); + + +/** Construct a new packet from data previously submitted to the repacketizer + * state via opus_repacketizer_cat(). + * @param rp OpusRepacketizer*: The repacketizer state from which to + * construct the new packet. + * @param begin int: The index of the first frame in the current + * repacketizer state to include in the output. + * @param end int: One past the index of the last frame in the + * current repacketizer state to include in the + * output. + * @param[out] data const unsigned char*: The buffer in which to + * store the output packet. + * @param maxlen opus_int32: The maximum number of bytes to store in + * the output buffer. In order to guarantee + * success, this should be at least + * 1276 for a single frame, + * or for multiple frames, + * 1277*(end-begin). + * However, 1*(end-begin) plus + * the size of all packet data submitted to + * the repacketizer since the last call to + * opus_repacketizer_init() or + * opus_repacketizer_create() is also + * sufficient, and possibly much smaller. + * @returns The total size of the output packet on success, or an error code + * on failure. + * @retval #OPUS_BAD_ARG [begin,end) was an invalid range of + * frames (begin < 0, begin >= end, or end > + * opus_repacketizer_get_nb_frames()). + * @retval #OPUS_BUFFER_TOO_SMALL \a maxlen was insufficient to contain the + * complete output packet. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_repacketizer_out_range(OpusRepacketizer *rp, int begin, int end, unsigned char *data, opus_int32 maxlen) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Return the total number of frames contained in packet data submitted to + * the repacketizer state so far via opus_repacketizer_cat() since the last + * call to opus_repacketizer_init() or opus_repacketizer_create(). + * This defines the valid range of packets that can be extracted with + * opus_repacketizer_out_range() or opus_repacketizer_out(). + * @param rp OpusRepacketizer*: The repacketizer state containing the + * frames. + * @returns The total number of frames contained in the packet data submitted + * to the repacketizer state. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_repacketizer_get_nb_frames(OpusRepacketizer *rp) OPUS_ARG_NONNULL(1); + +/** Construct a new packet from data previously submitted to the repacketizer + * state via opus_repacketizer_cat(). + * This is a convenience routine that returns all the data submitted so far + * in a single packet. + * It is equivalent to calling + * @code + * opus_repacketizer_out_range(rp, 0, opus_repacketizer_get_nb_frames(rp), + * data, maxlen) + * @endcode + * @param rp OpusRepacketizer*: The repacketizer state from which to + * construct the new packet. + * @param[out] data const unsigned char*: The buffer in which to + * store the output packet. + * @param maxlen opus_int32: The maximum number of bytes to store in + * the output buffer. In order to guarantee + * success, this should be at least + * 1277*opus_repacketizer_get_nb_frames(rp). + * However, + * 1*opus_repacketizer_get_nb_frames(rp) + * plus the size of all packet data + * submitted to the repacketizer since the + * last call to opus_repacketizer_init() or + * opus_repacketizer_create() is also + * sufficient, and possibly much smaller. + * @returns The total size of the output packet on success, or an error code + * on failure. + * @retval #OPUS_BUFFER_TOO_SMALL \a maxlen was insufficient to contain the + * complete output packet. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_repacketizer_out(OpusRepacketizer *rp, unsigned char *data, opus_int32 maxlen) OPUS_ARG_NONNULL(1); + +/** Pads a given Opus packet to a larger size (possibly changing the TOC sequence). + * @param[in,out] data const unsigned char*: The buffer containing the + * packet to pad. + * @param len opus_int32: The size of the packet. + * This must be at least 1. + * @param new_len opus_int32: The desired size of the packet after padding. + * This must be at least as large as len. + * @returns an error code + * @retval #OPUS_OK \a on success. + * @retval #OPUS_BAD_ARG \a len was less than 1 or new_len was less than len. + * @retval #OPUS_INVALID_PACKET \a data did not contain a valid Opus packet. + */ +OPUS_EXPORT int opus_packet_pad(unsigned char *data, opus_int32 len, opus_int32 new_len); + +/** Remove all padding from a given Opus packet and rewrite the TOC sequence to + * minimize space usage. + * @param[in,out] data const unsigned char*: The buffer containing the + * packet to strip. + * @param len opus_int32: The size of the packet. + * This must be at least 1. + * @returns The new size of the output packet on success, or an error code + * on failure. + * @retval #OPUS_BAD_ARG \a len was less than 1. + * @retval #OPUS_INVALID_PACKET \a data did not contain a valid Opus packet. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_packet_unpad(unsigned char *data, opus_int32 len); + +/** Pads a given Opus multi-stream packet to a larger size (possibly changing the TOC sequence). + * @param[in,out] data const unsigned char*: The buffer containing the + * packet to pad. + * @param len opus_int32: The size of the packet. + * This must be at least 1. + * @param new_len opus_int32: The desired size of the packet after padding. + * This must be at least 1. + * @param nb_streams opus_int32: The number of streams (not channels) in the packet. + * This must be at least as large as len. + * @returns an error code + * @retval #OPUS_OK \a on success. + * @retval #OPUS_BAD_ARG \a len was less than 1. + * @retval #OPUS_INVALID_PACKET \a data did not contain a valid Opus packet. + */ +OPUS_EXPORT int opus_multistream_packet_pad(unsigned char *data, opus_int32 len, opus_int32 new_len, int nb_streams); + +/** Remove all padding from a given Opus multi-stream packet and rewrite the TOC sequence to + * minimize space usage. + * @param[in,out] data const unsigned char*: The buffer containing the + * packet to strip. + * @param len opus_int32: The size of the packet. + * This must be at least 1. + * @param nb_streams opus_int32: The number of streams (not channels) in the packet. + * This must be at least 1. + * @returns The new size of the output packet on success, or an error code + * on failure. + * @retval #OPUS_BAD_ARG \a len was less than 1 or new_len was less than len. + * @retval #OPUS_INVALID_PACKET \a data did not contain a valid Opus packet. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_multistream_packet_unpad(unsigned char *data, opus_int32 len, int nb_streams); + +/**@}*/ + +#ifdef __cplusplus +} +#endif + +#endif /* OPUS_H */ diff --git a/native/third_party/ucrt64/include/opus/opus_custom.h b/native/third_party/ucrt64/include/opus/opus_custom.h new file mode 100644 index 0000000..e3f7cf2 --- /dev/null +++ b/native/third_party/ucrt64/include/opus/opus_custom.h @@ -0,0 +1,381 @@ +/* Copyright (c) 2007-2008 CSIRO + Copyright (c) 2007-2009 Xiph.Org Foundation + Copyright (c) 2008-2012 Gregory Maxwell + Written by Jean-Marc Valin and Gregory Maxwell */ +/* + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + - Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + - Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER + OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +/** + @file opus_custom.h + @brief Opus-Custom reference implementation API + */ + +#ifndef OPUS_CUSTOM_H +#define OPUS_CUSTOM_H + +#include "opus_defines.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#if defined(CUSTOM_MODES) || defined(ENABLE_OPUS_CUSTOM_API) +# define OPUS_CUSTOM_EXPORT OPUS_EXPORT +# define OPUS_CUSTOM_EXPORT_STATIC OPUS_EXPORT +#else +# define OPUS_CUSTOM_EXPORT +# ifdef OPUS_BUILD +# define OPUS_CUSTOM_EXPORT_STATIC static OPUS_INLINE +# else +# define OPUS_CUSTOM_EXPORT_STATIC +# endif +#endif + +/** @defgroup opus_custom Opus Custom + * @{ + * Opus Custom is an optional part of the Opus specification and + * reference implementation which uses a distinct API from the regular + * API and supports frame sizes that are not normally supported.\ Use + * of Opus Custom is discouraged for all but very special applications + * for which a frame size different from 2.5, 5, 10, or 20 ms is needed + * (for either complexity or latency reasons) and where interoperability + * is less important. + * + * In addition to the interoperability limitations the use of Opus custom + * disables a substantial chunk of the codec and generally lowers the + * quality available at a given bitrate. Normally when an application needs + * a different frame size from the codec it should buffer to match the + * sizes but this adds a small amount of delay which may be important + * in some very low latency applications. Some transports (especially + * constant rate RF transports) may also work best with frames of + * particular durations. + * + * Libopus only supports custom modes if they are enabled at compile time. + * + * The Opus Custom API is similar to the regular API but the + * @ref opus_encoder_create and @ref opus_decoder_create calls take + * an additional mode parameter which is a structure produced by + * a call to @ref opus_custom_mode_create. Both the encoder and decoder + * must create a mode using the same sample rate (fs) and frame size + * (frame size) so these parameters must either be signaled out of band + * or fixed in a particular implementation. + * + * Similar to regular Opus the custom modes support on the fly frame size + * switching, but the sizes available depend on the particular frame size in + * use. For some initial frame sizes on a single on the fly size is available. + */ + +/** Contains the state of an encoder. One encoder state is needed + for each stream. It is initialized once at the beginning of the + stream. Do *not* re-initialize the state for every frame. + @brief Encoder state + */ +typedef struct OpusCustomEncoder OpusCustomEncoder; + +/** State of the decoder. One decoder state is needed for each stream. + It is initialized once at the beginning of the stream. Do *not* + re-initialize the state for every frame. + @brief Decoder state + */ +typedef struct OpusCustomDecoder OpusCustomDecoder; + +/** The mode contains all the information necessary to create an + encoder. Both the encoder and decoder need to be initialized + with exactly the same mode, otherwise the output will be + corrupted. The mode MUST NOT BE DESTROYED until the encoders and + decoders that use it are destroyed as well. + @brief Mode configuration + */ +typedef struct OpusCustomMode OpusCustomMode; + +/** Creates a new mode struct. This will be passed to an encoder or + * decoder. The mode MUST NOT BE DESTROYED until the encoders and + * decoders that use it are destroyed as well. + * @param [in] Fs int: Sampling rate (8000 to 96000 Hz) + * @param [in] frame_size int: Number of samples (per channel) to encode in each + * packet (64 - 1024, prime factorization must contain zero or more 2s, 3s, or 5s and no other primes) + * @param [out] error int*: Returned error code (if NULL, no error will be returned) + * @return A newly created mode + */ +OPUS_CUSTOM_EXPORT OPUS_WARN_UNUSED_RESULT OpusCustomMode *opus_custom_mode_create(opus_int32 Fs, int frame_size, int *error); + +/** Destroys a mode struct. Only call this after all encoders and + * decoders using this mode are destroyed as well. + * @param [in] mode OpusCustomMode*: Mode to be freed. + */ +OPUS_CUSTOM_EXPORT void opus_custom_mode_destroy(OpusCustomMode *mode); + + +#if !defined(OPUS_BUILD) || defined(CELT_ENCODER_C) + +/* Encoder */ +/** Gets the size of an OpusCustomEncoder structure. + * @param [in] mode OpusCustomMode *: Mode configuration + * @param [in] channels int: Number of channels + * @returns size + */ +OPUS_CUSTOM_EXPORT_STATIC OPUS_WARN_UNUSED_RESULT int opus_custom_encoder_get_size( + const OpusCustomMode *mode, + int channels +) OPUS_ARG_NONNULL(1); + +#if defined(CUSTOM_MODES) || defined(ENABLE_OPUS_CUSTOM_API) +/** Initializes a previously allocated encoder state + * The memory pointed to by st must be the size returned by opus_custom_encoder_get_size. + * This is intended for applications which use their own allocator instead of malloc. + * @see opus_custom_encoder_create(),opus_custom_encoder_get_size() + * To reset a previously initialized state use the OPUS_RESET_STATE CTL. + * @param [in] st OpusCustomEncoder*: Encoder state + * @param [in] mode OpusCustomMode *: Contains all the information about the characteristics of + * the stream (must be the same characteristics as used for the + * decoder) + * @param [in] channels int: Number of channels + * @return OPUS_OK Success or @ref opus_errorcodes + */ +OPUS_CUSTOM_EXPORT int opus_custom_encoder_init( + OpusCustomEncoder *st, + const OpusCustomMode *mode, + int channels +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2); +# endif +#endif + + +/** Creates a new encoder state. Each stream needs its own encoder + * state (can't be shared across simultaneous streams). + * @param [in] mode OpusCustomMode*: Contains all the information about the characteristics of + * the stream (must be the same characteristics as used for the + * decoder) + * @param [in] channels int: Number of channels + * @param [out] error int*: Returns an error code + * @return Newly created encoder state. +*/ +OPUS_CUSTOM_EXPORT OPUS_WARN_UNUSED_RESULT OpusCustomEncoder *opus_custom_encoder_create( + const OpusCustomMode *mode, + int channels, + int *error +) OPUS_ARG_NONNULL(1); + + +/** Destroys an encoder state. + * @param[in] st OpusCustomEncoder*: State to be freed. + */ +OPUS_CUSTOM_EXPORT void opus_custom_encoder_destroy(OpusCustomEncoder *st); + +/** Encodes a frame of audio. + * @param [in] st OpusCustomEncoder*: Encoder state + * @param [in] pcm float*: PCM audio in float format, with a normal range of +/-1.0. + * Samples with a range beyond +/-1.0 are supported but will + * be clipped by decoders using the integer API and should + * only be used if it is known that the far end supports + * extended dynamic range. There must be exactly + * frame_size samples per channel. + * @param [in] frame_size int: Number of samples per frame of input signal + * @param [out] compressed char *: The compressed data is written here. This may not alias pcm and must be at least maxCompressedBytes long. + * @param [in] maxCompressedBytes int: Maximum number of bytes to use for compressing the frame + * (can change from one frame to another) + * @return Number of bytes written to "compressed". + * If negative, an error has occurred (see error codes). It is IMPORTANT that + * the length returned be somehow transmitted to the decoder. Otherwise, no + * decoding is possible. + */ +OPUS_CUSTOM_EXPORT OPUS_WARN_UNUSED_RESULT int opus_custom_encode_float( + OpusCustomEncoder *st, + const float *pcm, + int frame_size, + unsigned char *compressed, + int maxCompressedBytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + +/** Encodes a frame of audio. + * @param [in] st OpusCustomEncoder*: Encoder state + * @param [in] pcm opus_int16*: PCM audio in signed 16-bit format (native endian). + * There must be exactly frame_size samples per channel. + * @param [in] frame_size int: Number of samples per frame of input signal + * @param [out] compressed char *: The compressed data is written here. This may not alias pcm and must be at least maxCompressedBytes long. + * @param [in] maxCompressedBytes int: Maximum number of bytes to use for compressing the frame + * (can change from one frame to another) + * @return Number of bytes written to "compressed". + * If negative, an error has occurred (see error codes). It is IMPORTANT that + * the length returned be somehow transmitted to the decoder. Otherwise, no + * decoding is possible. + */ +OPUS_CUSTOM_EXPORT OPUS_WARN_UNUSED_RESULT int opus_custom_encode( + OpusCustomEncoder *st, + const opus_int16 *pcm, + int frame_size, + unsigned char *compressed, + int maxCompressedBytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + +/** Encodes a frame of audio. + * @param [in] st OpusCustomEncoder*: Encoder state + * @param [in] pcm opus_int32*: PCM audio in signed 32-bit format (native endian) representing (or slightly exceeding) 24-bit values. + * There must be exactly frame_size samples per channel. + * @param [in] frame_size int: Number of samples per frame of input signal + * @param [out] compressed char *: The compressed data is written here. This may not alias pcm and must be at least maxCompressedBytes long. + * @param [in] maxCompressedBytes int: Maximum number of bytes to use for compressing the frame + * (can change from one frame to another) + * @return Number of bytes written to "compressed". + * If negative, an error has occurred (see error codes). It is IMPORTANT that + * the length returned be somehow transmitted to the decoder. Otherwise, no + * decoding is possible. + */ +OPUS_CUSTOM_EXPORT OPUS_WARN_UNUSED_RESULT int opus_custom_encode24( + OpusCustomEncoder *st, + const opus_int32 *pcm, + int frame_size, + unsigned char *compressed, + int maxCompressedBytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + +/** Perform a CTL function on an Opus custom encoder. + * + * Generally the request and subsequent arguments are generated + * by a convenience macro. + * @see opus_encoderctls + */ +OPUS_CUSTOM_EXPORT int opus_custom_encoder_ctl(OpusCustomEncoder * OPUS_RESTRICT st, int request, ...) OPUS_ARG_NONNULL(1); + + +#if !defined(OPUS_BUILD) || defined(CELT_DECODER_C) +/* Decoder */ + +/** Gets the size of an OpusCustomDecoder structure. + * @param [in] mode OpusCustomMode *: Mode configuration + * @param [in] channels int: Number of channels + * @returns size + */ +OPUS_CUSTOM_EXPORT_STATIC OPUS_WARN_UNUSED_RESULT int opus_custom_decoder_get_size( + const OpusCustomMode *mode, + int channels +) OPUS_ARG_NONNULL(1); + +/** Initializes a previously allocated decoder state + * The memory pointed to by st must be the size returned by opus_custom_decoder_get_size. + * This is intended for applications which use their own allocator instead of malloc. + * @see opus_custom_decoder_create(),opus_custom_decoder_get_size() + * To reset a previously initialized state use the OPUS_RESET_STATE CTL. + * @param [in] st OpusCustomDecoder*: Decoder state + * @param [in] mode OpusCustomMode *: Contains all the information about the characteristics of + * the stream (must be the same characteristics as used for the + * encoder) + * @param [in] channels int: Number of channels + * @return OPUS_OK Success or @ref opus_errorcodes + */ +OPUS_CUSTOM_EXPORT_STATIC int opus_custom_decoder_init( + OpusCustomDecoder *st, + const OpusCustomMode *mode, + int channels +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2); + +#endif + + +/** Creates a new decoder state. Each stream needs its own decoder state (can't + * be shared across simultaneous streams). + * @param [in] mode OpusCustomMode: Contains all the information about the characteristics of the + * stream (must be the same characteristics as used for the encoder) + * @param [in] channels int: Number of channels + * @param [out] error int*: Returns an error code + * @return Newly created decoder state. + */ +OPUS_CUSTOM_EXPORT OPUS_WARN_UNUSED_RESULT OpusCustomDecoder *opus_custom_decoder_create( + const OpusCustomMode *mode, + int channels, + int *error +) OPUS_ARG_NONNULL(1); + +/** Destroys a decoder state. + * @param[in] st OpusCustomDecoder*: State to be freed. + */ +OPUS_CUSTOM_EXPORT void opus_custom_decoder_destroy(OpusCustomDecoder *st); + +/** Decode an opus custom frame with floating point output + * @param [in] st OpusCustomDecoder*: Decoder state + * @param [in] data char*: Input payload. Use a NULL pointer to indicate packet loss + * @param [in] len int: Number of bytes in payload + * @param [out] pcm float*: Output signal (interleaved if 2 channels). length + * is frame_size*channels*sizeof(float) + * @param [in] frame_size Number of samples per channel of available space in *pcm. + * @returns Number of decoded samples or @ref opus_errorcodes + */ +OPUS_CUSTOM_EXPORT OPUS_WARN_UNUSED_RESULT int opus_custom_decode_float( + OpusCustomDecoder *st, + const unsigned char *data, + int len, + float *pcm, + int frame_size +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Decode an opus custom frame + * @param [in] st OpusCustomDecoder*: Decoder state + * @param [in] data char*: Input payload. Use a NULL pointer to indicate packet loss + * @param [in] len int: Number of bytes in payload + * @param [out] pcm opus_int16*: Output signal (interleaved if 2 channels). length + * is frame_size*channels*sizeof(opus_int16) + * @param [in] frame_size Number of samples per channel of available space in *pcm. + * @returns Number of decoded samples or @ref opus_errorcodes + */ +OPUS_CUSTOM_EXPORT OPUS_WARN_UNUSED_RESULT int opus_custom_decode( + OpusCustomDecoder *st, + const unsigned char *data, + int len, + opus_int16 *pcm, + int frame_size +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Decode an opus custom frame + * @param [in] st OpusCustomDecoder*: Decoder state + * @param [in] data char*: Input payload. Use a NULL pointer to indicate packet loss + * @param [in] len int: Number of bytes in payload + * @param [out] pcm opus_int32*: Output signal (interleaved if 2 channels) representing (or slightly exceeding) 24-bit values. length + * is frame_size*channels*sizeof(opus_int32) + * @param [in] frame_size Number of samples per channel of available space in *pcm. + * @returns Number of decoded samples or @ref opus_errorcodes + */ +OPUS_CUSTOM_EXPORT OPUS_WARN_UNUSED_RESULT int opus_custom_decode24( + OpusCustomDecoder *st, + const unsigned char *data, + int len, + opus_int32 *pcm, + int frame_size +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Perform a CTL function on an Opus custom decoder. + * + * Generally the request and subsequent arguments are generated + * by a convenience macro. + * @see opus_genericctls + */ +OPUS_CUSTOM_EXPORT int opus_custom_decoder_ctl(OpusCustomDecoder * OPUS_RESTRICT st, int request, ...) OPUS_ARG_NONNULL(1); + +/**@}*/ + +#ifdef __cplusplus +} +#endif + +#endif /* OPUS_CUSTOM_H */ diff --git a/native/third_party/ucrt64/include/opus/opus_defines.h b/native/third_party/ucrt64/include/opus/opus_defines.h new file mode 100644 index 0000000..0d103a7 --- /dev/null +++ b/native/third_party/ucrt64/include/opus/opus_defines.h @@ -0,0 +1,867 @@ +/* Copyright (c) 2010-2011 Xiph.Org Foundation, Skype Limited + Written by Jean-Marc Valin and Koen Vos */ +/* + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + - Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + - Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER + OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +/** + * @file opus_defines.h + * @brief Opus reference implementation constants + */ + +#ifndef OPUS_DEFINES_H +#define OPUS_DEFINES_H + +#include "opus_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup opus_errorcodes Error codes + * @{ + */ +/** No error @hideinitializer*/ +#define OPUS_OK 0 +/** One or more invalid/out of range arguments @hideinitializer*/ +#define OPUS_BAD_ARG -1 +/** Not enough bytes allocated in the buffer @hideinitializer*/ +#define OPUS_BUFFER_TOO_SMALL -2 +/** An internal error was detected @hideinitializer*/ +#define OPUS_INTERNAL_ERROR -3 +/** The compressed data passed is corrupted @hideinitializer*/ +#define OPUS_INVALID_PACKET -4 +/** Invalid/unsupported request number @hideinitializer*/ +#define OPUS_UNIMPLEMENTED -5 +/** An encoder or decoder structure is invalid or already freed @hideinitializer*/ +#define OPUS_INVALID_STATE -6 +/** Memory allocation has failed @hideinitializer*/ +#define OPUS_ALLOC_FAIL -7 +/**@}*/ + +/** @cond OPUS_INTERNAL_DOC */ +/**Export control for opus functions */ + +#ifndef OPUS_EXPORT +# if defined(_WIN32) +# if defined(OPUS_BUILD) && defined(DLL_EXPORT) +# define OPUS_EXPORT __declspec(dllexport) +# else +# define OPUS_EXPORT +# endif +# elif defined(__GNUC__) && defined(OPUS_BUILD) +# define OPUS_EXPORT __attribute__ ((visibility ("default"))) +# else +# define OPUS_EXPORT +# endif +#endif + +# if !defined(OPUS_GNUC_PREREQ) +# if defined(__GNUC__)&&defined(__GNUC_MINOR__) +# define OPUS_GNUC_PREREQ(_maj,_min) \ + ((__GNUC__<<16)+__GNUC_MINOR__>=((_maj)<<16)+(_min)) +# else +# define OPUS_GNUC_PREREQ(_maj,_min) 0 +# endif +# endif + +#if (!defined(__STDC_VERSION__) || (__STDC_VERSION__ < 199901L) ) +# if OPUS_GNUC_PREREQ(3,0) +# define OPUS_RESTRICT __restrict__ +# elif (defined(_MSC_VER) && _MSC_VER >= 1400) +# define OPUS_RESTRICT __restrict +# else +# define OPUS_RESTRICT +# endif +#else +# define OPUS_RESTRICT restrict +#endif + +#if (!defined(__STDC_VERSION__) || (__STDC_VERSION__ < 199901L) ) +# if OPUS_GNUC_PREREQ(2,7) +# define OPUS_INLINE __inline__ +# elif (defined(_MSC_VER)) +# define OPUS_INLINE __inline +# else +# define OPUS_INLINE +# endif +#else +# define OPUS_INLINE inline +#endif + +/**Warning attributes for opus functions + * NONNULL is not used in OPUS_BUILD to avoid the compiler optimizing out + * some paranoid null checks. */ +#if defined(__GNUC__) && OPUS_GNUC_PREREQ(3, 4) +# define OPUS_WARN_UNUSED_RESULT __attribute__ ((__warn_unused_result__)) +#else +# define OPUS_WARN_UNUSED_RESULT +#endif +#if !defined(OPUS_BUILD) && defined(__GNUC__) && OPUS_GNUC_PREREQ(3, 4) +# define OPUS_ARG_NONNULL(_x) __attribute__ ((__nonnull__(_x))) +#else +# define OPUS_ARG_NONNULL(_x) +#endif + +/** These are the actual Encoder CTL ID numbers. + * They should not be used directly by applications. + * In general, SETs should be even and GETs should be odd.*/ +#define OPUS_SET_APPLICATION_REQUEST 4000 +#define OPUS_GET_APPLICATION_REQUEST 4001 +#define OPUS_SET_BITRATE_REQUEST 4002 +#define OPUS_GET_BITRATE_REQUEST 4003 +#define OPUS_SET_MAX_BANDWIDTH_REQUEST 4004 +#define OPUS_GET_MAX_BANDWIDTH_REQUEST 4005 +#define OPUS_SET_VBR_REQUEST 4006 +#define OPUS_GET_VBR_REQUEST 4007 +#define OPUS_SET_BANDWIDTH_REQUEST 4008 +#define OPUS_GET_BANDWIDTH_REQUEST 4009 +#define OPUS_SET_COMPLEXITY_REQUEST 4010 +#define OPUS_GET_COMPLEXITY_REQUEST 4011 +#define OPUS_SET_INBAND_FEC_REQUEST 4012 +#define OPUS_GET_INBAND_FEC_REQUEST 4013 +#define OPUS_SET_PACKET_LOSS_PERC_REQUEST 4014 +#define OPUS_GET_PACKET_LOSS_PERC_REQUEST 4015 +#define OPUS_SET_DTX_REQUEST 4016 +#define OPUS_GET_DTX_REQUEST 4017 +#define OPUS_SET_VBR_CONSTRAINT_REQUEST 4020 +#define OPUS_GET_VBR_CONSTRAINT_REQUEST 4021 +#define OPUS_SET_FORCE_CHANNELS_REQUEST 4022 +#define OPUS_GET_FORCE_CHANNELS_REQUEST 4023 +#define OPUS_SET_SIGNAL_REQUEST 4024 +#define OPUS_GET_SIGNAL_REQUEST 4025 +#define OPUS_GET_LOOKAHEAD_REQUEST 4027 +/* #define OPUS_RESET_STATE 4028 */ +#define OPUS_GET_SAMPLE_RATE_REQUEST 4029 +#define OPUS_GET_FINAL_RANGE_REQUEST 4031 +#define OPUS_GET_PITCH_REQUEST 4033 +#define OPUS_SET_GAIN_REQUEST 4034 +#define OPUS_GET_GAIN_REQUEST 4045 /* Should have been 4035 */ +#define OPUS_SET_LSB_DEPTH_REQUEST 4036 +#define OPUS_GET_LSB_DEPTH_REQUEST 4037 +#define OPUS_GET_LAST_PACKET_DURATION_REQUEST 4039 +#define OPUS_SET_EXPERT_FRAME_DURATION_REQUEST 4040 +#define OPUS_GET_EXPERT_FRAME_DURATION_REQUEST 4041 +#define OPUS_SET_PREDICTION_DISABLED_REQUEST 4042 +#define OPUS_GET_PREDICTION_DISABLED_REQUEST 4043 +/* Don't use 4045, it's already taken by OPUS_GET_GAIN_REQUEST */ +#define OPUS_SET_PHASE_INVERSION_DISABLED_REQUEST 4046 +#define OPUS_GET_PHASE_INVERSION_DISABLED_REQUEST 4047 +#define OPUS_GET_IN_DTX_REQUEST 4049 +#define OPUS_SET_DRED_DURATION_REQUEST 4050 +#define OPUS_GET_DRED_DURATION_REQUEST 4051 +#define OPUS_SET_DNN_BLOB_REQUEST 4052 +/*#define OPUS_GET_DNN_BLOB_REQUEST 4053 */ +#define OPUS_SET_OSCE_BWE_REQUEST 4054 +#define OPUS_GET_OSCE_BWE_REQUEST 4055 +#define OPUS_SET_QEXT_REQUEST 4056 +#define OPUS_GET_QEXT_REQUEST 4057 +#define OPUS_SET_IGNORE_EXTENSIONS_REQUEST 4058 +#define OPUS_GET_IGNORE_EXTENSIONS_REQUEST 4059 + +/** Defines for the presence of extended APIs. */ +#define OPUS_HAVE_OPUS_PROJECTION_H + +/* Macros to trigger compilation errors when the wrong types are provided to a CTL */ +#define opus_check_int(x) (((void)((x) == (opus_int32)0)), (opus_int32)(x)) + +#ifdef DISABLE_PTR_CHECK +/* Disable checks to prevent ubsan from complaining about NULL checks + in test_opus_api. */ +#define opus_check_int_ptr(ptr) (ptr) +#define opus_check_uint_ptr(ptr) (ptr) +#define opus_check_uint8_ptr(ptr) (ptr) +#define opus_check_val16_ptr(ptr) (ptr) +#define opus_check_void_ptr(ptr) (ptr) +#else +#define opus_check_int_ptr(ptr) ((ptr) + ((ptr) - (opus_int32*)(ptr))) +#define opus_check_uint_ptr(ptr) ((ptr) + ((ptr) - (opus_uint32*)(ptr))) +#define opus_check_uint8_ptr(ptr) ((ptr) + ((ptr) - (opus_uint8*)(ptr))) +#define opus_check_val16_ptr(ptr) ((ptr) + ((ptr) - (opus_val16*)(ptr))) +#define opus_check_void_ptr(x) ((void)((void *)0 == (x)), (x)) +#endif +/** @endcond */ + +/** @defgroup opus_ctlvalues Pre-defined values for CTL interface + * @see opus_genericctls, opus_encoderctls + * @{ + */ +/* Values for the various encoder CTLs */ +#define OPUS_AUTO -1000 /**opus_int32: Allowed values: 0-10, inclusive. + * + * @hideinitializer */ +#define OPUS_SET_COMPLEXITY(x) OPUS_SET_COMPLEXITY_REQUEST, opus_check_int(x) +/** Gets the encoder's complexity configuration. + * @see OPUS_SET_COMPLEXITY + * @param[out] x opus_int32 *: Returns a value in the range 0-10, + * inclusive. + * @hideinitializer */ +#define OPUS_GET_COMPLEXITY(x) OPUS_GET_COMPLEXITY_REQUEST, opus_check_int_ptr(x) + +/** Configures the bitrate in the encoder. + * Rates from 500 to 512000 bits per second are meaningful, as well as the + * special values #OPUS_AUTO and #OPUS_BITRATE_MAX. + * The value #OPUS_BITRATE_MAX can be used to cause the codec to use as much + * rate as it can, which is useful for controlling the rate by adjusting the + * output buffer size. + * @see OPUS_GET_BITRATE + * @param[in] x opus_int32: Bitrate in bits per second. The default + * is determined based on the number of + * channels and the input sampling rate. + * @hideinitializer */ +#define OPUS_SET_BITRATE(x) OPUS_SET_BITRATE_REQUEST, opus_check_int(x) +/** Gets the encoder's bitrate configuration. + * @see OPUS_SET_BITRATE + * @param[out] x opus_int32 *: Returns the bitrate in bits per second. + * The default is determined based on the + * number of channels and the input + * sampling rate. + * @hideinitializer */ +#define OPUS_GET_BITRATE(x) OPUS_GET_BITRATE_REQUEST, opus_check_int_ptr(x) + +/** Enables or disables variable bitrate (VBR) in the encoder. + * The configured bitrate may not be met exactly because frames must + * be an integer number of bytes in length. + * @see OPUS_GET_VBR + * @see OPUS_SET_VBR_CONSTRAINT + * @param[in] x opus_int32: Allowed values: + *
+ *
0
Hard CBR. For LPC/hybrid modes at very low bit-rate, this can + * cause noticeable quality degradation.
+ *
1
VBR (default). The exact type of VBR is controlled by + * #OPUS_SET_VBR_CONSTRAINT.
+ *
+ * @hideinitializer */ +#define OPUS_SET_VBR(x) OPUS_SET_VBR_REQUEST, opus_check_int(x) +/** Determine if variable bitrate (VBR) is enabled in the encoder. + * @see OPUS_SET_VBR + * @see OPUS_GET_VBR_CONSTRAINT + * @param[out] x opus_int32 *: Returns one of the following values: + *
+ *
0
Hard CBR.
+ *
1
VBR (default). The exact type of VBR may be retrieved via + * #OPUS_GET_VBR_CONSTRAINT.
+ *
+ * @hideinitializer */ +#define OPUS_GET_VBR(x) OPUS_GET_VBR_REQUEST, opus_check_int_ptr(x) + +/** Enables or disables constrained VBR in the encoder. + * This setting is ignored when the encoder is in CBR mode. + * @warning Only the MDCT mode of Opus currently heeds the constraint. + * Speech mode ignores it completely, hybrid mode may fail to obey it + * if the LPC layer uses more bitrate than the constraint would have + * permitted. + * @see OPUS_GET_VBR_CONSTRAINT + * @see OPUS_SET_VBR + * @param[in] x opus_int32: Allowed values: + *
+ *
0
Unconstrained VBR.
+ *
1
Constrained VBR (default). This creates a maximum of one + * frame of buffering delay assuming a transport with a + * serialization speed of the nominal bitrate.
+ *
+ * @hideinitializer */ +#define OPUS_SET_VBR_CONSTRAINT(x) OPUS_SET_VBR_CONSTRAINT_REQUEST, opus_check_int(x) +/** Determine if constrained VBR is enabled in the encoder. + * @see OPUS_SET_VBR_CONSTRAINT + * @see OPUS_GET_VBR + * @param[out] x opus_int32 *: Returns one of the following values: + *
+ *
0
Unconstrained VBR.
+ *
1
Constrained VBR (default).
+ *
+ * @hideinitializer */ +#define OPUS_GET_VBR_CONSTRAINT(x) OPUS_GET_VBR_CONSTRAINT_REQUEST, opus_check_int_ptr(x) + +/** Configures mono/stereo forcing in the encoder. + * This can force the encoder to produce packets encoded as either mono or + * stereo, regardless of the format of the input audio. This is useful when + * the caller knows that the input signal is currently a mono source embedded + * in a stereo stream. + * @see OPUS_GET_FORCE_CHANNELS + * @param[in] x opus_int32: Allowed values: + *
+ *
#OPUS_AUTO
Not forced (default)
+ *
1
Forced mono
+ *
2
Forced stereo
+ *
+ * @hideinitializer */ +#define OPUS_SET_FORCE_CHANNELS(x) OPUS_SET_FORCE_CHANNELS_REQUEST, opus_check_int(x) +/** Gets the encoder's forced channel configuration. + * @see OPUS_SET_FORCE_CHANNELS + * @param[out] x opus_int32 *: + *
+ *
#OPUS_AUTO
Not forced (default)
+ *
1
Forced mono
+ *
2
Forced stereo
+ *
+ * @hideinitializer */ +#define OPUS_GET_FORCE_CHANNELS(x) OPUS_GET_FORCE_CHANNELS_REQUEST, opus_check_int_ptr(x) + +/** Configures the maximum bandpass that the encoder will select automatically. + * Applications should normally use this instead of #OPUS_SET_BANDWIDTH + * (leaving that set to the default, #OPUS_AUTO). This allows the + * application to set an upper bound based on the type of input it is + * providing, but still gives the encoder the freedom to reduce the bandpass + * when the bitrate becomes too low, for better overall quality. + * @see OPUS_GET_MAX_BANDWIDTH + * @param[in] x opus_int32: Allowed values: + *
+ *
OPUS_BANDWIDTH_NARROWBAND
4 kHz passband
+ *
OPUS_BANDWIDTH_MEDIUMBAND
6 kHz passband
+ *
OPUS_BANDWIDTH_WIDEBAND
8 kHz passband
+ *
OPUS_BANDWIDTH_SUPERWIDEBAND
12 kHz passband
+ *
OPUS_BANDWIDTH_FULLBAND
20 kHz passband (default)
+ *
+ * @hideinitializer */ +#define OPUS_SET_MAX_BANDWIDTH(x) OPUS_SET_MAX_BANDWIDTH_REQUEST, opus_check_int(x) + +/** Gets the encoder's configured maximum allowed bandpass. + * @see OPUS_SET_MAX_BANDWIDTH + * @param[out] x opus_int32 *: Allowed values: + *
+ *
#OPUS_BANDWIDTH_NARROWBAND
4 kHz passband
+ *
#OPUS_BANDWIDTH_MEDIUMBAND
6 kHz passband
+ *
#OPUS_BANDWIDTH_WIDEBAND
8 kHz passband
+ *
#OPUS_BANDWIDTH_SUPERWIDEBAND
12 kHz passband
+ *
#OPUS_BANDWIDTH_FULLBAND
20 kHz passband (default)
+ *
+ * @hideinitializer */ +#define OPUS_GET_MAX_BANDWIDTH(x) OPUS_GET_MAX_BANDWIDTH_REQUEST, opus_check_int_ptr(x) + +/** Sets the encoder's bandpass to a specific value. + * This prevents the encoder from automatically selecting the bandpass based + * on the available bitrate. If an application knows the bandpass of the input + * audio it is providing, it should normally use #OPUS_SET_MAX_BANDWIDTH + * instead, which still gives the encoder the freedom to reduce the bandpass + * when the bitrate becomes too low, for better overall quality. + * @see OPUS_GET_BANDWIDTH + * @param[in] x opus_int32: Allowed values: + *
+ *
#OPUS_AUTO
(default)
+ *
#OPUS_BANDWIDTH_NARROWBAND
4 kHz passband
+ *
#OPUS_BANDWIDTH_MEDIUMBAND
6 kHz passband
+ *
#OPUS_BANDWIDTH_WIDEBAND
8 kHz passband
+ *
#OPUS_BANDWIDTH_SUPERWIDEBAND
12 kHz passband
+ *
#OPUS_BANDWIDTH_FULLBAND
20 kHz passband
+ *
+ * @hideinitializer */ +#define OPUS_SET_BANDWIDTH(x) OPUS_SET_BANDWIDTH_REQUEST, opus_check_int(x) + +/** Configures the type of signal being encoded. + * This is a hint which helps the encoder's mode selection. + * @see OPUS_GET_SIGNAL + * @param[in] x opus_int32: Allowed values: + *
+ *
#OPUS_AUTO
(default)
+ *
#OPUS_SIGNAL_VOICE
Bias thresholds towards choosing LPC or Hybrid modes.
+ *
#OPUS_SIGNAL_MUSIC
Bias thresholds towards choosing MDCT modes.
+ *
+ * @hideinitializer */ +#define OPUS_SET_SIGNAL(x) OPUS_SET_SIGNAL_REQUEST, opus_check_int(x) +/** Gets the encoder's configured signal type. + * @see OPUS_SET_SIGNAL + * @param[out] x opus_int32 *: Returns one of the following values: + *
+ *
#OPUS_AUTO
(default)
+ *
#OPUS_SIGNAL_VOICE
Bias thresholds towards choosing LPC or Hybrid modes.
+ *
#OPUS_SIGNAL_MUSIC
Bias thresholds towards choosing MDCT modes.
+ *
+ * @hideinitializer */ +#define OPUS_GET_SIGNAL(x) OPUS_GET_SIGNAL_REQUEST, opus_check_int_ptr(x) + + +/** Configures the encoder's intended application. + * The initial value is a mandatory argument to the encoder_create function. + * @see OPUS_GET_APPLICATION + * @param[in] x opus_int32: Returns one of the following values: + *
+ *
#OPUS_APPLICATION_VOIP
+ *
Process signal for improved speech intelligibility.
+ *
#OPUS_APPLICATION_AUDIO
+ *
Favor faithfulness to the original input.
+ *
#OPUS_APPLICATION_RESTRICTED_LOWDELAY
+ *
Configure the minimum possible coding delay by disabling certain modes + * of operation.
+ *
+ * @hideinitializer */ +#define OPUS_SET_APPLICATION(x) OPUS_SET_APPLICATION_REQUEST, opus_check_int(x) +/** Gets the encoder's configured application. + * @see OPUS_SET_APPLICATION + * @param[out] x opus_int32 *: Returns one of the following values: + *
+ *
#OPUS_APPLICATION_VOIP
+ *
Process signal for improved speech intelligibility.
+ *
#OPUS_APPLICATION_AUDIO
+ *
Favor faithfulness to the original input.
+ *
#OPUS_APPLICATION_RESTRICTED_LOWDELAY
+ *
Configure the minimum possible coding delay by disabling certain modes + * of operation.
+ *
+ * @hideinitializer */ +#define OPUS_GET_APPLICATION(x) OPUS_GET_APPLICATION_REQUEST, opus_check_int_ptr(x) + +/** Gets the total samples of delay added by the entire codec. + * This can be queried by the encoder and then the provided number of samples can be + * skipped on from the start of the decoder's output to provide time aligned input + * and output. From the perspective of a decoding application the real data begins this many + * samples late. + * + * The decoder contribution to this delay is identical for all decoders, but the + * encoder portion of the delay may vary from implementation to implementation, + * version to version, or even depend on the encoder's initial configuration. + * Applications needing delay compensation should call this CTL rather than + * hard-coding a value. + * @param[out] x opus_int32 *: Number of lookahead samples + * @hideinitializer */ +#define OPUS_GET_LOOKAHEAD(x) OPUS_GET_LOOKAHEAD_REQUEST, opus_check_int_ptr(x) + +/** Configures the encoder's use of inband forward error correction (FEC). + * @note This is only applicable to the LPC layer + * @see OPUS_GET_INBAND_FEC + * @param[in] x opus_int32: Allowed values: + *
+ *
0
Disable inband FEC (default).
+ *
1
Inband FEC enabled. If the packet loss rate is sufficiently high, Opus will automatically switch to SILK even at high rates to enable use of that FEC.
+ *
2
Inband FEC enabled, but does not necessarily switch to SILK if we have music.
+ *
+ * @hideinitializer */ +#define OPUS_SET_INBAND_FEC(x) OPUS_SET_INBAND_FEC_REQUEST, opus_check_int(x) +/** Gets encoder's configured use of inband forward error correction. + * @see OPUS_SET_INBAND_FEC + * @param[out] x opus_int32 *: Returns one of the following values: + *
+ *
0
Inband FEC disabled (default).
+ *
1
Inband FEC enabled. If the packet loss rate is sufficiently high, Opus will automatically switch to SILK even at high rates to enable use of that FEC.
+ *
2
Inband FEC enabled, but does not necessarily switch to SILK if we have music.
+ *
+ * @hideinitializer */ +#define OPUS_GET_INBAND_FEC(x) OPUS_GET_INBAND_FEC_REQUEST, opus_check_int_ptr(x) + +/** Configures the encoder's expected packet loss percentage. + * Higher values trigger progressively more loss resistant behavior in the encoder + * at the expense of quality at a given bitrate in the absence of packet loss, but + * greater quality under loss. + * @see OPUS_GET_PACKET_LOSS_PERC + * @param[in] x opus_int32: Loss percentage in the range 0-100, inclusive (default: 0). + * @hideinitializer */ +#define OPUS_SET_PACKET_LOSS_PERC(x) OPUS_SET_PACKET_LOSS_PERC_REQUEST, opus_check_int(x) +/** Gets the encoder's configured packet loss percentage. + * @see OPUS_SET_PACKET_LOSS_PERC + * @param[out] x opus_int32 *: Returns the configured loss percentage + * in the range 0-100, inclusive (default: 0). + * @hideinitializer */ +#define OPUS_GET_PACKET_LOSS_PERC(x) OPUS_GET_PACKET_LOSS_PERC_REQUEST, opus_check_int_ptr(x) + +/** Configures the encoder's use of discontinuous transmission (DTX). + * @note This is only applicable to the LPC layer + * @see OPUS_GET_DTX + * @param[in] x opus_int32: Allowed values: + *
+ *
0
Disable DTX (default).
+ *
1
Enabled DTX.
+ *
+ * @hideinitializer */ +#define OPUS_SET_DTX(x) OPUS_SET_DTX_REQUEST, opus_check_int(x) +/** Gets encoder's configured use of discontinuous transmission. + * @see OPUS_SET_DTX + * @param[out] x opus_int32 *: Returns one of the following values: + *
+ *
0
DTX disabled (default).
+ *
1
DTX enabled.
+ *
+ * @hideinitializer */ +#define OPUS_GET_DTX(x) OPUS_GET_DTX_REQUEST, opus_check_int_ptr(x) +/** Configures the depth of signal being encoded. + * + * This is a hint which helps the encoder identify silence and near-silence. + * It represents the number of significant bits of linear intensity below + * which the signal contains ignorable quantization or other noise. + * + * For example, OPUS_SET_LSB_DEPTH(14) would be an appropriate setting + * for G.711 u-law input. OPUS_SET_LSB_DEPTH(16) would be appropriate + * for 16-bit linear pcm input with opus_encode_float(). + * + * When using opus_encode() instead of opus_encode_float(), or when libopus + * is compiled for fixed-point, the encoder uses the minimum of the value + * set here and the value 16. + * + * @see OPUS_GET_LSB_DEPTH + * @param[in] x opus_int32: Input precision in bits, between 8 and 24 + * (default: 24). + * @hideinitializer */ +#define OPUS_SET_LSB_DEPTH(x) OPUS_SET_LSB_DEPTH_REQUEST, opus_check_int(x) +/** Gets the encoder's configured signal depth. + * @see OPUS_SET_LSB_DEPTH + * @param[out] x opus_int32 *: Input precision in bits, between 8 and + * 24 (default: 24). + * @hideinitializer */ +#define OPUS_GET_LSB_DEPTH(x) OPUS_GET_LSB_DEPTH_REQUEST, opus_check_int_ptr(x) + +/** Configures the encoder's use of variable duration frames. + * When variable duration is enabled, the encoder is free to use a shorter frame + * size than the one requested in the opus_encode*() call. + * It is then the user's responsibility + * to verify how much audio was encoded by checking the ToC byte of the encoded + * packet. The part of the audio that was not encoded needs to be resent to the + * encoder for the next call. Do not use this option unless you really + * know what you are doing. + * @see OPUS_GET_EXPERT_FRAME_DURATION + * @param[in] x opus_int32: Allowed values: + *
+ *
OPUS_FRAMESIZE_ARG
Select frame size from the argument (default).
+ *
OPUS_FRAMESIZE_2_5_MS
Use 2.5 ms frames.
+ *
OPUS_FRAMESIZE_5_MS
Use 5 ms frames.
+ *
OPUS_FRAMESIZE_10_MS
Use 10 ms frames.
+ *
OPUS_FRAMESIZE_20_MS
Use 20 ms frames.
+ *
OPUS_FRAMESIZE_40_MS
Use 40 ms frames.
+ *
OPUS_FRAMESIZE_60_MS
Use 60 ms frames.
+ *
OPUS_FRAMESIZE_80_MS
Use 80 ms frames.
+ *
OPUS_FRAMESIZE_100_MS
Use 100 ms frames.
+ *
OPUS_FRAMESIZE_120_MS
Use 120 ms frames.
+ *
+ * @hideinitializer */ +#define OPUS_SET_EXPERT_FRAME_DURATION(x) OPUS_SET_EXPERT_FRAME_DURATION_REQUEST, opus_check_int(x) +/** Gets the encoder's configured use of variable duration frames. + * @see OPUS_SET_EXPERT_FRAME_DURATION + * @param[out] x opus_int32 *: Returns one of the following values: + *
+ *
OPUS_FRAMESIZE_ARG
Select frame size from the argument (default).
+ *
OPUS_FRAMESIZE_2_5_MS
Use 2.5 ms frames.
+ *
OPUS_FRAMESIZE_5_MS
Use 5 ms frames.
+ *
OPUS_FRAMESIZE_10_MS
Use 10 ms frames.
+ *
OPUS_FRAMESIZE_20_MS
Use 20 ms frames.
+ *
OPUS_FRAMESIZE_40_MS
Use 40 ms frames.
+ *
OPUS_FRAMESIZE_60_MS
Use 60 ms frames.
+ *
OPUS_FRAMESIZE_80_MS
Use 80 ms frames.
+ *
OPUS_FRAMESIZE_100_MS
Use 100 ms frames.
+ *
OPUS_FRAMESIZE_120_MS
Use 120 ms frames.
+ *
+ * @hideinitializer */ +#define OPUS_GET_EXPERT_FRAME_DURATION(x) OPUS_GET_EXPERT_FRAME_DURATION_REQUEST, opus_check_int_ptr(x) + +/** If set to 1, disables almost all use of prediction, making frames almost + * completely independent. This reduces quality. + * @see OPUS_GET_PREDICTION_DISABLED + * @param[in] x opus_int32: Allowed values: + *
+ *
0
Enable prediction (default).
+ *
1
Disable prediction.
+ *
+ * @hideinitializer */ +#define OPUS_SET_PREDICTION_DISABLED(x) OPUS_SET_PREDICTION_DISABLED_REQUEST, opus_check_int(x) +/** Gets the encoder's configured prediction status. + * @see OPUS_SET_PREDICTION_DISABLED + * @param[out] x opus_int32 *: Returns one of the following values: + *
+ *
0
Prediction enabled (default).
+ *
1
Prediction disabled.
+ *
+ * @hideinitializer */ +#define OPUS_GET_PREDICTION_DISABLED(x) OPUS_GET_PREDICTION_DISABLED_REQUEST, opus_check_int_ptr(x) + +/** If non-zero, enables Deep Redundancy (DRED) and use the specified maximum number of 10-ms redundant frames + * @hideinitializer */ +#define OPUS_SET_DRED_DURATION(x) OPUS_SET_DRED_DURATION_REQUEST, opus_check_int(x) +/** Gets the encoder's configured Deep Redundancy (DRED) maximum number of frames. + * @hideinitializer */ +#define OPUS_GET_DRED_DURATION(x) OPUS_GET_DRED_DURATION_REQUEST, opus_check_int_ptr(x) + +/** Provide external DNN weights from binary object (only when explicitly built without the weights) + * @hideinitializer */ +#define OPUS_SET_DNN_BLOB(data, len) OPUS_SET_DNN_BLOB_REQUEST, opus_check_void_ptr(data), opus_check_int(len) + +/** If set to 1, enables quality extension (QEXT), otherwise disables it (default). Warning: This will *hurt* audio quality unless operating at + a very high bitrate. + * @hideinitializer */ +#define OPUS_SET_QEXT(x) OPUS_SET_QEXT_REQUEST, opus_check_int(x) +/** Gets the encoder's configured quality extension (QEXT). + * @hideinitializer */ +#define OPUS_GET_QEXT(x) OPUS_GET_QEXT_REQUEST, opus_check_int_ptr(x) + +/**@}*/ + +/** @defgroup opus_genericctls Generic CTLs + * + * These macros are used with the \c opus_decoder_ctl and + * \c opus_encoder_ctl calls to generate a particular + * request. + * + * When called on an \c OpusDecoder they apply to that + * particular decoder instance. When called on an + * \c OpusEncoder they apply to the corresponding setting + * on that encoder instance, if present. + * + * Some usage examples: + * + * @code + * int ret; + * opus_int32 pitch; + * ret = opus_decoder_ctl(dec_ctx, OPUS_GET_PITCH(&pitch)); + * if (ret == OPUS_OK) return ret; + * + * opus_encoder_ctl(enc_ctx, OPUS_RESET_STATE); + * opus_decoder_ctl(dec_ctx, OPUS_RESET_STATE); + * + * opus_int32 enc_bw, dec_bw; + * opus_encoder_ctl(enc_ctx, OPUS_GET_BANDWIDTH(&enc_bw)); + * opus_decoder_ctl(dec_ctx, OPUS_GET_BANDWIDTH(&dec_bw)); + * if (enc_bw != dec_bw) { + * printf("packet bandwidth mismatch!\n"); + * } + * @endcode + * + * @see opus_encoder, opus_decoder_ctl, opus_encoder_ctl, opus_decoderctls, opus_encoderctls + * @{ + */ + +/** Resets the codec state to be equivalent to a freshly initialized state. + * This should be called when switching streams in order to prevent + * the back to back decoding from giving different results from + * one at a time decoding. + * @hideinitializer */ +#define OPUS_RESET_STATE 4028 + +/** Gets the final state of the codec's entropy coder. + * This is used for testing purposes, + * The encoder and decoder state should be identical after coding a payload + * (assuming no data corruption or software bugs) + * + * @param[out] x opus_uint32 *: Entropy coder state + * + * @hideinitializer */ +#define OPUS_GET_FINAL_RANGE(x) OPUS_GET_FINAL_RANGE_REQUEST, opus_check_uint_ptr(x) + +/** Gets the encoder's configured bandpass or the decoder's last bandpass. + * @see OPUS_SET_BANDWIDTH + * @param[out] x opus_int32 *: Returns one of the following values: + *
+ *
#OPUS_AUTO
(default)
+ *
#OPUS_BANDWIDTH_NARROWBAND
4 kHz passband
+ *
#OPUS_BANDWIDTH_MEDIUMBAND
6 kHz passband
+ *
#OPUS_BANDWIDTH_WIDEBAND
8 kHz passband
+ *
#OPUS_BANDWIDTH_SUPERWIDEBAND
12 kHz passband
+ *
#OPUS_BANDWIDTH_FULLBAND
20 kHz passband
+ *
+ * @hideinitializer */ +#define OPUS_GET_BANDWIDTH(x) OPUS_GET_BANDWIDTH_REQUEST, opus_check_int_ptr(x) + +/** Gets the sampling rate the encoder or decoder was initialized with. + * This simply returns the Fs value passed to opus_encoder_init() + * or opus_decoder_init(). + * @param[out] x opus_int32 *: Sampling rate of encoder or decoder. + * @hideinitializer + */ +#define OPUS_GET_SAMPLE_RATE(x) OPUS_GET_SAMPLE_RATE_REQUEST, opus_check_int_ptr(x) + +/** If set to 1, disables the use of phase inversion for intensity stereo, + * improving the quality of mono downmixes, but slightly reducing normal + * stereo quality. Disabling phase inversion in the decoder does not comply + * with RFC 6716, although it does not cause any interoperability issue and + * is expected to become part of the Opus standard once RFC 6716 is updated + * by draft-ietf-codec-opus-update. + * @see OPUS_GET_PHASE_INVERSION_DISABLED + * @param[in] x opus_int32: Allowed values: + *
+ *
0
Enable phase inversion (default).
+ *
1
Disable phase inversion.
+ *
+ * @hideinitializer */ +#define OPUS_SET_PHASE_INVERSION_DISABLED(x) OPUS_SET_PHASE_INVERSION_DISABLED_REQUEST, opus_check_int(x) +/** Gets the encoder's configured phase inversion status. + * @see OPUS_SET_PHASE_INVERSION_DISABLED + * @param[out] x opus_int32 *: Returns one of the following values: + *
+ *
0
Stereo phase inversion enabled (default).
+ *
1
Stereo phase inversion disabled.
+ *
+ * @hideinitializer */ +#define OPUS_GET_PHASE_INVERSION_DISABLED(x) OPUS_GET_PHASE_INVERSION_DISABLED_REQUEST, opus_check_int_ptr(x) +/** Gets the DTX state of the encoder. + * Returns whether the last encoded frame was either a comfort noise update + * during DTX or not encoded because of DTX. + * @param[out] x opus_int32 *: Returns one of the following values: + *
+ *
0
The encoder is not in DTX.
+ *
1
The encoder is in DTX.
+ *
+ * @hideinitializer */ +#define OPUS_GET_IN_DTX(x) OPUS_GET_IN_DTX_REQUEST, opus_check_int_ptr(x) + +/**@}*/ + +/** @defgroup opus_decoderctls Decoder related CTLs + * @see opus_genericctls, opus_encoderctls, opus_decoder + * @{ + */ + +/** Configures decoder gain adjustment. + * Scales the decoded output by a factor specified in Q8 dB units. + * This has a maximum range of -32768 to 32767 inclusive, and returns + * OPUS_BAD_ARG otherwise. The default is zero indicating no adjustment. + * This setting survives decoder reset. + * + * gain = pow(10, x/(20.0*256)) + * + * @param[in] x opus_int32: Amount to scale PCM signal by in Q8 dB units. + * @hideinitializer */ +#define OPUS_SET_GAIN(x) OPUS_SET_GAIN_REQUEST, opus_check_int(x) +/** Gets the decoder's configured gain adjustment. @see OPUS_SET_GAIN + * + * @param[out] x opus_int32 *: Amount to scale PCM signal by in Q8 dB units. + * @hideinitializer */ +#define OPUS_GET_GAIN(x) OPUS_GET_GAIN_REQUEST, opus_check_int_ptr(x) + +/** Gets the duration (in samples) of the last packet successfully decoded or concealed. + * @param[out] x opus_int32 *: Number of samples (at current sampling rate). + * @hideinitializer */ +#define OPUS_GET_LAST_PACKET_DURATION(x) OPUS_GET_LAST_PACKET_DURATION_REQUEST, opus_check_int_ptr(x) + +/** Gets the pitch of the last decoded frame, if available. + * This can be used for any post-processing algorithm requiring the use of pitch, + * e.g. time stretching/shortening. If the last frame was not voiced, or if the + * pitch was not coded in the frame, then zero is returned. + * + * This CTL is only implemented for decoder instances. + * + * @param[out] x opus_int32 *: pitch period at 48 kHz (or 0 if not available) + * + * @hideinitializer */ +#define OPUS_GET_PITCH(x) OPUS_GET_PITCH_REQUEST, opus_check_int_ptr(x) + +/** Enables blind bandwidth extension for wideband signals if decoding sampling rate is 48 kHz. + * @param[in] x opus_int32 : 1 enables bandwidth extension, 0 disables it. + * The default is 0. + * + * @hideinitializer */ + #define OPUS_SET_OSCE_BWE(x) OPUS_SET_OSCE_BWE_REQUEST, opus_check_int(x) +/** Gets blind bandwidth extension flag for wideband signals if decoding sampling rate is 48 kHz. + * @param[out] x opus_int32 *: 1 if bwe enabled, 0 if disabled. + * + * @hideinitializer */ + #define OPUS_GET_OSCE_BWE(x) OPUS_GET_OSCE_BWE_REQUEST, opus_check_int_ptr(x) + +/** If set to 1, the decoder will ignore all extensions found in the padding area + * (does not affect DRED, which is decoded separately). + * @hideinitializer */ +#define OPUS_SET_IGNORE_EXTENSIONS(x) OPUS_SET_IGNORE_EXTENSIONS_REQUEST, opus_check_int(x) +/** Gets whether the decoder is ignoring extensions. + * @hideinitializer */ +#define OPUS_GET_IGNORE_EXTENSIONS(x) OPUS_GET_IGNORE_EXTENSIONS_REQUEST, opus_check_int_ptr(x) + +/**@}*/ + +/** @defgroup opus_libinfo Opus library information functions + * @{ + */ + +/** Converts an opus error code into a human readable string. + * + * @param[in] error int: Error number + * @returns Error string + */ +OPUS_EXPORT const char *opus_strerror(int error); + +/** Gets the libopus version string. + * + * Applications may look for the substring "-fixed" in the version string to + * determine whether they have a fixed-point or floating-point build at + * runtime. + * + * @returns Version string + */ +OPUS_EXPORT const char *opus_get_version_string(void); +/**@}*/ + +#ifdef __cplusplus +} +#endif + +#endif /* OPUS_DEFINES_H */ diff --git a/native/third_party/ucrt64/include/opus/opus_multistream.h b/native/third_party/ucrt64/include/opus/opus_multistream.h new file mode 100644 index 0000000..9e630b2 --- /dev/null +++ b/native/third_party/ucrt64/include/opus/opus_multistream.h @@ -0,0 +1,736 @@ +/* Copyright (c) 2011 Xiph.Org Foundation + Written by Jean-Marc Valin */ +/* + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + - Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + - Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER + OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +/** + * @file opus_multistream.h + * @brief Opus reference implementation multistream API + */ + +#ifndef OPUS_MULTISTREAM_H +#define OPUS_MULTISTREAM_H + +#include "opus.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** @cond OPUS_INTERNAL_DOC */ + +/** Macros to trigger compilation errors when the wrong types are provided to a + * CTL. */ +/**@{*/ +#define opus_check_encstate_ptr(ptr) ((ptr) + ((ptr) - (OpusEncoder**)(ptr))) +#define opus_check_decstate_ptr(ptr) ((ptr) + ((ptr) - (OpusDecoder**)(ptr))) +/**@}*/ + +/** These are the actual encoder and decoder CTL ID numbers. + * They should not be used directly by applications. + * In general, SETs should be even and GETs should be odd.*/ +/**@{*/ +#define OPUS_MULTISTREAM_GET_ENCODER_STATE_REQUEST 5120 +#define OPUS_MULTISTREAM_GET_DECODER_STATE_REQUEST 5122 +/**@}*/ + +/** @endcond */ + +/** @defgroup opus_multistream_ctls Multistream specific encoder and decoder CTLs + * + * These are convenience macros that are specific to the + * opus_multistream_encoder_ctl() and opus_multistream_decoder_ctl() + * interface. + * The CTLs from @ref opus_genericctls, @ref opus_encoderctls, and + * @ref opus_decoderctls may be applied to a multistream encoder or decoder as + * well. + * In addition, you may retrieve the encoder or decoder state for an specific + * stream via #OPUS_MULTISTREAM_GET_ENCODER_STATE or + * #OPUS_MULTISTREAM_GET_DECODER_STATE and apply CTLs to it individually. + */ +/**@{*/ + +/** Gets the encoder state for an individual stream of a multistream encoder. + * @param[in] x opus_int32: The index of the stream whose encoder you + * wish to retrieve. + * This must be non-negative and less than + * the streams parameter used + * to initialize the encoder. + * @param[out] y OpusEncoder**: Returns a pointer to the given + * encoder state. + * @retval OPUS_BAD_ARG The index of the requested stream was out of range. + * @hideinitializer + */ +#define OPUS_MULTISTREAM_GET_ENCODER_STATE(x,y) OPUS_MULTISTREAM_GET_ENCODER_STATE_REQUEST, opus_check_int(x), opus_check_encstate_ptr(y) + +/** Gets the decoder state for an individual stream of a multistream decoder. + * @param[in] x opus_int32: The index of the stream whose decoder you + * wish to retrieve. + * This must be non-negative and less than + * the streams parameter used + * to initialize the decoder. + * @param[out] y OpusDecoder**: Returns a pointer to the given + * decoder state. + * @retval OPUS_BAD_ARG The index of the requested stream was out of range. + * @hideinitializer + */ +#define OPUS_MULTISTREAM_GET_DECODER_STATE(x,y) OPUS_MULTISTREAM_GET_DECODER_STATE_REQUEST, opus_check_int(x), opus_check_decstate_ptr(y) + +/**@}*/ + +/** @defgroup opus_multistream Opus Multistream API + * @{ + * + * The multistream API allows individual Opus streams to be combined into a + * single packet, enabling support for up to 255 channels. Unlike an + * elementary Opus stream, the encoder and decoder must negotiate the channel + * configuration before the decoder can successfully interpret the data in the + * packets produced by the encoder. Some basic information, such as packet + * duration, can be computed without any special negotiation. + * + * The format for multistream Opus packets is defined in + * RFC 7845 + * and is based on the self-delimited Opus framing described in Appendix B of + * RFC 6716. + * Normal Opus packets are just a degenerate case of multistream Opus packets, + * and can be encoded or decoded with the multistream API by setting + * streams to 1 when initializing the encoder or + * decoder. + * + * Multistream Opus streams can contain up to 255 elementary Opus streams. + * These may be either "uncoupled" or "coupled", indicating that the decoder + * is configured to decode them to either 1 or 2 channels, respectively. + * The streams are ordered so that all coupled streams appear at the + * beginning. + * + * A mapping table defines which decoded channel i + * should be used for each input/output (I/O) channel j. This table is + * typically provided as an unsigned char array. + * Let i = mapping[j] be the index for I/O channel j. + * If i < 2*coupled_streams, then I/O channel j is + * encoded as the left channel of stream (i/2) if i + * is even, or as the right channel of stream (i/2) if + * i is odd. Otherwise, I/O channel j is encoded as + * mono in stream (i - coupled_streams), unless it has the special + * value 255, in which case it is omitted from the encoding entirely (the + * decoder will reproduce it as silence). Each value i must either + * be the special value 255 or be less than streams + coupled_streams. + * + * The output channels specified by the encoder + * should use the + * Vorbis + * channel ordering. A decoder may wish to apply an additional permutation + * to the mapping the encoder used to achieve a different output channel + * order (e.g. for outputting in WAV order). + * + * Each multistream packet contains an Opus packet for each stream, and all of + * the Opus packets in a single multistream packet must have the same + * duration. Therefore the duration of a multistream packet can be extracted + * from the TOC sequence of the first stream, which is located at the + * beginning of the packet, just like an elementary Opus stream: + * + * @code + * int nb_samples; + * int nb_frames; + * nb_frames = opus_packet_get_nb_frames(data, len); + * if (nb_frames < 1) + * return nb_frames; + * nb_samples = opus_packet_get_samples_per_frame(data, 48000) * nb_frames; + * @endcode + * + * The general encoding and decoding process proceeds exactly the same as in + * the normal @ref opus_encoder and @ref opus_decoder APIs. + * See their documentation for an overview of how to use the corresponding + * multistream functions. + */ + +/** Opus multistream encoder state. + * This contains the complete state of a multistream Opus encoder. + * It is position independent and can be freely copied. + * @see opus_multistream_encoder_create + * @see opus_multistream_encoder_init + */ +typedef struct OpusMSEncoder OpusMSEncoder; + +/** Opus multistream decoder state. + * This contains the complete state of a multistream Opus decoder. + * It is position independent and can be freely copied. + * @see opus_multistream_decoder_create + * @see opus_multistream_decoder_init + */ +typedef struct OpusMSDecoder OpusMSDecoder; + +/**\name Multistream encoder functions */ +/**@{*/ + +/** Gets the size of an OpusMSEncoder structure. + * @param streams int: The total number of streams to encode from the + * input. + * This must be no more than 255. + * @param coupled_streams int: Number of coupled (2 channel) streams + * to encode. + * This must be no larger than the total + * number of streams. + * Additionally, The total number of + * encoded channels (streams + + * coupled_streams) must be no + * more than 255. + * @returns The size in bytes on success, or a negative error code + * (see @ref opus_errorcodes) on error. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_multistream_encoder_get_size( + int streams, + int coupled_streams +); + +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_multistream_surround_encoder_get_size( + int channels, + int mapping_family +); + + +/** Allocates and initializes a multistream encoder state. + * Call opus_multistream_encoder_destroy() to release + * this object when finished. + * @param Fs opus_int32: Sampling rate of the input signal (in Hz). + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param channels int: Number of channels in the input signal. + * This must be at most 255. + * It may be greater than the number of + * coded channels (streams + + * coupled_streams). + * @param streams int: The total number of streams to encode from the + * input. + * This must be no more than the number of channels. + * @param coupled_streams int: Number of coupled (2 channel) streams + * to encode. + * This must be no larger than the total + * number of streams. + * Additionally, The total number of + * encoded channels (streams + + * coupled_streams) must be no + * more than the number of input channels. + * @param[in] mapping const unsigned char[channels]: Mapping from + * encoded channels to input channels, as described in + * @ref opus_multistream. As an extra constraint, the + * multistream encoder does not allow encoding coupled + * streams for which one channel is unused since this + * is never a good idea. + * @param application int: The target encoder application. + * This must be one of the following: + *
+ *
#OPUS_APPLICATION_VOIP
+ *
Process signal for improved speech intelligibility.
+ *
#OPUS_APPLICATION_AUDIO
+ *
Favor faithfulness to the original input.
+ *
#OPUS_APPLICATION_RESTRICTED_LOWDELAY
+ *
Configure the minimum possible coding delay by disabling certain modes + * of operation.
+ *
+ * @param[out] error int *: Returns #OPUS_OK on success, or an error + * code (see @ref opus_errorcodes) on + * failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusMSEncoder *opus_multistream_encoder_create( + opus_int32 Fs, + int channels, + int streams, + int coupled_streams, + const unsigned char *mapping, + int application, + int *error +) OPUS_ARG_NONNULL(5); + +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusMSEncoder *opus_multistream_surround_encoder_create( + opus_int32 Fs, + int channels, + int mapping_family, + int *streams, + int *coupled_streams, + unsigned char *mapping, + int application, + int *error +) OPUS_ARG_NONNULL(4) OPUS_ARG_NONNULL(5) OPUS_ARG_NONNULL(6); + +/** Initialize a previously allocated multistream encoder state. + * The memory pointed to by \a st must be at least the size returned by + * opus_multistream_encoder_get_size(). + * This is intended for applications which use their own allocator instead of + * malloc. + * To reset a previously initialized state, use the #OPUS_RESET_STATE CTL. + * @see opus_multistream_encoder_create + * @see opus_multistream_encoder_get_size + * @param st OpusMSEncoder*: Multistream encoder state to initialize. + * @param Fs opus_int32: Sampling rate of the input signal (in Hz). + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param channels int: Number of channels in the input signal. + * This must be at most 255. + * It may be greater than the number of + * coded channels (streams + + * coupled_streams). + * @param streams int: The total number of streams to encode from the + * input. + * This must be no more than the number of channels. + * @param coupled_streams int: Number of coupled (2 channel) streams + * to encode. + * This must be no larger than the total + * number of streams. + * Additionally, The total number of + * encoded channels (streams + + * coupled_streams) must be no + * more than the number of input channels. + * @param[in] mapping const unsigned char[channels]: Mapping from + * encoded channels to input channels, as described in + * @ref opus_multistream. As an extra constraint, the + * multistream encoder does not allow encoding coupled + * streams for which one channel is unused since this + * is never a good idea. + * @param application int: The target encoder application. + * This must be one of the following: + *
+ *
#OPUS_APPLICATION_VOIP
+ *
Process signal for improved speech intelligibility.
+ *
#OPUS_APPLICATION_AUDIO
+ *
Favor faithfulness to the original input.
+ *
#OPUS_APPLICATION_RESTRICTED_LOWDELAY
+ *
Configure the minimum possible coding delay by disabling certain modes + * of operation.
+ *
+ * @returns #OPUS_OK on success, or an error code (see @ref opus_errorcodes) + * on failure. + */ +OPUS_EXPORT int opus_multistream_encoder_init( + OpusMSEncoder *st, + opus_int32 Fs, + int channels, + int streams, + int coupled_streams, + const unsigned char *mapping, + int application +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(6); + +OPUS_EXPORT int opus_multistream_surround_encoder_init( + OpusMSEncoder *st, + opus_int32 Fs, + int channels, + int mapping_family, + int *streams, + int *coupled_streams, + unsigned char *mapping, + int application +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(5) OPUS_ARG_NONNULL(6) OPUS_ARG_NONNULL(7); + +/** Encodes a multistream Opus frame. + * @param st OpusMSEncoder*: Multistream encoder state. + * @param[in] pcm const opus_int16*: The input signal as interleaved + * samples. + * This must contain + * frame_size*channels + * samples. + * @param frame_size int: Number of samples per channel in the input + * signal. + * This must be an Opus frame size for the + * encoder's sampling rate. + * For example, at 48 kHz the permitted values + * are 120, 240, 480, 960, 1920, and 2880. + * Passing in a duration of less than 10 ms + * (480 samples at 48 kHz) will prevent the + * encoder from using the LPC or hybrid modes. + * @param[out] data unsigned char*: Output payload. + * This must contain storage for at + * least \a max_data_bytes. + * @param [in] max_data_bytes opus_int32: Size of the allocated + * memory for the output + * payload. This may be + * used to impose an upper limit on + * the instant bitrate, but should + * not be used as the only bitrate + * control. Use #OPUS_SET_BITRATE to + * control the bitrate. + * @returns The length of the encoded packet (in bytes) on success or a + * negative error code (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_multistream_encode( + OpusMSEncoder *st, + const opus_int16 *pcm, + int frame_size, + unsigned char *data, + opus_int32 max_data_bytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + +/** Encodes a multistream Opus frame. + * @param st OpusMSEncoder*: Multistream encoder state. + * @param[in] pcm const opus_int32*: The input signal as interleaved + * samples representing (or slightly exceeding) 24-bit values. + * This must contain + * frame_size*channels + * samples. + * @param frame_size int: Number of samples per channel in the input + * signal. + * This must be an Opus frame size for the + * encoder's sampling rate. + * For example, at 48 kHz the permitted values + * are 120, 240, 480, 960, 1920, and 2880. + * Passing in a duration of less than 10 ms + * (480 samples at 48 kHz) will prevent the + * encoder from using the LPC or hybrid modes. + * @param[out] data unsigned char*: Output payload. + * This must contain storage for at + * least \a max_data_bytes. + * @param [in] max_data_bytes opus_int32: Size of the allocated + * memory for the output + * payload. This may be + * used to impose an upper limit on + * the instant bitrate, but should + * not be used as the only bitrate + * control. Use #OPUS_SET_BITRATE to + * control the bitrate. + * @returns The length of the encoded packet (in bytes) on success or a + * negative error code (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_multistream_encode24( + OpusMSEncoder *st, + const opus_int32 *pcm, + int frame_size, + unsigned char *data, + opus_int32 max_data_bytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + +/** Encodes a multistream Opus frame from floating point input. + * @param st OpusMSEncoder*: Multistream encoder state. + * @param[in] pcm const float*: The input signal as interleaved + * samples with a normal range of + * +/-1.0. + * Samples with a range beyond +/-1.0 + * are supported but will be clipped by + * decoders using the integer API and + * should only be used if it is known + * that the far end supports extended + * dynamic range. + * This must contain + * frame_size*channels + * samples. + * @param frame_size int: Number of samples per channel in the input + * signal. + * This must be an Opus frame size for the + * encoder's sampling rate. + * For example, at 48 kHz the permitted values + * are 120, 240, 480, 960, 1920, and 2880. + * Passing in a duration of less than 10 ms + * (480 samples at 48 kHz) will prevent the + * encoder from using the LPC or hybrid modes. + * @param[out] data unsigned char*: Output payload. + * This must contain storage for at + * least \a max_data_bytes. + * @param [in] max_data_bytes opus_int32: Size of the allocated + * memory for the output + * payload. This may be + * used to impose an upper limit on + * the instant bitrate, but should + * not be used as the only bitrate + * control. Use #OPUS_SET_BITRATE to + * control the bitrate. + * @returns The length of the encoded packet (in bytes) on success or a + * negative error code (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_multistream_encode_float( + OpusMSEncoder *st, + const float *pcm, + int frame_size, + unsigned char *data, + opus_int32 max_data_bytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + +/** Frees an OpusMSEncoder allocated by + * opus_multistream_encoder_create(). + * @param st OpusMSEncoder*: Multistream encoder state to be freed. + */ +OPUS_EXPORT void opus_multistream_encoder_destroy(OpusMSEncoder *st); + +/** Perform a CTL function on a multistream Opus encoder. + * + * Generally the request and subsequent arguments are generated by a + * convenience macro. + * @param st OpusMSEncoder*: Multistream encoder state. + * @param request This and all remaining parameters should be replaced by one + * of the convenience macros in @ref opus_genericctls, + * @ref opus_encoderctls, or @ref opus_multistream_ctls. + * @see opus_genericctls + * @see opus_encoderctls + * @see opus_multistream_ctls + */ +OPUS_EXPORT int opus_multistream_encoder_ctl(OpusMSEncoder *st, int request, ...) OPUS_ARG_NONNULL(1); + +/**@}*/ + +/**\name Multistream decoder functions */ +/**@{*/ + +/** Gets the size of an OpusMSDecoder structure. + * @param streams int: The total number of streams coded in the + * input. + * This must be no more than 255. + * @param coupled_streams int: Number streams to decode as coupled + * (2 channel) streams. + * This must be no larger than the total + * number of streams. + * Additionally, The total number of + * coded channels (streams + + * coupled_streams) must be no + * more than 255. + * @returns The size in bytes on success, or a negative error code + * (see @ref opus_errorcodes) on error. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_multistream_decoder_get_size( + int streams, + int coupled_streams +); + +/** Allocates and initializes a multistream decoder state. + * Call opus_multistream_decoder_destroy() to release + * this object when finished. + * @param Fs opus_int32: Sampling rate to decode at (in Hz). + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param channels int: Number of channels to output. + * This must be at most 255. + * It may be different from the number of coded + * channels (streams + + * coupled_streams). + * @param streams int: The total number of streams coded in the + * input. + * This must be no more than 255. + * @param coupled_streams int: Number of streams to decode as coupled + * (2 channel) streams. + * This must be no larger than the total + * number of streams. + * Additionally, The total number of + * coded channels (streams + + * coupled_streams) must be no + * more than 255. + * @param[in] mapping const unsigned char[channels]: Mapping from + * coded channels to output channels, as described in + * @ref opus_multistream. + * @param[out] error int *: Returns #OPUS_OK on success, or an error + * code (see @ref opus_errorcodes) on + * failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusMSDecoder *opus_multistream_decoder_create( + opus_int32 Fs, + int channels, + int streams, + int coupled_streams, + const unsigned char *mapping, + int *error +) OPUS_ARG_NONNULL(5); + +/** Initialize a previously allocated decoder state object. + * The memory pointed to by \a st must be at least the size returned by + * opus_multistream_encoder_get_size(). + * This is intended for applications which use their own allocator instead of + * malloc. + * To reset a previously initialized state, use the #OPUS_RESET_STATE CTL. + * @see opus_multistream_decoder_create + * @see opus_multistream_deocder_get_size + * @param st OpusMSEncoder*: Multistream encoder state to initialize. + * @param Fs opus_int32: Sampling rate to decode at (in Hz). + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param channels int: Number of channels to output. + * This must be at most 255. + * It may be different from the number of coded + * channels (streams + + * coupled_streams). + * @param streams int: The total number of streams coded in the + * input. + * This must be no more than 255. + * @param coupled_streams int: Number of streams to decode as coupled + * (2 channel) streams. + * This must be no larger than the total + * number of streams. + * Additionally, The total number of + * coded channels (streams + + * coupled_streams) must be no + * more than 255. + * @param[in] mapping const unsigned char[channels]: Mapping from + * coded channels to output channels, as described in + * @ref opus_multistream. + * @returns #OPUS_OK on success, or an error code (see @ref opus_errorcodes) + * on failure. + */ +OPUS_EXPORT int opus_multistream_decoder_init( + OpusMSDecoder *st, + opus_int32 Fs, + int channels, + int streams, + int coupled_streams, + const unsigned char *mapping +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(6); + +/** Decode a multistream Opus packet. + * @param st OpusMSDecoder*: Multistream decoder state. + * @param[in] data const unsigned char*: Input payload. + * Use a NULL + * pointer to indicate packet + * loss. + * @param len opus_int32: Number of bytes in payload. + * @param[out] pcm opus_int16*: Output signal, with interleaved + * samples. + * This must contain room for + * frame_size*channels + * samples. + * @param frame_size int: The number of samples per channel of + * available space in \a pcm. + * If this is less than the maximum packet duration + * (120 ms; 5760 for 48kHz), this function will not be capable + * of decoding some packets. In the case of PLC (data==NULL) + * or FEC (decode_fec=1), then frame_size needs to be exactly + * the duration of audio that is missing, otherwise the + * decoder will not be in the optimal state to decode the + * next incoming packet. For the PLC and FEC cases, frame_size + * must be a multiple of 2.5 ms. + * @param decode_fec int: Flag (0 or 1) to request that any in-band + * forward error correction data be decoded. + * If no such data is available, the frame is + * decoded as if it were lost. + * @returns Number of samples decoded on success or a negative error code + * (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_multistream_decode( + OpusMSDecoder *st, + const unsigned char *data, + opus_int32 len, + opus_int16 *pcm, + int frame_size, + int decode_fec +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Decode a multistream Opus packet. + * @param st OpusMSDecoder*: Multistream decoder state. + * @param[in] data const unsigned char*: Input payload. + * Use a NULL + * pointer to indicate packet + * loss. + * @param len opus_int32: Number of bytes in payload. + * @param[out] pcm opus_int32*: Output signal, with interleaved + * samples representing (or slightly exceeding) 24-bit values. + * This must contain room for + * frame_size*channels + * samples. + * @param frame_size int: The number of samples per channel of + * available space in \a pcm. + * If this is less than the maximum packet duration + * (120 ms; 5760 for 48kHz), this function will not be capable + * of decoding some packets. In the case of PLC (data==NULL) + * or FEC (decode_fec=1), then frame_size needs to be exactly + * the duration of audio that is missing, otherwise the + * decoder will not be in the optimal state to decode the + * next incoming packet. For the PLC and FEC cases, frame_size + * must be a multiple of 2.5 ms. + * @param decode_fec int: Flag (0 or 1) to request that any in-band + * forward error correction data be decoded. + * If no such data is available, the frame is + * decoded as if it were lost. + * @returns Number of samples decoded on success or a negative error code + * (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_multistream_decode24( + OpusMSDecoder *st, + const unsigned char *data, + opus_int32 len, + opus_int32 *pcm, + int frame_size, + int decode_fec +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Decode a multistream Opus packet with floating point output. + * @param st OpusMSDecoder*: Multistream decoder state. + * @param[in] data const unsigned char*: Input payload. + * Use a NULL + * pointer to indicate packet + * loss. + * @param len opus_int32: Number of bytes in payload. + * @param[out] pcm opus_int16*: Output signal, with interleaved + * samples. + * This must contain room for + * frame_size*channels + * samples. + * @param frame_size int: The number of samples per channel of + * available space in \a pcm. + * If this is less than the maximum packet duration + * (120 ms; 5760 for 48kHz), this function will not be capable + * of decoding some packets. In the case of PLC (data==NULL) + * or FEC (decode_fec=1), then frame_size needs to be exactly + * the duration of audio that is missing, otherwise the + * decoder will not be in the optimal state to decode the + * next incoming packet. For the PLC and FEC cases, frame_size + * must be a multiple of 2.5 ms. + * @param decode_fec int: Flag (0 or 1) to request that any in-band + * forward error correction data be decoded. + * If no such data is available, the frame is + * decoded as if it were lost. + * @returns Number of samples decoded on success or a negative error code + * (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_multistream_decode_float( + OpusMSDecoder *st, + const unsigned char *data, + opus_int32 len, + float *pcm, + int frame_size, + int decode_fec +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Perform a CTL function on a multistream Opus decoder. + * + * Generally the request and subsequent arguments are generated by a + * convenience macro. + * @param st OpusMSDecoder*: Multistream decoder state. + * @param request This and all remaining parameters should be replaced by one + * of the convenience macros in @ref opus_genericctls, + * @ref opus_decoderctls, or @ref opus_multistream_ctls. + * @see opus_genericctls + * @see opus_decoderctls + * @see opus_multistream_ctls + */ +OPUS_EXPORT int opus_multistream_decoder_ctl(OpusMSDecoder *st, int request, ...) OPUS_ARG_NONNULL(1); + +/** Frees an OpusMSDecoder allocated by + * opus_multistream_decoder_create(). + * @param st OpusMSDecoder: Multistream decoder state to be freed. + */ +OPUS_EXPORT void opus_multistream_decoder_destroy(OpusMSDecoder *st); + +/**@}*/ + +/**@}*/ + +#ifdef __cplusplus +} +#endif + +#endif /* OPUS_MULTISTREAM_H */ diff --git a/native/third_party/ucrt64/include/opus/opus_projection.h b/native/third_party/ucrt64/include/opus/opus_projection.h new file mode 100644 index 0000000..a887452 --- /dev/null +++ b/native/third_party/ucrt64/include/opus/opus_projection.h @@ -0,0 +1,643 @@ +/* Copyright (c) 2017 Google Inc. + Written by Andrew Allen */ +/* + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + - Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + - Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER + OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +/** + * @file opus_projection.h + * @brief Opus projection reference API + */ + +#ifndef OPUS_PROJECTION_H +#define OPUS_PROJECTION_H + +#include "opus_multistream.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** @cond OPUS_INTERNAL_DOC */ + +/** These are the actual encoder and decoder CTL ID numbers. + * They should not be used directly by applications.c + * In general, SETs should be even and GETs should be odd.*/ +/**@{*/ +#define OPUS_PROJECTION_GET_DEMIXING_MATRIX_GAIN_REQUEST 6001 +#define OPUS_PROJECTION_GET_DEMIXING_MATRIX_SIZE_REQUEST 6003 +#define OPUS_PROJECTION_GET_DEMIXING_MATRIX_REQUEST 6005 +/**@}*/ + + +/** @endcond */ + +/** @defgroup opus_projection_ctls Projection specific encoder and decoder CTLs + * + * These are convenience macros that are specific to the + * opus_projection_encoder_ctl() and opus_projection_decoder_ctl() + * interface. + * The CTLs from @ref opus_genericctls, @ref opus_encoderctls, + * @ref opus_decoderctls, and @ref opus_multistream_ctls may be applied to a + * projection encoder or decoder as well. + */ +/**@{*/ + +/** Gets the gain (in dB. S7.8-format) of the demixing matrix from the encoder. + * @param[out] x opus_int32 *: Returns the gain (in dB. S7.8-format) + * of the demixing matrix. + * @hideinitializer + */ +#define OPUS_PROJECTION_GET_DEMIXING_MATRIX_GAIN(x) OPUS_PROJECTION_GET_DEMIXING_MATRIX_GAIN_REQUEST, opus_check_int_ptr(x) + + +/** Gets the size in bytes of the demixing matrix from the encoder. + * @param[out] x opus_int32 *: Returns the size in bytes of the + * demixing matrix. + * @hideinitializer + */ +#define OPUS_PROJECTION_GET_DEMIXING_MATRIX_SIZE(x) OPUS_PROJECTION_GET_DEMIXING_MATRIX_SIZE_REQUEST, opus_check_int_ptr(x) + + +/** Copies the demixing matrix to the supplied pointer location. + * @param[out] x unsigned char *: Returns the demixing matrix to the + * supplied pointer location. + * @param y opus_int32: The size in bytes of the reserved memory at the + * pointer location. + * @hideinitializer + */ +#define OPUS_PROJECTION_GET_DEMIXING_MATRIX(x,y) OPUS_PROJECTION_GET_DEMIXING_MATRIX_REQUEST, x, opus_check_int(y) + + +/**@}*/ + +/** Opus projection encoder state. + * This contains the complete state of a projection Opus encoder. + * It is position independent and can be freely copied. + * @see opus_projection_ambisonics_encoder_create + */ +typedef struct OpusProjectionEncoder OpusProjectionEncoder; + + +/** Opus projection decoder state. + * This contains the complete state of a projection Opus decoder. + * It is position independent and can be freely copied. + * @see opus_projection_decoder_create + * @see opus_projection_decoder_init + */ +typedef struct OpusProjectionDecoder OpusProjectionDecoder; + + +/**\name Projection encoder functions */ +/**@{*/ + +/** Gets the size of an OpusProjectionEncoder structure. + * @param channels int: The total number of input channels to encode. + * This must be no more than 255. + * @param mapping_family int: The mapping family to use for selecting + * the appropriate projection. + * @returns The size in bytes on success, or a negative error code + * (see @ref opus_errorcodes) on error. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_projection_ambisonics_encoder_get_size( + int channels, + int mapping_family +); + + +/** Allocates and initializes a projection encoder state. + * Call opus_projection_encoder_destroy() to release + * this object when finished. + * @param Fs opus_int32: Sampling rate of the input signal (in Hz). + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param channels int: Number of channels in the input signal. + * This must be at most 255. + * It may be greater than the number of + * coded channels (streams + + * coupled_streams). + * @param mapping_family int: The mapping family to use for selecting + * the appropriate projection. + * @param[out] streams int *: The total number of streams that will + * be encoded from the input. + * @param[out] coupled_streams int *: Number of coupled (2 channel) + * streams that will be encoded from the input. + * @param application int: The target encoder application. + * This must be one of the following: + *
+ *
#OPUS_APPLICATION_VOIP
+ *
Process signal for improved speech intelligibility.
+ *
#OPUS_APPLICATION_AUDIO
+ *
Favor faithfulness to the original input.
+ *
#OPUS_APPLICATION_RESTRICTED_LOWDELAY
+ *
Configure the minimum possible coding delay by disabling certain modes + * of operation.
+ *
+ * @param[out] error int *: Returns #OPUS_OK on success, or an error + * code (see @ref opus_errorcodes) on + * failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusProjectionEncoder *opus_projection_ambisonics_encoder_create( + opus_int32 Fs, + int channels, + int mapping_family, + int *streams, + int *coupled_streams, + int application, + int *error +) OPUS_ARG_NONNULL(4) OPUS_ARG_NONNULL(5); + + +/** Initialize a previously allocated projection encoder state. + * The memory pointed to by \a st must be at least the size returned by + * opus_projection_ambisonics_encoder_get_size(). + * This is intended for applications which use their own allocator instead of + * malloc. + * To reset a previously initialized state, use the #OPUS_RESET_STATE CTL. + * @see opus_projection_ambisonics_encoder_create + * @see opus_projection_ambisonics_encoder_get_size + * @param st OpusProjectionEncoder*: Projection encoder state to initialize. + * @param Fs opus_int32: Sampling rate of the input signal (in Hz). + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param channels int: Number of channels in the input signal. + * This must be at most 255. + * It may be greater than the number of + * coded channels (streams + + * coupled_streams). + * @param streams int: The total number of streams to encode from the + * input. + * This must be no more than the number of channels. + * @param coupled_streams int: Number of coupled (2 channel) streams + * to encode. + * This must be no larger than the total + * number of streams. + * Additionally, The total number of + * encoded channels (streams + + * coupled_streams) must be no + * more than the number of input channels. + * @param application int: The target encoder application. + * This must be one of the following: + *
+ *
#OPUS_APPLICATION_VOIP
+ *
Process signal for improved speech intelligibility.
+ *
#OPUS_APPLICATION_AUDIO
+ *
Favor faithfulness to the original input.
+ *
#OPUS_APPLICATION_RESTRICTED_LOWDELAY
+ *
Configure the minimum possible coding delay by disabling certain modes + * of operation.
+ *
+ * @returns #OPUS_OK on success, or an error code (see @ref opus_errorcodes) + * on failure. + */ +OPUS_EXPORT int opus_projection_ambisonics_encoder_init( + OpusProjectionEncoder *st, + opus_int32 Fs, + int channels, + int mapping_family, + int *streams, + int *coupled_streams, + int application +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(5) OPUS_ARG_NONNULL(6); + + +/** Encodes a projection Opus frame. + * @param st OpusProjectionEncoder*: Projection encoder state. + * @param[in] pcm const opus_int16*: The input signal as interleaved + * samples. + * This must contain + * frame_size*channels + * samples. + * @param frame_size int: Number of samples per channel in the input + * signal. + * This must be an Opus frame size for the + * encoder's sampling rate. + * For example, at 48 kHz the permitted values + * are 120, 240, 480, 960, 1920, and 2880. + * Passing in a duration of less than 10 ms + * (480 samples at 48 kHz) will prevent the + * encoder from using the LPC or hybrid modes. + * @param[out] data unsigned char*: Output payload. + * This must contain storage for at + * least \a max_data_bytes. + * @param [in] max_data_bytes opus_int32: Size of the allocated + * memory for the output + * payload. This may be + * used to impose an upper limit on + * the instant bitrate, but should + * not be used as the only bitrate + * control. Use #OPUS_SET_BITRATE to + * control the bitrate. + * @returns The length of the encoded packet (in bytes) on success or a + * negative error code (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_projection_encode( + OpusProjectionEncoder *st, + const opus_int16 *pcm, + int frame_size, + unsigned char *data, + opus_int32 max_data_bytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + +/** Encodes a projection Opus frame. + * @param st OpusProjectionEncoder*: Projection encoder state. + * @param[in] pcm const opus_int32*: The input signal as interleaved + * samples representing (or slightly exceeding) 24-bit values. + * This must contain + * frame_size*channels + * samples. + * @param frame_size int: Number of samples per channel in the input + * signal. + * This must be an Opus frame size for the + * encoder's sampling rate. + * For example, at 48 kHz the permitted values + * are 120, 240, 480, 960, 1920, and 2880. + * Passing in a duration of less than 10 ms + * (480 samples at 48 kHz) will prevent the + * encoder from using the LPC or hybrid modes. + * @param[out] data unsigned char*: Output payload. + * This must contain storage for at + * least \a max_data_bytes. + * @param [in] max_data_bytes opus_int32: Size of the allocated + * memory for the output + * payload. This may be + * used to impose an upper limit on + * the instant bitrate, but should + * not be used as the only bitrate + * control. Use #OPUS_SET_BITRATE to + * control the bitrate. + * @returns The length of the encoded packet (in bytes) on success or a + * negative error code (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_projection_encode24( + OpusProjectionEncoder *st, + const opus_int32 *pcm, + int frame_size, + unsigned char *data, + opus_int32 max_data_bytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + + +/** Encodes a projection Opus frame from floating point input. + * @param st OpusProjectionEncoder*: Projection encoder state. + * @param[in] pcm const float*: The input signal as interleaved + * samples with a normal range of + * +/-1.0. + * Samples with a range beyond +/-1.0 + * are supported but will be clipped by + * decoders using the integer API and + * should only be used if it is known + * that the far end supports extended + * dynamic range. + * This must contain + * frame_size*channels + * samples. + * @param frame_size int: Number of samples per channel in the input + * signal. + * This must be an Opus frame size for the + * encoder's sampling rate. + * For example, at 48 kHz the permitted values + * are 120, 240, 480, 960, 1920, and 2880. + * Passing in a duration of less than 10 ms + * (480 samples at 48 kHz) will prevent the + * encoder from using the LPC or hybrid modes. + * @param[out] data unsigned char*: Output payload. + * This must contain storage for at + * least \a max_data_bytes. + * @param [in] max_data_bytes opus_int32: Size of the allocated + * memory for the output + * payload. This may be + * used to impose an upper limit on + * the instant bitrate, but should + * not be used as the only bitrate + * control. Use #OPUS_SET_BITRATE to + * control the bitrate. + * @returns The length of the encoded packet (in bytes) on success or a + * negative error code (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_projection_encode_float( + OpusProjectionEncoder *st, + const float *pcm, + int frame_size, + unsigned char *data, + opus_int32 max_data_bytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + + +/** Frees an OpusProjectionEncoder allocated by + * opus_projection_ambisonics_encoder_create(). + * @param st OpusProjectionEncoder*: Projection encoder state to be freed. + */ +OPUS_EXPORT void opus_projection_encoder_destroy(OpusProjectionEncoder *st); + + +/** Perform a CTL function on a projection Opus encoder. + * + * Generally the request and subsequent arguments are generated by a + * convenience macro. + * @param st OpusProjectionEncoder*: Projection encoder state. + * @param request This and all remaining parameters should be replaced by one + * of the convenience macros in @ref opus_genericctls, + * @ref opus_encoderctls, @ref opus_multistream_ctls, or + * @ref opus_projection_ctls + * @see opus_genericctls + * @see opus_encoderctls + * @see opus_multistream_ctls + * @see opus_projection_ctls + */ +OPUS_EXPORT int opus_projection_encoder_ctl(OpusProjectionEncoder *st, int request, ...) OPUS_ARG_NONNULL(1); + + +/**@}*/ + +/**\name Projection decoder functions */ +/**@{*/ + +/** Gets the size of an OpusProjectionDecoder structure. + * @param channels int: The total number of output channels. + * This must be no more than 255. + * @param streams int: The total number of streams coded in the + * input. + * This must be no more than 255. + * @param coupled_streams int: Number streams to decode as coupled + * (2 channel) streams. + * This must be no larger than the total + * number of streams. + * Additionally, The total number of + * coded channels (streams + + * coupled_streams) must be no + * more than 255. + * @returns The size in bytes on success, or a negative error code + * (see @ref opus_errorcodes) on error. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_projection_decoder_get_size( + int channels, + int streams, + int coupled_streams +); + + +/** Allocates and initializes a projection decoder state. + * Call opus_projection_decoder_destroy() to release + * this object when finished. + * @param Fs opus_int32: Sampling rate to decode at (in Hz). + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param channels int: Number of channels to output. + * This must be at most 255. + * It may be different from the number of coded + * channels (streams + + * coupled_streams). + * @param streams int: The total number of streams coded in the + * input. + * This must be no more than 255. + * @param coupled_streams int: Number of streams to decode as coupled + * (2 channel) streams. + * This must be no larger than the total + * number of streams. + * Additionally, The total number of + * coded channels (streams + + * coupled_streams) must be no + * more than 255. + * @param[in] demixing_matrix const unsigned char[demixing_matrix_size]: Demixing matrix + * that mapping from coded channels to output channels, + * as described in @ref opus_projection and + * @ref opus_projection_ctls. + * @param demixing_matrix_size opus_int32: The size in bytes of the + * demixing matrix, as + * described in @ref + * opus_projection_ctls. + * @param[out] error int *: Returns #OPUS_OK on success, or an error + * code (see @ref opus_errorcodes) on + * failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusProjectionDecoder *opus_projection_decoder_create( + opus_int32 Fs, + int channels, + int streams, + int coupled_streams, + unsigned char *demixing_matrix, + opus_int32 demixing_matrix_size, + int *error +) OPUS_ARG_NONNULL(5); + + +/** Initialize a previously allocated projection decoder state object. + * The memory pointed to by \a st must be at least the size returned by + * opus_projection_decoder_get_size(). + * This is intended for applications which use their own allocator instead of + * malloc. + * To reset a previously initialized state, use the #OPUS_RESET_STATE CTL. + * @see opus_projection_decoder_create + * @see opus_projection_deocder_get_size + * @param st OpusProjectionDecoder*: Projection encoder state to initialize. + * @param Fs opus_int32: Sampling rate to decode at (in Hz). + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param channels int: Number of channels to output. + * This must be at most 255. + * It may be different from the number of coded + * channels (streams + + * coupled_streams). + * @param streams int: The total number of streams coded in the + * input. + * This must be no more than 255. + * @param coupled_streams int: Number of streams to decode as coupled + * (2 channel) streams. + * This must be no larger than the total + * number of streams. + * Additionally, The total number of + * coded channels (streams + + * coupled_streams) must be no + * more than 255. + * @param[in] demixing_matrix const unsigned char[demixing_matrix_size]: Demixing matrix + * that mapping from coded channels to output channels, + * as described in @ref opus_projection and + * @ref opus_projection_ctls. + * @param demixing_matrix_size opus_int32: The size in bytes of the + * demixing matrix, as + * described in @ref + * opus_projection_ctls. + * @returns #OPUS_OK on success, or an error code (see @ref opus_errorcodes) + * on failure. + */ +OPUS_EXPORT int opus_projection_decoder_init( + OpusProjectionDecoder *st, + opus_int32 Fs, + int channels, + int streams, + int coupled_streams, + unsigned char *demixing_matrix, + opus_int32 demixing_matrix_size +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(6); + + +/** Decode a projection Opus packet. + * @param st OpusProjectionDecoder*: Projection decoder state. + * @param[in] data const unsigned char*: Input payload. + * Use a NULL + * pointer to indicate packet + * loss. + * @param len opus_int32: Number of bytes in payload. + * @param[out] pcm opus_int16*: Output signal, with interleaved + * samples. + * This must contain room for + * frame_size*channels + * samples. + * @param frame_size int: The number of samples per channel of + * available space in \a pcm. + * If this is less than the maximum packet duration + * (120 ms; 5760 for 48kHz), this function will not be capable + * of decoding some packets. In the case of PLC (data==NULL) + * or FEC (decode_fec=1), then frame_size needs to be exactly + * the duration of audio that is missing, otherwise the + * decoder will not be in the optimal state to decode the + * next incoming packet. For the PLC and FEC cases, frame_size + * must be a multiple of 2.5 ms. + * @param decode_fec int: Flag (0 or 1) to request that any in-band + * forward error correction data be decoded. + * If no such data is available, the frame is + * decoded as if it were lost. + * @returns Number of samples decoded on success or a negative error code + * (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_projection_decode( + OpusProjectionDecoder *st, + const unsigned char *data, + opus_int32 len, + opus_int16 *pcm, + int frame_size, + int decode_fec +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Decode a projection Opus packet. + * @param st OpusProjectionDecoder*: Projection decoder state. + * @param[in] data const unsigned char*: Input payload. + * Use a NULL + * pointer to indicate packet + * loss. + * @param len opus_int32: Number of bytes in payload. + * @param[out] pcm opus_int32*: Output signal, with interleaved + * samples representing (or slightly exceeding) 24-bit values. + * This must contain room for + * frame_size*channels + * samples. + * @param frame_size int: The number of samples per channel of + * available space in \a pcm. + * If this is less than the maximum packet duration + * (120 ms; 5760 for 48kHz), this function will not be capable + * of decoding some packets. In the case of PLC (data==NULL) + * or FEC (decode_fec=1), then frame_size needs to be exactly + * the duration of audio that is missing, otherwise the + * decoder will not be in the optimal state to decode the + * next incoming packet. For the PLC and FEC cases, frame_size + * must be a multiple of 2.5 ms. + * @param decode_fec int: Flag (0 or 1) to request that any in-band + * forward error correction data be decoded. + * If no such data is available, the frame is + * decoded as if it were lost. + * @returns Number of samples decoded on success or a negative error code + * (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_projection_decode24( + OpusProjectionDecoder *st, + const unsigned char *data, + opus_int32 len, + opus_int32 *pcm, + int frame_size, + int decode_fec +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Decode a projection Opus packet with floating point output. + * @param st OpusProjectionDecoder*: Projection decoder state. + * @param[in] data const unsigned char*: Input payload. + * Use a NULL + * pointer to indicate packet + * loss. + * @param len opus_int32: Number of bytes in payload. + * @param[out] pcm opus_int16*: Output signal, with interleaved + * samples. + * This must contain room for + * frame_size*channels + * samples. + * @param frame_size int: The number of samples per channel of + * available space in \a pcm. + * If this is less than the maximum packet duration + * (120 ms; 5760 for 48kHz), this function will not be capable + * of decoding some packets. In the case of PLC (data==NULL) + * or FEC (decode_fec=1), then frame_size needs to be exactly + * the duration of audio that is missing, otherwise the + * decoder will not be in the optimal state to decode the + * next incoming packet. For the PLC and FEC cases, frame_size + * must be a multiple of 2.5 ms. + * @param decode_fec int: Flag (0 or 1) to request that any in-band + * forward error correction data be decoded. + * If no such data is available, the frame is + * decoded as if it were lost. + * @returns Number of samples decoded on success or a negative error code + * (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_projection_decode_float( + OpusProjectionDecoder *st, + const unsigned char *data, + opus_int32 len, + float *pcm, + int frame_size, + int decode_fec +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + + +/** Perform a CTL function on a projection Opus decoder. + * + * Generally the request and subsequent arguments are generated by a + * convenience macro. + * @param st OpusProjectionDecoder*: Projection decoder state. + * @param request This and all remaining parameters should be replaced by one + * of the convenience macros in @ref opus_genericctls, + * @ref opus_decoderctls, @ref opus_multistream_ctls, or + * @ref opus_projection_ctls. + * @see opus_genericctls + * @see opus_decoderctls + * @see opus_multistream_ctls + * @see opus_projection_ctls + */ +OPUS_EXPORT int opus_projection_decoder_ctl(OpusProjectionDecoder *st, int request, ...) OPUS_ARG_NONNULL(1); + + +/** Frees an OpusProjectionDecoder allocated by + * opus_projection_decoder_create(). + * @param st OpusProjectionDecoder: Projection decoder state to be freed. + */ +OPUS_EXPORT void opus_projection_decoder_destroy(OpusProjectionDecoder *st); + + +/**@}*/ + +/**@}*/ + +#ifdef __cplusplus +} +#endif + +#endif /* OPUS_PROJECTION_H */ diff --git a/native/third_party/ucrt64/include/opus/opus_types.h b/native/third_party/ucrt64/include/opus/opus_types.h new file mode 100644 index 0000000..b4f09a3 --- /dev/null +++ b/native/third_party/ucrt64/include/opus/opus_types.h @@ -0,0 +1,166 @@ +/* (C) COPYRIGHT 1994-2002 Xiph.Org Foundation */ +/* Modified by Jean-Marc Valin */ +/* + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + - Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + - Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER + OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ +/* opus_types.h based on ogg_types.h from libogg */ + +/** + @file opus_types.h + @brief Opus reference implementation types +*/ +#ifndef OPUS_TYPES_H +#define OPUS_TYPES_H + +#define opus_int int /* used for counters etc; at least 16 bits */ +#define opus_int64 long long +#define opus_int8 signed char + +#define opus_uint unsigned int /* used for counters etc; at least 16 bits */ +#define opus_uint64 unsigned long long +#define opus_uint8 unsigned char + +/* Use the real stdint.h if it's there (taken from Paul Hsieh's pstdint.h) */ +#if (defined(__STDC__) && __STDC__ && defined(__STDC_VERSION__) && __STDC_VERSION__ >= 199901L) || (defined(__GNUC__) && (defined(_STDINT_H) || defined(_STDINT_H_)) || defined (HAVE_STDINT_H)) +#include +# undef opus_int64 +# undef opus_int8 +# undef opus_uint64 +# undef opus_uint8 + typedef int8_t opus_int8; + typedef uint8_t opus_uint8; + typedef int16_t opus_int16; + typedef uint16_t opus_uint16; + typedef int32_t opus_int32; + typedef uint32_t opus_uint32; + typedef int64_t opus_int64; + typedef uint64_t opus_uint64; +#elif defined(_WIN32) + +# if defined(__CYGWIN__) +# include <_G_config.h> + typedef _G_int32_t opus_int32; + typedef _G_uint32_t opus_uint32; + typedef _G_int16 opus_int16; + typedef _G_uint16 opus_uint16; +# elif defined(__MINGW32__) + typedef short opus_int16; + typedef unsigned short opus_uint16; + typedef int opus_int32; + typedef unsigned int opus_uint32; +# elif defined(__MWERKS__) + typedef int opus_int32; + typedef unsigned int opus_uint32; + typedef short opus_int16; + typedef unsigned short opus_uint16; +# else + /* MSVC/Borland */ + typedef __int32 opus_int32; + typedef unsigned __int32 opus_uint32; + typedef __int16 opus_int16; + typedef unsigned __int16 opus_uint16; +# endif + +#elif defined(__MACOS__) + +# include + typedef SInt16 opus_int16; + typedef UInt16 opus_uint16; + typedef SInt32 opus_int32; + typedef UInt32 opus_uint32; + +#elif (defined(__APPLE__) && defined(__MACH__)) /* MacOS X Framework build */ + +# include + typedef int16_t opus_int16; + typedef u_int16_t opus_uint16; + typedef int32_t opus_int32; + typedef u_int32_t opus_uint32; + +#elif defined(__BEOS__) + + /* Be */ +# include + typedef int16 opus_int16; + typedef u_int16 opus_uint16; + typedef int32_t opus_int32; + typedef u_int32_t opus_uint32; + +#elif defined (__EMX__) + + /* OS/2 GCC */ + typedef short opus_int16; + typedef unsigned short opus_uint16; + typedef int opus_int32; + typedef unsigned int opus_uint32; + +#elif defined (DJGPP) + + /* DJGPP */ + typedef short opus_int16; + typedef unsigned short opus_uint16; + typedef int opus_int32; + typedef unsigned int opus_uint32; + +#elif defined(R5900) + + /* PS2 EE */ + typedef int opus_int32; + typedef unsigned opus_uint32; + typedef short opus_int16; + typedef unsigned short opus_uint16; + +#elif defined(__SYMBIAN32__) + + /* Symbian GCC */ + typedef signed short opus_int16; + typedef unsigned short opus_uint16; + typedef signed int opus_int32; + typedef unsigned int opus_uint32; + +#elif defined(CONFIG_TI_C54X) || defined (CONFIG_TI_C55X) + + typedef short opus_int16; + typedef unsigned short opus_uint16; + typedef long opus_int32; + typedef unsigned long opus_uint32; + +#elif defined(CONFIG_TI_C6X) + + typedef short opus_int16; + typedef unsigned short opus_uint16; + typedef int opus_int32; + typedef unsigned int opus_uint32; + +#else + + /* Give up, take a reasonable guess */ + typedef short opus_int16; + typedef unsigned short opus_uint16; + typedef int opus_int32; + typedef unsigned int opus_uint32; + +#endif + +#endif /* OPUS_TYPES_H */ diff --git a/native/third_party/ucrt64/include/vpx/vp8.h b/native/third_party/ucrt64/include/vpx/vp8.h new file mode 100644 index 0000000..a9d2aca --- /dev/null +++ b/native/third_party/ucrt64/include/vpx/vp8.h @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2010 The WebM project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +/*!\defgroup vp8 VP8 + * \ingroup codecs + * VP8 is a video compression algorithm that uses motion + * compensated prediction, Discrete Cosine Transform (DCT) coding of the + * prediction error signal and context dependent entropy coding techniques + * based on arithmetic principles. It features: + * - YUV 4:2:0 image format + * - Macro-block based coding (16x16 luma plus two 8x8 chroma) + * - 1/4 (1/8) pixel accuracy motion compensated prediction + * - 4x4 DCT transform + * - 128 level linear quantizer + * - In loop deblocking filter + * - Context-based entropy coding + * + * @{ + */ +/*!\file + * \brief Provides controls common to both the VP8 encoder and decoder. + */ +#ifndef VPX_VPX_VP8_H_ +#define VPX_VPX_VP8_H_ + +#include "./vpx_codec.h" +#include "./vpx_image.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/*!\brief Control functions + * + * The set of macros define the control functions of VP8 interface + */ +enum vp8_com_control_id { + /*!\brief pass in an external frame into decoder to be used as reference frame + */ + VP8_SET_REFERENCE = 1, + VP8_COPY_REFERENCE = 2, /**< get a copy of reference frame from the decoder */ + VP8_SET_POSTPROC = 3, /**< set the decoder's post processing settings */ + + /* TODO(jkoleszar): The encoder incorrectly reuses some of these values (5+) + * for its control ids. These should be migrated to something like the + * VP8_DECODER_CTRL_ID_START range next time we're ready to break the ABI. + */ + VP9_GET_REFERENCE = 128, /**< get a pointer to a reference frame */ + VP8_COMMON_CTRL_ID_MAX, + VP8_DECODER_CTRL_ID_START = 256 +}; + +/*!\brief post process flags + * + * The set of macros define VP8 decoder post processing flags + */ +enum vp8_postproc_level { + VP8_NOFILTERING = 0, + VP8_DEBLOCK = 1 << 0, + VP8_DEMACROBLOCK = 1 << 1, + VP8_ADDNOISE = 1 << 2, + VP8_MFQE = 1 << 3 +}; + +/*!\brief post process flags + * + * This define a structure that describe the post processing settings. For + * the best objective measure (using the PSNR metric) set post_proc_flag + * to VP8_DEBLOCK and deblocking_level to 1. + */ + +typedef struct vp8_postproc_cfg { + /*!\brief the types of post processing to be done, should be combination of + * "vp8_postproc_level" */ + int post_proc_flag; + int deblocking_level; /**< the strength of deblocking, valid range [0, 16] */ + int noise_level; /**< the strength of additive noise, valid range [0, 16] */ +} vp8_postproc_cfg_t; + +/*!\brief reference frame type + * + * The set of macros define the type of VP8 reference frames + */ +typedef enum vpx_ref_frame_type { + VP8_LAST_FRAME = 1, + VP8_GOLD_FRAME = 2, + VP8_ALTR_FRAME = 4 +} vpx_ref_frame_type_t; + +/*!\brief reference frame data struct + * + * Define the data struct to access vp8 reference frames. + */ +typedef struct vpx_ref_frame { + vpx_ref_frame_type_t frame_type; /**< which reference frame */ + vpx_image_t img; /**< reference frame data in image format */ +} vpx_ref_frame_t; + +/*!\brief VP9 specific reference frame data struct + * + * Define the data struct to access vp9 reference frames. + */ +typedef struct vp9_ref_frame { + int idx; /**< frame index to get (input) */ + vpx_image_t img; /**< img structure to populate (output) */ +} vp9_ref_frame_t; + +/*!\cond */ +/*!\brief vp8 decoder control function parameter type + * + * defines the data type for each of VP8 decoder control function requires + */ +VPX_CTRL_USE_TYPE(VP8_SET_REFERENCE, vpx_ref_frame_t *) +#define VPX_CTRL_VP8_SET_REFERENCE +VPX_CTRL_USE_TYPE(VP8_COPY_REFERENCE, vpx_ref_frame_t *) +#define VPX_CTRL_VP8_COPY_REFERENCE +VPX_CTRL_USE_TYPE(VP8_SET_POSTPROC, vp8_postproc_cfg_t *) +#define VPX_CTRL_VP8_SET_POSTPROC +VPX_CTRL_USE_TYPE(VP9_GET_REFERENCE, vp9_ref_frame_t *) +#define VPX_CTRL_VP9_GET_REFERENCE + +/*!\endcond */ +/*! @} - end defgroup vp8 */ + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // VPX_VPX_VP8_H_ diff --git a/native/third_party/ucrt64/include/vpx/vp8cx.h b/native/third_party/ucrt64/include/vpx/vp8cx.h new file mode 100644 index 0000000..52f88b6 --- /dev/null +++ b/native/third_party/ucrt64/include/vpx/vp8cx.h @@ -0,0 +1,1129 @@ +/* + * Copyright (c) 2010 The WebM project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ +#ifndef VPX_VPX_VP8CX_H_ +#define VPX_VPX_VP8CX_H_ + +/*!\defgroup vp8_encoder WebM VP8/VP9 Encoder + * \ingroup vp8 + * + * @{ + */ +#include "./vp8.h" +#include "./vpx_encoder.h" +#include "./vpx_ext_ratectrl.h" + +/*!\file + * \brief Provides definitions for using VP8 or VP9 encoder algorithm within the + * vpx Codec Interface. + */ + +#ifdef __cplusplus +extern "C" { +#endif + +/*!\name Algorithm interface for VP8 + * + * This interface provides the capability to encode raw VP8 streams. + * @{ + */ + +/*!\brief A single instance of the VP8 encoder. + *\deprecated This access mechanism is provided for backwards compatibility; + * prefer vpx_codec_vp8_cx(). + */ +extern vpx_codec_iface_t vpx_codec_vp8_cx_algo; + +/*!\brief The interface to the VP8 encoder. + */ +extern vpx_codec_iface_t *vpx_codec_vp8_cx(void); +/*!@} - end algorithm interface member group*/ + +/*!\name Algorithm interface for VP9 + * + * This interface provides the capability to encode raw VP9 streams. + * @{ + */ + +/*!\brief A single instance of the VP9 encoder. + *\deprecated This access mechanism is provided for backwards compatibility; + * prefer vpx_codec_vp9_cx(). + */ +extern vpx_codec_iface_t vpx_codec_vp9_cx_algo; + +/*!\brief The interface to the VP9 encoder. + */ +extern vpx_codec_iface_t *vpx_codec_vp9_cx(void); +/*!@} - end algorithm interface member group*/ + +/* + * Algorithm Flags + */ + +/*!\brief Don't reference the last frame + * + * When this flag is set, the encoder will not use the last frame as a + * predictor. When not set, the encoder will choose whether to use the + * last frame or not automatically. + */ +#define VP8_EFLAG_NO_REF_LAST (1 << 16) + +/*!\brief Don't reference the golden frame + * + * When this flag is set, the encoder will not use the golden frame as a + * predictor. When not set, the encoder will choose whether to use the + * golden frame or not automatically. + */ +#define VP8_EFLAG_NO_REF_GF (1 << 17) + +/*!\brief Don't reference the alternate reference frame + * + * When this flag is set, the encoder will not use the alt ref frame as a + * predictor. When not set, the encoder will choose whether to use the + * alt ref frame or not automatically. + */ +#define VP8_EFLAG_NO_REF_ARF (1 << 21) + +/*!\brief Don't update the last frame + * + * When this flag is set, the encoder will not update the last frame with + * the contents of the current frame. + */ +#define VP8_EFLAG_NO_UPD_LAST (1 << 18) + +/*!\brief Don't update the golden frame + * + * When this flag is set, the encoder will not update the golden frame with + * the contents of the current frame. + */ +#define VP8_EFLAG_NO_UPD_GF (1 << 22) + +/*!\brief Don't update the alternate reference frame + * + * When this flag is set, the encoder will not update the alt ref frame with + * the contents of the current frame. + */ +#define VP8_EFLAG_NO_UPD_ARF (1 << 23) + +/*!\brief Force golden frame update + * + * When this flag is set, the encoder copy the contents of the current frame + * to the golden frame buffer. + */ +#define VP8_EFLAG_FORCE_GF (1 << 19) + +/*!\brief Force alternate reference frame update + * + * When this flag is set, the encoder copy the contents of the current frame + * to the alternate reference frame buffer. + */ +#define VP8_EFLAG_FORCE_ARF (1 << 24) + +/*!\brief Disable entropy update + * + * When this flag is set, the encoder will not update its internal entropy + * model based on the entropy of this frame. + */ +#define VP8_EFLAG_NO_UPD_ENTROPY (1 << 20) + +/*!\brief VPx encoder control functions + * + * This set of macros define the control functions available for VPx + * encoder interface. + * + * \sa #vpx_codec_control + */ +enum vp8e_enc_control_id { + /*!\brief Codec control function to pass an ROI map to encoder. + * + * Supported in codecs: VP8 + */ + VP8E_SET_ROI_MAP = 8, + + /*!\brief Codec control function to pass an Active map to encoder. + * + * Supported in codecs: VP8, VP9 + */ + VP8E_SET_ACTIVEMAP, + + /*!\brief Codec control function to set encoder scaling mode. + * + * Supported in codecs: VP8, VP9 + */ + VP8E_SET_SCALEMODE = 11, + + /*!\brief Codec control function to set encoder internal speed settings. + * + * Changes in this value influences, among others, the encoder's selection + * of motion estimation methods. Values greater than 0 will increase encoder + * speed at the expense of quality. + * + * \note Valid range for VP8: -16..16 + * \note Valid range for VP9: -9..9 + * \note A negative value (-n) is treated as its absolute value (n) in VP9. + * + * Supported in codecs: VP8, VP9 + */ + VP8E_SET_CPUUSED = 13, + + /*!\brief Codec control function to enable automatic use of arf frames. + * + * \note Valid range for VP8: 0..1 + * \note Valid range for VP9: 0..6 + * + * Supported in codecs: VP8, VP9 + */ + VP8E_SET_ENABLEAUTOALTREF, + + /*!\brief control function to set noise sensitivity + * + * 0: off, 1: OnYOnly, 2: OnYUV, + * 3: OnYUVAggressive, 4: Adaptive + * + * Supported in codecs: VP8 + */ + VP8E_SET_NOISE_SENSITIVITY, + + /*!\brief Codec control function to set higher sharpness at the expense + * of a lower PSNR. + * + * \note Valid range: 0..7 + * + * Supported in codecs: VP8, VP9 + */ + VP8E_SET_SHARPNESS, + + /*!\brief Codec control function to set the threshold for MBs treated static. + * + * Supported in codecs: VP8, VP9 + */ + VP8E_SET_STATIC_THRESHOLD, + + /*!\brief Codec control function to set the number of token partitions. + * + * Supported in codecs: VP8 + */ + VP8E_SET_TOKEN_PARTITIONS, + + /*!\brief Codec control function to get last quantizer chosen by the encoder. + * + * Return value uses internal quantizer scale defined by the codec. + * + * Supported in codecs: VP8, VP9 + */ + VP8E_GET_LAST_QUANTIZER, + + /*!\brief Codec control function to get last quantizer chosen by the encoder. + * + * Return value uses the 0..63 scale as used by the rc_*_quantizer config + * parameters. + * + * Supported in codecs: VP8, VP9 + */ + VP8E_GET_LAST_QUANTIZER_64, + + /*!\brief Codec control function to set the max no of frames to create arf. + * + * Supported in codecs: VP8, VP9 + */ + VP8E_SET_ARNR_MAXFRAMES, + + /*!\brief Codec control function to set the filter strength for the arf. + * + * Supported in codecs: VP8, VP9 + */ + VP8E_SET_ARNR_STRENGTH, + + /*!\deprecated control function to set the filter type to use for the arf. */ + VP8E_SET_ARNR_TYPE, + + /*!\brief Codec control function to set visual tuning. + * + * Supported in codecs: VP8, VP9 + */ + VP8E_SET_TUNING, + + /*!\brief Codec control function to set constrained / constant quality level. + * + * \attention For this value to be used vpx_codec_enc_cfg_t::rc_end_usage must + * be set to #VPX_CQ or #VPX_Q + * \note Valid range: 0..63 + * + * Supported in codecs: VP8, VP9 + */ + VP8E_SET_CQ_LEVEL, + + /*!\brief Codec control function to set Max data rate for Intra frames. + * + * This value controls additional clamping on the maximum size of a + * keyframe. It is expressed as a percentage of the average + * per-frame bitrate, with the special (and default) value 0 meaning + * unlimited, or no additional clamping beyond the codec's built-in + * algorithm. + * + * For example, to allocate no more than 4.5 frames worth of bitrate + * to a keyframe, set this to 450. + * + * Supported in codecs: VP8, VP9 + */ + VP8E_SET_MAX_INTRA_BITRATE_PCT, + + /*!\brief Codec control function to set reference and update frame flags. + * + * Supported in codecs: VP8 + */ + VP8E_SET_FRAME_FLAGS, + + /*!\brief Codec control function to set max data rate for Inter frames. + * + * This value controls additional clamping on the maximum size of an + * inter frame. It is expressed as a percentage of the average + * per-frame bitrate, with the special (and default) value 0 meaning + * unlimited, or no additional clamping beyond the codec's built-in + * algorithm. + * + * For example, to allow no more than 4.5 frames worth of bitrate + * to an inter frame, set this to 450. + * + * Supported in codecs: VP9 + */ + VP9E_SET_MAX_INTER_BITRATE_PCT, + + /*!\brief Boost percentage for Golden Frame in CBR mode. + * + * This value controls the amount of boost given to Golden Frame in + * CBR mode. It is expressed as a percentage of the average + * per-frame bitrate, with the special (and default) value 0 meaning + * the feature is off, i.e., no golden frame boost in CBR mode and + * average bitrate target is used. + * + * For example, to allow 100% more bits, i.e., 2X, in a golden frame + * than average frame, set this to 100. + * + * Supported in codecs: VP9 + */ + VP9E_SET_GF_CBR_BOOST_PCT, + + /*!\brief Codec control function to set the temporal layer id. + * + * For temporal scalability: this control allows the application to set the + * layer id for each frame to be encoded. Note that this control must be set + * for every frame prior to encoding. The usage of this control function + * supersedes the internal temporal pattern counter, which is now deprecated. + * + * Supported in codecs: VP8 + */ + VP8E_SET_TEMPORAL_LAYER_ID, + + /*!\brief Codec control function to set encoder screen content mode. + * + * 0: off, 1: On, 2: On with more aggressive rate control. + * + * Supported in codecs: VP8 + */ + VP8E_SET_SCREEN_CONTENT_MODE, + + /*!\brief Codec control function to set lossless encoding mode. + * + * VP9 can operate in lossless encoding mode, in which the bitstream + * produced will be able to decode and reconstruct a perfect copy of + * input source. This control function provides a mean to switch encoder + * into lossless coding mode(1) or normal coding mode(0) that may be lossy. + * 0 = lossy coding mode + * 1 = lossless coding mode + * + * By default, encoder operates in normal coding mode (maybe lossy). + * + * Supported in codecs: VP9 + */ + VP9E_SET_LOSSLESS, + + /*!\brief Codec control function to set number of tile columns. + * + * In encoding and decoding, VP9 allows an input image frame be partitioned + * into separated vertical tile columns, which can be encoded or decoded + * independently. This enables easy implementation of parallel encoding and + * decoding. This control requests the encoder to use column tiles in + * encoding an input frame, with number of tile columns (in Log2 unit) as + * the parameter: + * 0 = 1 tile column + * 1 = 2 tile columns + * 2 = 4 tile columns + * ..... + * n = 2**n tile columns + * The requested tile columns will be capped by the encoder based on image + * size limitations (The minimum width of a tile column is 256 pixels, the + * maximum is 4096). + * + * By default, the value is 6, i.e., the maximum number of tiles supported by + * the resolution. + * + * Supported in codecs: VP9 + */ + VP9E_SET_TILE_COLUMNS, + + /*!\brief Codec control function to set number of tile rows. + * + * In encoding and decoding, VP9 allows an input image frame be partitioned + * into separated horizontal tile rows. Tile rows are encoded or decoded + * sequentially. Even though encoding/decoding of later tile rows depends on + * earlier ones, this allows the encoder to output data packets for tile rows + * prior to completely processing all tile rows in a frame, thereby reducing + * the latency in processing between input and output. The parameter + * for this control describes the number of tile rows, which has a valid + * range [0, 2]: + * 0 = 1 tile row + * 1 = 2 tile rows + * 2 = 4 tile rows + * + * By default, the value is 0, i.e. one single row tile for entire image. + * + * Supported in codecs: VP9 + */ + VP9E_SET_TILE_ROWS, + + /*!\brief Codec control function to enable frame parallel decoding feature. + * + * VP9 has a bitstream feature to reduce decoding dependency between frames + * by turning off backward update of probability context used in encoding + * and decoding. This allows staged parallel processing of more than one + * video frame in the decoder. This control function provides a means to + * turn this feature on or off for bitstreams produced by encoder. + * + * By default, this feature is on. + * + * Supported in codecs: VP9 + */ + VP9E_SET_FRAME_PARALLEL_DECODING, + + /*!\brief Codec control function to set adaptive quantization mode. + * + * VP9 has a segment based feature that allows encoder to adaptively change + * quantization parameter for each segment within a frame to improve the + * subjective quality. This control makes encoder operate in one of the + * several AQ_modes supported. + * + * By default, encoder operates with AQ_Mode 0(adaptive quantization off). + * + * Supported in codecs: VP9 + */ + VP9E_SET_AQ_MODE, + + /*!\brief Codec control function to enable/disable periodic Q boost. + * + * One VP9 encoder speed feature is to enable quality boost by lowering + * frame level Q periodically. This control function provides a mean to + * turn on/off this feature. + * 0 = off + * 1 = on + * + * By default, the encoder is allowed to use this feature for appropriate + * encoding modes. + * + * Supported in codecs: VP9 + */ + VP9E_SET_FRAME_PERIODIC_BOOST, + + /*!\brief Codec control function to set noise sensitivity. + * + * 0: off, 1: On(YOnly), 2: For SVC only, on top two spatial layers(YOnly) + * + * Supported in codecs: VP9 + */ + VP9E_SET_NOISE_SENSITIVITY, + + /*!\brief Codec control function to turn on/off SVC in encoder. + * \note Return value is VPX_CODEC_INVALID_PARAM if the encoder does not + * support SVC in its current encoding mode + * 0: off, 1: on + * + * Supported in codecs: VP9 + */ + VP9E_SET_SVC, + + /*!\brief Codec control function to pass an ROI map to encoder. + * + * Supported in codecs: VP9 + */ + VP9E_SET_ROI_MAP, + + /*!\brief Codec control function to set parameters for SVC. + * \note Parameters contain min_q, max_q, scaling factor for each of the + * SVC layers. + * + * Supported in codecs: VP9 + */ + VP9E_SET_SVC_PARAMETERS, + + /*!\brief Codec control function to set svc layer for spatial and temporal. + * \note Valid ranges: 0..#vpx_codec_enc_cfg::ss_number_layers for spatial + * layer and 0..#vpx_codec_enc_cfg::ts_number_layers for + * temporal layer. + * + * Supported in codecs: VP9 + */ + VP9E_SET_SVC_LAYER_ID, + + /*!\brief Codec control function to set content type. + * \note Valid parameter range: + * VP9E_CONTENT_DEFAULT = Regular video content (Default) + * VP9E_CONTENT_SCREEN = Screen capture content + * VP9E_CONTENT_FILM = Film content: improves grain retention + * + * Supported in codecs: VP9 + */ + VP9E_SET_TUNE_CONTENT, + + /*!\brief Codec control function to get svc layer ID. + * \note The layer ID returned is for the data packet from the registered + * callback function. + * + * Supported in codecs: VP9 + */ + VP9E_GET_SVC_LAYER_ID, + + /*!\brief Codec control function to register callback to get per layer packet. + * \note Parameter for this control function is a structure with a callback + * function and a pointer to private data used by the callback. + * + * Supported in codecs: VP9 + */ + VP9E_REGISTER_CX_CALLBACK, + + /*!\brief Codec control function to set color space info. + * \note Valid ranges: 0..7, default is "UNKNOWN". + * 0 = UNKNOWN, + * 1 = BT_601 + * 2 = BT_709 + * 3 = SMPTE_170 + * 4 = SMPTE_240 + * 5 = BT_2020 + * 6 = RESERVED + * 7 = SRGB + * + * Supported in codecs: VP9 + */ + VP9E_SET_COLOR_SPACE, + + /*!\brief Codec control function to set minimum interval between GF/ARF frames + * + * By default the value is set as 4. + * + * Supported in codecs: VP9 + */ + VP9E_SET_MIN_GF_INTERVAL = 48, + + /*!\brief Codec control function to set minimum interval between GF/ARF frames + * + * By default the value is set as 16. + * + * Supported in codecs: VP9 + */ + VP9E_SET_MAX_GF_INTERVAL, + + /*!\brief Codec control function to get an Active map back from the encoder. + * + * Supported in codecs: VP9 + */ + VP9E_GET_ACTIVEMAP, + + /*!\brief Codec control function to set color range bit. + * \note Valid ranges: 0..1, default is 0 + * 0 = Limited range (16..235 or HBD equivalent) + * 1 = Full range (0..255 or HBD equivalent) + * + * Supported in codecs: VP9 + */ + VP9E_SET_COLOR_RANGE, + + /*!\brief Codec control function to set the frame flags and buffer indices + * for spatial layers. The frame flags and buffer indices are set using the + * struct #vpx_svc_ref_frame_config defined below. + * + * Supported in codecs: VP9 + */ + VP9E_SET_SVC_REF_FRAME_CONFIG, + + /*!\brief Codec control function to set intended rendering image size. + * + * By default, this is identical to the image size in pixels. + * + * Supported in codecs: VP9 + */ + VP9E_SET_RENDER_SIZE, + + /*!\brief Codec control function to set target level. + * + * 255: off (default); 0: only keep level stats; 10: target for level 1.0; + * 11: target for level 1.1; ... 62: target for level 6.2 + * + * Supported in codecs: VP9 + */ + VP9E_SET_TARGET_LEVEL, + + /*!\brief Codec control function to set row level multi-threading. + * + * 0 : off, 1 : on + * + * Supported in codecs: VP9 + */ + VP9E_SET_ROW_MT, + + /*!\brief Codec control function to get bitstream level. + * + * Supported in codecs: VP9 + */ + VP9E_GET_LEVEL, + + /*!\brief Codec control function to enable/disable special mode for altref + * adaptive quantization. You can use it with --aq-mode concurrently. + * + * Enable special adaptive quantization for altref frames based on their + * expected prediction quality for the future frames. + * + * Supported in codecs: VP9 + */ + VP9E_SET_ALT_REF_AQ, + + /*!\brief Boost percentage for Golden Frame in CBR mode. + * + * This value controls the amount of boost given to Golden Frame in + * CBR mode. It is expressed as a percentage of the average + * per-frame bitrate, with the special (and default) value 0 meaning + * the feature is off, i.e., no golden frame boost in CBR mode and + * average bitrate target is used. + * + * For example, to allow 100% more bits, i.e., 2X, in a golden frame + * than average frame, set this to 100. + * + * Supported in codecs: VP8 + */ + VP8E_SET_GF_CBR_BOOST_PCT, + + /*!\brief Codec control function to enable the extreme motion vector unit test + * in VP9. Please note that this is only used in motion vector unit test. + * + * 0 : off, 1 : MAX_EXTREME_MV, 2 : MIN_EXTREME_MV + * + * Supported in codecs: VP9 + */ + VP9E_ENABLE_MOTION_VECTOR_UNIT_TEST, + + /*!\brief Codec control function to constrain the inter-layer prediction + * (prediction of lower spatial resolution) in VP9 SVC. + * + * 0 : inter-layer prediction on, 1 : off, 2 : off only on non-key frames + * + * Supported in codecs: VP9 + */ + VP9E_SET_SVC_INTER_LAYER_PRED, + + /*!\brief Codec control function to set mode and thresholds for frame + * dropping in SVC. Drop frame thresholds are set per-layer. Mode is set as: + * 0 : layer-dependent dropping, 1 : constrained dropping, current layer drop + * forces drop on all upper layers. Default mode is 0. + * + * Supported in codecs: VP9 + */ + VP9E_SET_SVC_FRAME_DROP_LAYER, + + /*!\brief Codec control function to get the refresh and reference flags and + * the buffer indices, up to the last encoded spatial layer. + * + * Supported in codecs: VP9 + */ + VP9E_GET_SVC_REF_FRAME_CONFIG, + + /*!\brief Codec control function to enable/disable use of golden reference as + * a second temporal reference for SVC. Only used when inter-layer prediction + * is disabled on INTER frames. + * + * 0: Off, 1: Enabled (default) + * + * Supported in codecs: VP9 + */ + VP9E_SET_SVC_GF_TEMPORAL_REF, + + /*!\brief Codec control function to enable spatial layer sync frame, for any + * spatial layer. Enabling it for layer k means spatial layer k will disable + * all temporal prediction, but keep the inter-layer prediction. It will + * refresh any temporal reference buffer for that layer, and reset the + * temporal layer for the superframe to 0. Setting the layer sync for base + * spatial layer forces a key frame. Default is off (0) for all spatial + * layers. Spatial layer sync flag is reset to 0 after each encoded layer, + * so when control is invoked it is only used for the current superframe. + * + * 0: Off (default), 1: Enabled + * + * Supported in codecs: VP9 + */ + VP9E_SET_SVC_SPATIAL_LAYER_SYNC, + + /*!\brief Codec control function to enable temporal dependency model. + * + * Vp9 allows the encoder to run temporal dependency model and use it to + * improve the compression performance. To enable, set this parameter to be + * 1. The default value is set to be 1. + */ + VP9E_SET_TPL, + + /*!\brief Codec control function to enable post encode frame drop. + * + * This will allow encoder to drop frame after it's encoded. + * + * 0: Off (default), 1: Enabled + * + * Supported in codecs: VP9 + */ + VP9E_SET_POSTENCODE_DROP, + + /*!\brief Codec control function to set delta q for uv. + * + * Cap it at +/-15. + * + * Supported in codecs: VP9 + */ + VP9E_SET_DELTA_Q_UV, + + /*!\brief Codec control function to disable increase Q on overshoot in CBR. + * + * 0: On (default), 1: Disable. + * + * Supported in codecs: VP9 + */ + VP9E_SET_DISABLE_OVERSHOOT_MAXQ_CBR, + + /*!\brief Codec control function to disable loopfilter. + * + * 0: Loopfilter on all frames, 1: Disable on non reference frames. + * 2: Disable on all frames. + * + * Supported in codecs: VP9 + */ + VP9E_SET_DISABLE_LOOPFILTER, + + /*!\brief Codec control function to enable external rate control library. + * + * args[0]: path of the rate control library + * + * args[1]: private config of the rate control library + * + * Supported in codecs: VP9 + */ + VP9E_SET_EXTERNAL_RATE_CONTROL, + + /*!\brief Codec control to disable internal features in rate control. + * + * This will do 3 things, only for 1 pass: + * - Turn off low motion computation + * - Turn off gf update constraint on key frame frequency + * - Turn off content mode for cyclic refresh + * + * With those, the rate control is expected to work exactly the same as the + * interface provided in ratectrl_rtc.cc/h + * + * Supported in codecs: VP9 + */ + VP9E_SET_RTC_EXTERNAL_RATECTRL, + + /*!\brief Codec control function to get loopfilter level in the encoder. + * + * Supported in codecs: VP9 + */ + VP9E_GET_LOOPFILTER_LEVEL, + + /*!\brief Codec control to get last quantizers for all spatial layers. + * + * Return value uses an array of internal quantizers scale defined by the + * codec, for all spatial layers. + * The size of the array passed in should be #VPX_SS_MAX_LAYERS. + * + * Supported in codecs: VP9 + */ + VP9E_GET_LAST_QUANTIZER_SVC_LAYERS, + + /*!\brief Codec control to disable internal features in rate control. + * + * This will turn off cyclic refresh for vp8. + * + * With this, the rate control is expected to work exactly the same as the + * interface provided in vp8_ratectrl_rtc.cc/h + * + * Supported in codecs: VP8 + */ + VP8E_SET_RTC_EXTERNAL_RATECTRL, + + /*!\brief Codec control to set quantizer for the next frame. + * + * This will turn off cyclic refresh. Only applicable to 1-pass without + * spatial layers. + * + * Supported in codecs: VP9 + * + */ + VP9E_SET_QUANTIZER_ONE_PASS, + + /*!\brief Codec control function to enable key frame temporal filtering. + * + * Vp9 allows the encoder to run key frame temporal filtering and use it to + * improve the compression performance. To enable, set this parameter to be + * 1. The default value is set to be 0. + */ + VP9E_SET_KEY_FRAME_FILTERING, + + /*!\brief Codec control function to validate HBD input. + * + * VP9 allows the encoder to validate the high bitdepth (HBD) input and + * ensure that every pixel is within the valid range. To disable/enable, + * set this parameter to 0/1. The default value is set to be 1. + * + * Supported in codecs: VP9 + */ + VP9E_SET_VALIDATE_HBD_INPUT, +}; + +/*!\brief vpx 1-D scaling mode + * + * This set of constants define 1-D vpx scaling modes + */ +typedef enum vpx_scaling_mode_1d { + VP8E_NORMAL = 0, + VP8E_FOURFIVE = 1, + VP8E_THREEFIVE = 2, + VP8E_ONETWO = 3 +} VPX_SCALING_MODE; + +/*!\brief Temporal layering mode enum for VP9 SVC. + * + * This set of macros define the different temporal layering modes. + * Supported codecs: VP9 (in SVC mode) + * + */ +typedef enum vp9e_temporal_layering_mode { + /*!\brief No temporal layering. + * Used when only spatial layering is used. + */ + VP9E_TEMPORAL_LAYERING_MODE_NOLAYERING = 0, + + /*!\brief Bypass mode. + * Used when application needs to control spatial and temporal layering. + */ + VP9E_TEMPORAL_LAYERING_MODE_BYPASS = 1, + + /*!\brief 0-1-0-1... temporal layering scheme with two temporal layers. + */ + VP9E_TEMPORAL_LAYERING_MODE_0101 = 2, + + /*!\brief 0-2-1-2... temporal layering scheme with three temporal layers. + */ + VP9E_TEMPORAL_LAYERING_MODE_0212 = 3 +} VP9E_TEMPORAL_LAYERING_MODE; + +/*!\brief vpx region of interest map + * + * These defines the data structures for the region of interest map + * + */ + +typedef struct vpx_roi_map { + /*! If ROI is enabled. */ + uint8_t enabled; + /*! An id between 0-3 (0-7 for vp9) for each 16x16 (8x8 for VP9) + * region within a frame. */ + unsigned char *roi_map; + unsigned int rows; /**< Number of rows. */ + unsigned int cols; /**< Number of columns. */ + /*! VP8 only uses the first 4 segments. VP9 uses 8 segments. */ + int delta_q[8]; /**< Quantizer deltas. Valid range: [-63, 63].*/ + int delta_lf[8]; /**< Loop filter deltas. Valid range: [-63, 63].*/ + /*! skip and ref frame segment is only used in VP9. */ + int skip[8]; /**< Skip this block. */ + int ref_frame[8]; /**< Reference frame for this block. */ + /*! Static breakout threshold for each segment. Only used in VP8. */ + unsigned int static_threshold[4]; +} vpx_roi_map_t; + +/*!\brief vpx active region map + * + * These defines the data structures for active region map + * + */ + +typedef struct vpx_active_map { + /*!\brief specify an on (1) or off (0) each 16x16 region within a frame */ + unsigned char *active_map; + unsigned int rows; /**< number of rows */ + unsigned int cols; /**< number of cols */ +} vpx_active_map_t; + +/*!\brief vpx image scaling mode + * + * This defines the data structure for image scaling mode + * + */ +typedef struct vpx_scaling_mode { + VPX_SCALING_MODE h_scaling_mode; /**< horizontal scaling mode */ + VPX_SCALING_MODE v_scaling_mode; /**< vertical scaling mode */ +} vpx_scaling_mode_t; + +/*!\brief VP8 token partition mode + * + * This defines VP8 partitioning mode for compressed data, i.e., the number of + * sub-streams in the bitstream. Used for parallelized decoding. + * + */ + +typedef enum { + VP8_ONE_TOKENPARTITION = 0, + VP8_TWO_TOKENPARTITION = 1, + VP8_FOUR_TOKENPARTITION = 2, + VP8_EIGHT_TOKENPARTITION = 3 +} vp8e_token_partitions; + +/*!\brief VP9 encoder content type */ +typedef enum { + VP9E_CONTENT_DEFAULT, + VP9E_CONTENT_SCREEN, + VP9E_CONTENT_FILM, + VP9E_CONTENT_INVALID +} vp9e_tune_content; + +/*!\brief VP8 model tuning parameters + * + * Changes the encoder to tune for certain types of input material. + * + */ +typedef enum { VP8_TUNE_PSNR, VP8_TUNE_SSIM } vp8e_tuning; + +/*!\brief vp9 svc layer parameters + * + * This defines the spatial and temporal layer id numbers for svc encoding. + * This is used with the #VP9E_SET_SVC_LAYER_ID control to set the spatial and + * temporal layer id for the current frame. + * + */ +typedef struct vpx_svc_layer_id { + int spatial_layer_id; /**< First spatial layer to start encoding. */ + // TODO(jianj): Deprecated, to be removed. + int temporal_layer_id; /**< Temporal layer id number. */ + int temporal_layer_id_per_spatial[VPX_SS_MAX_LAYERS]; /**< Temp layer id. */ +} vpx_svc_layer_id_t; + +/*!\brief vp9 svc frame flag parameters. + * + * This defines the frame flags and buffer indices for each spatial layer for + * svc encoding. + * This is used with the #VP9E_SET_SVC_REF_FRAME_CONFIG control to set frame + * flags and buffer indices for each spatial layer for the current (super)frame. + * + */ +typedef struct vpx_svc_ref_frame_config { + int lst_fb_idx[VPX_SS_MAX_LAYERS]; /**< Last buffer index. */ + int gld_fb_idx[VPX_SS_MAX_LAYERS]; /**< Golden buffer index. */ + int alt_fb_idx[VPX_SS_MAX_LAYERS]; /**< Altref buffer index. */ + int update_buffer_slot[VPX_SS_MAX_LAYERS]; /**< Update reference frames. */ + // TODO(jianj): Remove update_last/golden/alt_ref, these are deprecated. + int update_last[VPX_SS_MAX_LAYERS]; /**< Update last. */ + int update_golden[VPX_SS_MAX_LAYERS]; /**< Update golden. */ + int update_alt_ref[VPX_SS_MAX_LAYERS]; /**< Update altref. */ + int reference_last[VPX_SS_MAX_LAYERS]; /**< Last as reference. */ + int reference_golden[VPX_SS_MAX_LAYERS]; /**< Golden as reference. */ + int reference_alt_ref[VPX_SS_MAX_LAYERS]; /**< Altref as reference. */ + int64_t duration[VPX_SS_MAX_LAYERS]; /**< Duration per spatial layer. */ +} vpx_svc_ref_frame_config_t; + +/*!\brief VP9 svc frame dropping mode. + * + * This defines the frame drop mode for SVC. + * + */ +typedef enum { + CONSTRAINED_LAYER_DROP, + /**< Upper layers are constrained to drop if current layer drops. */ + LAYER_DROP, /**< Any spatial layer can drop. */ + FULL_SUPERFRAME_DROP, /**< Only full superframe can drop. */ + CONSTRAINED_FROM_ABOVE_DROP, + /**< Lower layers are constrained to drop if current layer drops. */ +} SVC_LAYER_DROP_MODE; + +/*!\brief vp9 svc frame dropping parameters. + * + * This defines the frame drop thresholds for each spatial layer, and + * the frame dropping mode: 0 = layer based frame dropping (default), + * 1 = constrained dropping where current layer drop forces all upper + * spatial layers to drop. + */ +typedef struct vpx_svc_frame_drop { + int framedrop_thresh[VPX_SS_MAX_LAYERS]; /**< Frame drop thresholds */ + SVC_LAYER_DROP_MODE + framedrop_mode; /**< Layer-based or constrained dropping. */ + int max_consec_drop; /**< Maximum consecutive drops, for any layer. */ +} vpx_svc_frame_drop_t; + +/*!\brief vp9 svc spatial layer sync parameters. + * + * This defines the spatial layer sync flag, defined per spatial layer. + * + */ +typedef struct vpx_svc_spatial_layer_sync { + int spatial_layer_sync[VPX_SS_MAX_LAYERS]; /**< Sync layer flags */ + int base_layer_intra_only; /**< Flag for setting Intra-only frame on base */ +} vpx_svc_spatial_layer_sync_t; + +/*!\cond */ +/*!\brief VP8 encoder control function parameter type + * + * Defines the data types that VP8E control functions take. Note that + * additional common controls are defined in vp8.h + * + */ + +VPX_CTRL_USE_TYPE(VP8E_SET_ROI_MAP, vpx_roi_map_t *) +#define VPX_CTRL_VP8E_SET_ROI_MAP +VPX_CTRL_USE_TYPE(VP8E_SET_ACTIVEMAP, vpx_active_map_t *) +#define VPX_CTRL_VP8E_SET_ACTIVEMAP +VPX_CTRL_USE_TYPE(VP8E_SET_SCALEMODE, vpx_scaling_mode_t *) +#define VPX_CTRL_VP8E_SET_SCALEMODE +VPX_CTRL_USE_TYPE(VP8E_SET_CPUUSED, int) +#define VPX_CTRL_VP8E_SET_CPUUSED +VPX_CTRL_USE_TYPE(VP8E_SET_ENABLEAUTOALTREF, unsigned int) +#define VPX_CTRL_VP8E_SET_ENABLEAUTOALTREF +VPX_CTRL_USE_TYPE(VP8E_SET_NOISE_SENSITIVITY, unsigned int) +#define VPX_CTRL_VP8E_SET_NOISE_SENSITIVITY +VPX_CTRL_USE_TYPE(VP8E_SET_SHARPNESS, unsigned int) +#define VPX_CTRL_VP8E_SET_SHARPNESS +VPX_CTRL_USE_TYPE(VP8E_SET_STATIC_THRESHOLD, unsigned int) +#define VPX_CTRL_VP8E_SET_STATIC_THRESHOLD +VPX_CTRL_USE_TYPE(VP8E_SET_TOKEN_PARTITIONS, int) /* vp8e_token_partitions */ +#define VPX_CTRL_VP8E_SET_TOKEN_PARTITIONS +VPX_CTRL_USE_TYPE(VP8E_GET_LAST_QUANTIZER, int *) +#define VPX_CTRL_VP8E_GET_LAST_QUANTIZER +VPX_CTRL_USE_TYPE(VP8E_GET_LAST_QUANTIZER_64, int *) +#define VPX_CTRL_VP8E_GET_LAST_QUANTIZER_64 +VPX_CTRL_USE_TYPE(VP8E_SET_ARNR_MAXFRAMES, unsigned int) +#define VPX_CTRL_VP8E_SET_ARNR_MAXFRAMES +VPX_CTRL_USE_TYPE(VP8E_SET_ARNR_STRENGTH, unsigned int) +#define VPX_CTRL_VP8E_SET_ARNR_STRENGTH +VPX_CTRL_USE_TYPE_DEPRECATED(VP8E_SET_ARNR_TYPE, unsigned int) +#define VPX_CTRL_VP8E_SET_ARNR_TYPE +VPX_CTRL_USE_TYPE(VP8E_SET_TUNING, int) /* vp8e_tuning */ +#define VPX_CTRL_VP8E_SET_TUNING +VPX_CTRL_USE_TYPE(VP8E_SET_CQ_LEVEL, unsigned int) +#define VPX_CTRL_VP8E_SET_CQ_LEVEL +VPX_CTRL_USE_TYPE(VP8E_SET_MAX_INTRA_BITRATE_PCT, unsigned int) +#define VPX_CTRL_VP8E_SET_MAX_INTRA_BITRATE_PCT +VPX_CTRL_USE_TYPE(VP8E_SET_FRAME_FLAGS, int) +#define VPX_CTRL_VP8E_SET_FRAME_FLAGS +VPX_CTRL_USE_TYPE(VP9E_SET_MAX_INTER_BITRATE_PCT, unsigned int) +#define VPX_CTRL_VP9E_SET_MAX_INTER_BITRATE_PCT +VPX_CTRL_USE_TYPE(VP9E_SET_GF_CBR_BOOST_PCT, unsigned int) +#define VPX_CTRL_VP9E_SET_GF_CBR_BOOST_PCT +VPX_CTRL_USE_TYPE(VP8E_SET_TEMPORAL_LAYER_ID, int) +#define VPX_CTRL_VP8E_SET_TEMPORAL_LAYER_ID +VPX_CTRL_USE_TYPE(VP8E_SET_SCREEN_CONTENT_MODE, unsigned int) +#define VPX_CTRL_VP8E_SET_SCREEN_CONTENT_MODE +VPX_CTRL_USE_TYPE(VP9E_SET_LOSSLESS, unsigned int) +#define VPX_CTRL_VP9E_SET_LOSSLESS +VPX_CTRL_USE_TYPE(VP9E_SET_TILE_COLUMNS, int) +#define VPX_CTRL_VP9E_SET_TILE_COLUMNS +VPX_CTRL_USE_TYPE(VP9E_SET_TILE_ROWS, int) +#define VPX_CTRL_VP9E_SET_TILE_ROWS +VPX_CTRL_USE_TYPE(VP9E_SET_FRAME_PARALLEL_DECODING, unsigned int) +#define VPX_CTRL_VP9E_SET_FRAME_PARALLEL_DECODING +VPX_CTRL_USE_TYPE(VP9E_SET_AQ_MODE, unsigned int) +#define VPX_CTRL_VP9E_SET_AQ_MODE +VPX_CTRL_USE_TYPE(VP9E_SET_FRAME_PERIODIC_BOOST, unsigned int) +#define VPX_CTRL_VP9E_SET_FRAME_PERIODIC_BOOST +VPX_CTRL_USE_TYPE(VP9E_SET_NOISE_SENSITIVITY, unsigned int) +#define VPX_CTRL_VP9E_SET_NOISE_SENSITIVITY +VPX_CTRL_USE_TYPE(VP9E_SET_SVC, int) +#define VPX_CTRL_VP9E_SET_SVC +VPX_CTRL_USE_TYPE(VP9E_SET_ROI_MAP, vpx_roi_map_t *) +#define VPX_CTRL_VP9E_SET_ROI_MAP +VPX_CTRL_USE_TYPE(VP9E_SET_SVC_PARAMETERS, void *) +#define VPX_CTRL_VP9E_SET_SVC_PARAMETERS +VPX_CTRL_USE_TYPE(VP9E_SET_SVC_LAYER_ID, vpx_svc_layer_id_t *) +#define VPX_CTRL_VP9E_SET_SVC_LAYER_ID +VPX_CTRL_USE_TYPE(VP9E_SET_TUNE_CONTENT, int) /* vp9e_tune_content */ +#define VPX_CTRL_VP9E_SET_TUNE_CONTENT +VPX_CTRL_USE_TYPE(VP9E_GET_SVC_LAYER_ID, vpx_svc_layer_id_t *) +#define VPX_CTRL_VP9E_GET_SVC_LAYER_ID +VPX_CTRL_USE_TYPE(VP9E_REGISTER_CX_CALLBACK, void *) +#define VPX_CTRL_VP9E_REGISTER_CX_CALLBACK +VPX_CTRL_USE_TYPE(VP9E_SET_COLOR_SPACE, int) +#define VPX_CTRL_VP9E_SET_COLOR_SPACE +VPX_CTRL_USE_TYPE(VP9E_SET_MIN_GF_INTERVAL, unsigned int) +#define VPX_CTRL_VP9E_SET_MIN_GF_INTERVAL +VPX_CTRL_USE_TYPE(VP9E_SET_MAX_GF_INTERVAL, unsigned int) +#define VPX_CTRL_VP9E_SET_MAX_GF_INTERVAL +VPX_CTRL_USE_TYPE(VP9E_GET_ACTIVEMAP, vpx_active_map_t *) +#define VPX_CTRL_VP9E_GET_ACTIVEMAP +VPX_CTRL_USE_TYPE(VP9E_SET_COLOR_RANGE, int) +#define VPX_CTRL_VP9E_SET_COLOR_RANGE +VPX_CTRL_USE_TYPE(VP9E_SET_SVC_REF_FRAME_CONFIG, vpx_svc_ref_frame_config_t *) +#define VPX_CTRL_VP9E_SET_SVC_REF_FRAME_CONFIG +VPX_CTRL_USE_TYPE(VP9E_SET_RENDER_SIZE, int *) +#define VPX_CTRL_VP9E_SET_RENDER_SIZE +VPX_CTRL_USE_TYPE(VP9E_SET_TARGET_LEVEL, unsigned int) +#define VPX_CTRL_VP9E_SET_TARGET_LEVEL +VPX_CTRL_USE_TYPE(VP9E_SET_ROW_MT, unsigned int) +#define VPX_CTRL_VP9E_SET_ROW_MT +VPX_CTRL_USE_TYPE(VP9E_GET_LEVEL, int *) +#define VPX_CTRL_VP9E_GET_LEVEL +VPX_CTRL_USE_TYPE(VP9E_SET_ALT_REF_AQ, int) +#define VPX_CTRL_VP9E_SET_ALT_REF_AQ +VPX_CTRL_USE_TYPE(VP8E_SET_GF_CBR_BOOST_PCT, unsigned int) +#define VPX_CTRL_VP8E_SET_GF_CBR_BOOST_PCT +VPX_CTRL_USE_TYPE(VP9E_ENABLE_MOTION_VECTOR_UNIT_TEST, unsigned int) +#define VPX_CTRL_VP9E_ENABLE_MOTION_VECTOR_UNIT_TEST +VPX_CTRL_USE_TYPE(VP9E_SET_SVC_INTER_LAYER_PRED, unsigned int) +#define VPX_CTRL_VP9E_SET_SVC_INTER_LAYER_PRED +VPX_CTRL_USE_TYPE(VP9E_SET_SVC_FRAME_DROP_LAYER, vpx_svc_frame_drop_t *) +#define VPX_CTRL_VP9E_SET_SVC_FRAME_DROP_LAYER +VPX_CTRL_USE_TYPE(VP9E_GET_SVC_REF_FRAME_CONFIG, vpx_svc_ref_frame_config_t *) +#define VPX_CTRL_VP9E_GET_SVC_REF_FRAME_CONFIG +VPX_CTRL_USE_TYPE(VP9E_SET_SVC_GF_TEMPORAL_REF, unsigned int) +#define VPX_CTRL_VP9E_SET_SVC_GF_TEMPORAL_REF +VPX_CTRL_USE_TYPE(VP9E_SET_SVC_SPATIAL_LAYER_SYNC, + vpx_svc_spatial_layer_sync_t *) +#define VPX_CTRL_VP9E_SET_SVC_SPATIAL_LAYER_SYNC +VPX_CTRL_USE_TYPE(VP9E_SET_TPL, int) +#define VPX_CTRL_VP9E_SET_TPL +VPX_CTRL_USE_TYPE(VP9E_SET_POSTENCODE_DROP, unsigned int) +#define VPX_CTRL_VP9E_SET_POSTENCODE_DROP +VPX_CTRL_USE_TYPE(VP9E_SET_DELTA_Q_UV, int) +#define VPX_CTRL_VP9E_SET_DELTA_Q_UV +VPX_CTRL_USE_TYPE(VP9E_SET_DISABLE_OVERSHOOT_MAXQ_CBR, int) +#define VPX_CTRL_VP9E_SET_DISABLE_OVERSHOOT_MAXQ_CBR +VPX_CTRL_USE_TYPE(VP9E_SET_DISABLE_LOOPFILTER, int) +#define VPX_CTRL_VP9E_SET_DISABLE_LOOPFILTER +VPX_CTRL_USE_TYPE(VP9E_SET_EXTERNAL_RATE_CONTROL, vpx_rc_funcs_t *) +#define VPX_CTRL_VP9E_SET_EXTERNAL_RATE_CONTROL +VPX_CTRL_USE_TYPE(VP9E_SET_RTC_EXTERNAL_RATECTRL, int) +#define VPX_CTRL_VP9E_SET_RTC_EXTERNAL_RATECTRL +VPX_CTRL_USE_TYPE(VP9E_GET_LOOPFILTER_LEVEL, int *) +#define VPX_CTRL_VP9E_GET_LOOPFILTER_LEVEL +VPX_CTRL_USE_TYPE(VP9E_GET_LAST_QUANTIZER_SVC_LAYERS, int *) +#define VPX_CTRL_VP9E_GET_LAST_QUANTIZER_SVC_LAYERS +VPX_CTRL_USE_TYPE(VP8E_SET_RTC_EXTERNAL_RATECTRL, int) +#define VPX_CTRL_VP8E_SET_RTC_EXTERNAL_RATECTRL +VPX_CTRL_USE_TYPE(VP9E_SET_QUANTIZER_ONE_PASS, int) +#define VPX_CTRL_VP9E_SET_QUANTIZER_ONE_PASS +VPX_CTRL_USE_TYPE(VP9E_SET_KEY_FRAME_FILTERING, int) +#define VPX_CTRL_VP9E_SET_KEY_FRAME_FILTERING +VPX_CTRL_USE_TYPE(VP9E_SET_VALIDATE_HBD_INPUT, int) +#define VPX_CTRL_VP9E_SET_VALIDATE_HBD_INPUT + +/*!\endcond */ +/*! @} - end defgroup vp8_encoder */ +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // VPX_VPX_VP8CX_H_ diff --git a/native/third_party/ucrt64/include/vpx/vp8dx.h b/native/third_party/ucrt64/include/vpx/vp8dx.h new file mode 100644 index 0000000..a7e6426 --- /dev/null +++ b/native/third_party/ucrt64/include/vpx/vp8dx.h @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2010 The WebM project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +/*!\defgroup vp8_decoder WebM VP8/VP9 Decoder + * \ingroup vp8 + * + * @{ + */ +/*!\file + * \brief Provides definitions for using VP8 or VP9 within the vpx Decoder + * interface. + */ +#ifndef VPX_VPX_VP8DX_H_ +#define VPX_VPX_VP8DX_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +/* Include controls common to both the encoder and decoder */ +#include "./vp8.h" + +/*!\name Algorithm interface for VP8 + * + * This interface provides the capability to decode VP8 streams. + * @{ + */ + +/*!\brief A single instance of the VP8 decoder. + *\deprecated This access mechanism is provided for backwards compatibility; + * prefer vpx_codec_vp8_dx(). + */ +extern vpx_codec_iface_t vpx_codec_vp8_dx_algo; + +/*!\brief The interface to the VP8 decoder. + */ +extern vpx_codec_iface_t *vpx_codec_vp8_dx(void); +/*!@} - end algorithm interface member group*/ + +/*!\name Algorithm interface for VP9 + * + * This interface provides the capability to decode VP9 streams. + * @{ + */ + +/*!\brief A single instance of the VP9 decoder. + *\deprecated This access mechanism is provided for backwards compatibility; + * prefer vpx_codec_vp9_dx(). + */ +extern vpx_codec_iface_t vpx_codec_vp9_dx_algo; + +/*!\brief The interface to the VP9 decoder. + */ +extern vpx_codec_iface_t *vpx_codec_vp9_dx(void); +/*!@} - end algorithm interface member group*/ + +/*!\enum vp8_dec_control_id + * \brief VP8 decoder control functions + * + * This set of macros define the control functions available for the VP8 + * decoder interface. + * + * \sa #vpx_codec_control + */ +enum vp8_dec_control_id { + /** control function to get info on which reference frames were updated + * by the last decode + */ + VP8D_GET_LAST_REF_UPDATES = VP8_DECODER_CTRL_ID_START, + + /** check if the indicated frame is corrupted */ + VP8D_GET_FRAME_CORRUPTED, + + /** control function to get info on which reference frames were used + * by the last decode + */ + VP8D_GET_LAST_REF_USED, + + /** decryption function to decrypt encoded buffer data immediately + * before decoding. Takes a vpx_decrypt_init, which contains + * a callback function and opaque context pointer. + */ + VPXD_SET_DECRYPTOR, + VP8D_SET_DECRYPTOR = VPXD_SET_DECRYPTOR, + + /** control function to get the dimensions that the current frame is decoded + * at. This may be different to the intended display size for the frame as + * specified in the wrapper or frame header (see VP9D_GET_DISPLAY_SIZE). */ + VP9D_GET_FRAME_SIZE, + + /** control function to get the current frame's intended display dimensions + * (as specified in the wrapper or frame header). This may be different to + * the decoded dimensions of this frame (see VP9D_GET_FRAME_SIZE). */ + VP9D_GET_DISPLAY_SIZE, + + /** control function to get the bit depth of the stream. */ + VP9D_GET_BIT_DEPTH, + + /** control function to set the byte alignment of the planes in the reference + * buffers. Valid values are power of 2, from 32 to 1024. A value of 0 sets + * legacy alignment. I.e. Y plane is aligned to 32 bytes, U plane directly + * follows Y plane, and V plane directly follows U plane. Default value is 0. + */ + VP9_SET_BYTE_ALIGNMENT, + + /** control function to invert the decoding order to from right to left. The + * function is used in a test to confirm the decoding independence of tile + * columns. The function may be used in application where this order + * of decoding is desired. + * + * TODO(yaowu): Rework the unit test that uses this control, and in a future + * release, this test-only control shall be removed. + */ + VP9_INVERT_TILE_DECODE_ORDER, + + /** control function to set the skip loop filter flag. Valid values are + * integers. The decoder will skip the loop filter when its value is set to + * nonzero. If the loop filter is skipped the decoder may accumulate decode + * artifacts. The default value is 0. + */ + VP9_SET_SKIP_LOOP_FILTER, + + /** control function to decode SVC stream up to the x spatial layers, + * where x is passed in through the control, and is 0 for base layer. + */ + VP9_DECODE_SVC_SPATIAL_LAYER, + + /*!\brief Codec control function to get last decoded frame quantizer. + * + * Return value uses internal quantizer scale defined by the codec. + * + * Supported in codecs: VP8, VP9 + */ + VPXD_GET_LAST_QUANTIZER, + + /*!\brief Codec control function to set row level multi-threading. + * + * 0 : off, 1 : on + * + * Supported in codecs: VP9 + */ + VP9D_SET_ROW_MT, + + /*!\brief Codec control function to set loopfilter optimization. + * + * 0 : off, Loop filter is done after all tiles have been decoded + * 1 : on, Loop filter is done immediately after decode without + * waiting for all threads to sync. + * + * Supported in codecs: VP9 + */ + VP9D_SET_LOOP_FILTER_OPT, + + VP8_DECODER_CTRL_ID_MAX +}; + +/** Decrypt n bytes of data from input -> output, using the decrypt_state + * passed in VPXD_SET_DECRYPTOR. + */ +typedef void (*vpx_decrypt_cb)(void *decrypt_state, const unsigned char *input, + unsigned char *output, int count); + +/*!\brief Structure to hold decryption state + * + * Defines a structure to hold the decryption state and access function. + */ +typedef struct vpx_decrypt_init { + /*! Decrypt callback. */ + vpx_decrypt_cb decrypt_cb; + + /*! Decryption state. */ + void *decrypt_state; +} vpx_decrypt_init; + +/*!\cond */ +/*!\brief VP8 decoder control function parameter type + * + * Defines the data types that VP8D control functions take. Note that + * additional common controls are defined in vp8.h + * + */ + +VPX_CTRL_USE_TYPE(VP8D_GET_LAST_REF_UPDATES, int *) +#define VPX_CTRL_VP8D_GET_LAST_REF_UPDATES +VPX_CTRL_USE_TYPE(VP8D_GET_FRAME_CORRUPTED, int *) +#define VPX_CTRL_VP8D_GET_FRAME_CORRUPTED +VPX_CTRL_USE_TYPE(VP8D_GET_LAST_REF_USED, int *) +#define VPX_CTRL_VP8D_GET_LAST_REF_USED +VPX_CTRL_USE_TYPE(VPXD_SET_DECRYPTOR, vpx_decrypt_init *) +#define VPX_CTRL_VPXD_SET_DECRYPTOR +VPX_CTRL_USE_TYPE(VP8D_SET_DECRYPTOR, vpx_decrypt_init *) +#define VPX_CTRL_VP8D_SET_DECRYPTOR +VPX_CTRL_USE_TYPE(VP9D_GET_FRAME_SIZE, int *) +#define VPX_CTRL_VP9D_GET_FRAME_SIZE +VPX_CTRL_USE_TYPE(VP9D_GET_DISPLAY_SIZE, int *) +#define VPX_CTRL_VP9D_GET_DISPLAY_SIZE +VPX_CTRL_USE_TYPE(VP9D_GET_BIT_DEPTH, unsigned int *) +#define VPX_CTRL_VP9D_GET_BIT_DEPTH +VPX_CTRL_USE_TYPE(VP9_SET_BYTE_ALIGNMENT, int) +#define VPX_CTRL_VP9_SET_BYTE_ALIGNMENT +VPX_CTRL_USE_TYPE(VP9_INVERT_TILE_DECODE_ORDER, int) +#define VPX_CTRL_VP9_INVERT_TILE_DECODE_ORDER +VPX_CTRL_USE_TYPE(VP9_SET_SKIP_LOOP_FILTER, int) +#define VPX_CTRL_VP9_SET_SKIP_LOOP_FILTER +VPX_CTRL_USE_TYPE(VP9_DECODE_SVC_SPATIAL_LAYER, int) +#define VPX_CTRL_VP9_DECODE_SVC_SPATIAL_LAYER +VPX_CTRL_USE_TYPE(VPXD_GET_LAST_QUANTIZER, int *) +#define VPX_CTRL_VPXD_GET_LAST_QUANTIZER +VPX_CTRL_USE_TYPE(VP9D_SET_ROW_MT, int) +#define VPX_CTRL_VP9_DECODE_SET_ROW_MT +VPX_CTRL_USE_TYPE(VP9D_SET_LOOP_FILTER_OPT, int) +#define VPX_CTRL_VP9_SET_LOOP_FILTER_OPT + +/*!\endcond */ +/*! @} - end defgroup vp8_decoder */ + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // VPX_VPX_VP8DX_H_ diff --git a/native/third_party/ucrt64/include/vpx/vpx_codec.h b/native/third_party/ucrt64/include/vpx/vpx_codec.h new file mode 100644 index 0000000..d9c9256 --- /dev/null +++ b/native/third_party/ucrt64/include/vpx/vpx_codec.h @@ -0,0 +1,475 @@ +/* + * Copyright (c) 2010 The WebM project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +/*!\defgroup codec Common Algorithm Interface + * This abstraction allows applications to easily support multiple video + * formats with minimal code duplication. This section describes the interface + * common to all codecs (both encoders and decoders). + * @{ + */ + +/*!\file + * \brief Describes the codec algorithm interface to applications. + * + * This file describes the interface between an application and a + * video codec algorithm. + * + * An application instantiates a specific codec instance by using + * vpx_codec_dec_init() or vpx_codec_enc_init() and a pointer to the + * algorithm's interface structure: + *
+ *     my_app.c:
+ *       extern vpx_codec_iface_t my_codec;
+ *       {
+ *           vpx_codec_ctx_t algo;
+ *           int threads = 4;
+ *           vpx_codec_dec_cfg_t cfg = { threads, 0, 0 };
+ *           res = vpx_codec_dec_init(&algo, &my_codec, &cfg, 0);
+ *       }
+ *     
+ * + * Once initialized, the instance is manged using other functions from + * the vpx_codec_* family. + */ +#ifndef VPX_VPX_VPX_CODEC_H_ +#define VPX_VPX_VPX_CODEC_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include "./vpx_image.h" +#include "./vpx_integer.h" + +/*!\brief Decorator indicating a function is deprecated */ +#ifndef VPX_DEPRECATED +#if defined(__GNUC__) +#define VPX_DEPRECATED __attribute__((deprecated)) +#elif defined(_MSC_VER) +#define VPX_DEPRECATED +#else +#define VPX_DEPRECATED +#endif +#endif /* VPX_DEPRECATED */ + +#ifndef VPX_DECLSPEC_DEPRECATED +#if defined(__GNUC__) +#define VPX_DECLSPEC_DEPRECATED /**< \copydoc #VPX_DEPRECATED */ +#elif defined(_MSC_VER) +/*!\brief \copydoc #VPX_DEPRECATED */ +#define VPX_DECLSPEC_DEPRECATED __declspec(deprecated) +#else +#define VPX_DECLSPEC_DEPRECATED /**< \copydoc #VPX_DEPRECATED */ +#endif +#endif /* VPX_DECLSPEC_DEPRECATED */ + +/*!\brief Decorator indicating a function is potentially unused */ +#ifndef VPX_UNUSED +#if defined(__GNUC__) || defined(__clang__) +#define VPX_UNUSED __attribute__((unused)) +#else +#define VPX_UNUSED +#endif +#endif /* VPX_UNUSED */ + +/*!\brief Current ABI version number + * + * \internal + * If this file is altered in any way that changes the ABI, this value + * must be bumped. Examples include, but are not limited to, changing + * types, removing or reassigning enums, adding/removing/rearranging + * fields to structures + */ +#define VPX_CODEC_ABI_VERSION (4 + VPX_IMAGE_ABI_VERSION) /**<\hideinitializer*/ + +/*!\brief Algorithm return codes */ +typedef enum { + /*!\brief Operation completed without error */ + VPX_CODEC_OK, + + /*!\brief Unspecified error */ + VPX_CODEC_ERROR, + + /*!\brief Memory operation failed */ + VPX_CODEC_MEM_ERROR, + + /*!\brief ABI version mismatch */ + VPX_CODEC_ABI_MISMATCH, + + /*!\brief Algorithm does not have required capability */ + VPX_CODEC_INCAPABLE, + + /*!\brief The given bitstream is not supported. + * + * The bitstream was unable to be parsed at the highest level. The decoder + * is unable to proceed. This error \ref SHOULD be treated as fatal to the + * stream. */ + VPX_CODEC_UNSUP_BITSTREAM, + + /*!\brief Encoded bitstream uses an unsupported feature + * + * The decoder does not implement a feature required by the encoder. This + * return code should only be used for features that prevent future + * pictures from being properly decoded. This error \ref MAY be treated as + * fatal to the stream or \ref MAY be treated as fatal to the current GOP. + */ + VPX_CODEC_UNSUP_FEATURE, + + /*!\brief The coded data for this stream is corrupt or incomplete + * + * There was a problem decoding the current frame. This return code + * should only be used for failures that prevent future pictures from + * being properly decoded. This error \ref MAY be treated as fatal to the + * stream or \ref MAY be treated as fatal to the current GOP. If decoding + * is continued for the current GOP, artifacts may be present. + */ + VPX_CODEC_CORRUPT_FRAME, + + /*!\brief An application-supplied parameter is not valid. + * + */ + VPX_CODEC_INVALID_PARAM, + + /*!\brief An iterator reached the end of list. + * + */ + VPX_CODEC_LIST_END + +} vpx_codec_err_t; + +/*! \brief Codec capabilities bitfield + * + * Each codec advertises the capabilities it supports as part of its + * ::vpx_codec_iface_t interface structure. Capabilities are extra interfaces + * or functionality, and are not required to be supported. + * + * The available flags are specified by VPX_CODEC_CAP_* defines. + */ +typedef long vpx_codec_caps_t; +#define VPX_CODEC_CAP_DECODER 0x1 /**< Is a decoder */ +#define VPX_CODEC_CAP_ENCODER 0x2 /**< Is an encoder */ + +/*! Can support images at greater than 8 bitdepth. + */ +#define VPX_CODEC_CAP_HIGHBITDEPTH 0x4 + +/*! \brief Initialization-time Feature Enabling + * + * Certain codec features must be known at initialization time, to allow for + * proper memory allocation. + * + * The available flags are specified by VPX_CODEC_USE_* defines. + */ +typedef long vpx_codec_flags_t; + +/*!\brief Codec interface structure. + * + * Contains function pointers and other data private to the codec + * implementation. This structure is opaque to the application. + */ +typedef const struct vpx_codec_iface vpx_codec_iface_t; + +/*!\brief Codec private data structure. + * + * Contains data private to the codec implementation. This structure is opaque + * to the application. + */ +typedef struct vpx_codec_priv vpx_codec_priv_t; + +/*!\brief Iterator + * + * Opaque storage used for iterating over lists. + */ +typedef const void *vpx_codec_iter_t; + +/*!\brief Codec context structure + * + * All codecs \ref MUST support this context structure fully. In general, + * this data should be considered private to the codec algorithm, and + * not be manipulated or examined by the calling application. Applications + * may reference the 'name' member to get a printable description of the + * algorithm. + */ +typedef struct vpx_codec_ctx { + const char *name; /**< Printable interface name */ + vpx_codec_iface_t *iface; /**< Interface pointers */ + vpx_codec_err_t err; /**< Last returned error */ + const char *err_detail; /**< Detailed info, if available */ + vpx_codec_flags_t init_flags; /**< Flags passed at init time */ + union { + /**< Decoder Configuration Pointer */ + const struct vpx_codec_dec_cfg *dec; + /**< Encoder Configuration Pointer */ + const struct vpx_codec_enc_cfg *enc; + const void *raw; + } config; /**< Configuration pointer aliasing union */ + vpx_codec_priv_t *priv; /**< Algorithm private storage */ +} vpx_codec_ctx_t; + +/*!\brief Bit depth for codec + * * + * This enumeration determines the bit depth of the codec. + */ +typedef enum vpx_bit_depth { + VPX_BITS_8 = 8, /**< 8 bits */ + VPX_BITS_10 = 10, /**< 10 bits */ + VPX_BITS_12 = 12, /**< 12 bits */ +} vpx_bit_depth_t; + +/* + * Library Version Number Interface + * + * For example, see the following sample return values: + * vpx_codec_version() (1<<16 | 2<<8 | 3) + * vpx_codec_version_str() "v1.2.3-rc1-16-gec6a1ba" + * vpx_codec_version_extra_str() "rc1-16-gec6a1ba" + */ + +/*!\brief Return the version information (as an integer) + * + * Returns a packed encoding of the library version number. This will only + * include + * the major.minor.patch component of the version number. Note that this encoded + * value should be accessed through the macros provided, as the encoding may + * change + * in the future. + * + */ +int vpx_codec_version(void); +#define VPX_VERSION_MAJOR(v) \ + (((v) >> 16) & 0xff) /**< extract major from packed version */ +#define VPX_VERSION_MINOR(v) \ + (((v) >> 8) & 0xff) /**< extract minor from packed version */ +#define VPX_VERSION_PATCH(v) \ + (((v) >> 0) & 0xff) /**< extract patch from packed version */ + +/*!\brief Return the version major number */ +#define vpx_codec_version_major() ((vpx_codec_version() >> 16) & 0xff) + +/*!\brief Return the version minor number */ +#define vpx_codec_version_minor() ((vpx_codec_version() >> 8) & 0xff) + +/*!\brief Return the version patch number */ +#define vpx_codec_version_patch() ((vpx_codec_version() >> 0) & 0xff) + +/*!\brief Return the version information (as a string) + * + * Returns a printable string containing the full library version number. This + * may + * contain additional text following the three digit version number, as to + * indicate + * release candidates, prerelease versions, etc. + * + */ +const char *vpx_codec_version_str(void); + +/*!\brief Return the version information (as a string) + * + * Returns a printable "extra string". This is the component of the string + * returned + * by vpx_codec_version_str() following the three digit version number. + * + */ +const char *vpx_codec_version_extra_str(void); + +/*!\brief Return the build configuration + * + * Returns a printable string containing an encoded version of the build + * configuration. This may be useful to vpx support. + * + */ +const char *vpx_codec_build_config(void); + +/*!\brief Return the name for a given interface + * + * Returns a human readable string for name of the given codec interface. + * + * \param[in] iface Interface pointer + * + */ +const char *vpx_codec_iface_name(vpx_codec_iface_t *iface); + +/*!\brief Convert error number to printable string + * + * Returns a human readable string for the last error returned by the + * algorithm. The returned error will be one line and will not contain + * any newline characters. + * + * + * \param[in] err Error number. + * + */ +const char *vpx_codec_err_to_string(vpx_codec_err_t err); + +/*!\brief Retrieve error synopsis for codec context + * + * Returns a human readable string for the last error returned by the + * algorithm. The returned error will be one line and will not contain + * any newline characters. + * + * + * \param[in] ctx Pointer to this instance's context. + * + */ +const char *vpx_codec_error(const vpx_codec_ctx_t *ctx); + +/*!\brief Retrieve detailed error information for codec context + * + * Returns a human readable string providing detailed information about + * the last error. The returned string is only valid until the next + * vpx_codec_* function call (except vpx_codec_error and + * vpx_codec_error_detail) on the codec context. + * + * \param[in] ctx Pointer to this instance's context. + * + * \retval NULL + * No detailed information is available. + */ +const char *vpx_codec_error_detail(const vpx_codec_ctx_t *ctx); + +/* REQUIRED FUNCTIONS + * + * The following functions are required to be implemented for all codecs. + * They represent the base case functionality expected of all codecs. + */ + +/*!\brief Destroy a codec instance + * + * Destroys a codec context, freeing any associated memory buffers. + * + * \param[in] ctx Pointer to this instance's context + * + * \retval #VPX_CODEC_OK + * The codec instance has been destroyed. + * \retval #VPX_CODEC_INVALID_PARAM + * ctx is a null pointer. + * \retval #VPX_CODEC_ERROR + * Codec context not initialized. + */ +vpx_codec_err_t vpx_codec_destroy(vpx_codec_ctx_t *ctx); + +/*!\brief Get the capabilities of an algorithm. + * + * Retrieves the capabilities bitfield from the algorithm's interface. + * + * \param[in] iface Pointer to the algorithm interface + * + */ +vpx_codec_caps_t vpx_codec_get_caps(vpx_codec_iface_t *iface); + +/*!\brief Control algorithm + * + * This function is used to exchange algorithm specific data with the codec + * instance. This can be used to implement features specific to a particular + * algorithm. + * + * This wrapper function dispatches the request to the helper function + * associated with the given ctrl_id. It tries to call this function + * transparently, but will return #VPX_CODEC_ERROR if the request could not + * be dispatched. + * + * Note that this function should not be used directly. Call the + * #vpx_codec_control wrapper macro instead. + * + * \param[in] ctx Pointer to this instance's context + * \param[in] ctrl_id Algorithm specific control identifier + * + * \retval #VPX_CODEC_OK + * The control request was processed. + * \retval #VPX_CODEC_ERROR + * The control request was not processed. + * \retval #VPX_CODEC_INVALID_PARAM + * The data was not valid. + */ +vpx_codec_err_t vpx_codec_control_(vpx_codec_ctx_t *ctx, int ctrl_id, ...); +#if defined(VPX_DISABLE_CTRL_TYPECHECKS) && VPX_DISABLE_CTRL_TYPECHECKS +#define vpx_codec_control(ctx, id, data) vpx_codec_control_(ctx, id, data) +#define VPX_CTRL_USE_TYPE(id, typ) +#define VPX_CTRL_USE_TYPE_DEPRECATED(id, typ) +#define VPX_CTRL_VOID(id, typ) + +#else +/*!\brief vpx_codec_control wrapper macro + * + * This macro allows for type safe conversions across the variadic parameter + * to vpx_codec_control_(). + * + * \internal + * It works by dispatching the call to the control function through a wrapper + * function named with the id parameter. + */ +#define vpx_codec_control(ctx, id, data) \ + vpx_codec_control_##id(ctx, id, data) /**<\hideinitializer*/ + +/*!\brief vpx_codec_control type definition macro + * + * This macro allows for type safe conversions across the variadic parameter + * to vpx_codec_control_(). It defines the type of the argument for a given + * control identifier. + * + * \internal + * It defines a static function with + * the correctly typed arguments as a wrapper to the type-unsafe internal + * function. + */ +#define VPX_CTRL_USE_TYPE(id, typ) \ + static vpx_codec_err_t vpx_codec_control_##id(vpx_codec_ctx_t *, int, typ) \ + VPX_UNUSED; \ + \ + static vpx_codec_err_t vpx_codec_control_##id(vpx_codec_ctx_t *ctx, \ + int ctrl_id, typ data) { \ + return vpx_codec_control_(ctx, ctrl_id, data); \ + } /**<\hideinitializer*/ + +/*!\brief vpx_codec_control deprecated type definition macro + * + * Like #VPX_CTRL_USE_TYPE, but indicates that the specified control is + * deprecated and should not be used. Consult the documentation for your + * codec for more information. + * + * \internal + * It defines a static function with the correctly typed arguments as a + * wrapper to the type-unsafe internal function. + */ +#define VPX_CTRL_USE_TYPE_DEPRECATED(id, typ) \ + VPX_DECLSPEC_DEPRECATED static vpx_codec_err_t vpx_codec_control_##id( \ + vpx_codec_ctx_t *, int, typ) VPX_DEPRECATED VPX_UNUSED; \ + \ + VPX_DECLSPEC_DEPRECATED static vpx_codec_err_t vpx_codec_control_##id( \ + vpx_codec_ctx_t *ctx, int ctrl_id, typ data) { \ + return vpx_codec_control_(ctx, ctrl_id, data); \ + } /**<\hideinitializer*/ + +/*!\brief vpx_codec_control void type definition macro + * + * This macro allows for type safe conversions across the variadic parameter + * to vpx_codec_control_(). It indicates that a given control identifier takes + * no argument. + * + * \internal + * It defines a static function without a data argument as a wrapper to the + * type-unsafe internal function. + */ +#define VPX_CTRL_VOID(id) \ + static vpx_codec_err_t vpx_codec_control_##id(vpx_codec_ctx_t *, int) \ + VPX_UNUSED; \ + \ + static vpx_codec_err_t vpx_codec_control_##id(vpx_codec_ctx_t *ctx, \ + int ctrl_id) { \ + return vpx_codec_control_(ctx, ctrl_id); \ + } /**<\hideinitializer*/ + +#endif + +/*!@} - end defgroup codec*/ +#ifdef __cplusplus +} +#endif +#endif // VPX_VPX_VPX_CODEC_H_ diff --git a/native/third_party/ucrt64/include/vpx/vpx_decoder.h b/native/third_party/ucrt64/include/vpx/vpx_decoder.h new file mode 100644 index 0000000..796180d --- /dev/null +++ b/native/third_party/ucrt64/include/vpx/vpx_decoder.h @@ -0,0 +1,371 @@ +/* + * Copyright (c) 2010 The WebM project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ +#ifndef VPX_VPX_VPX_DECODER_H_ +#define VPX_VPX_VPX_DECODER_H_ + +/*!\defgroup decoder Decoder Algorithm Interface + * \ingroup codec + * This abstraction allows applications using this decoder to easily support + * multiple video formats with minimal code duplication. This section describes + * the interface common to all decoders. + * @{ + */ + +/*!\file + * \brief Describes the decoder algorithm interface to applications. + * + * This file describes the interface between an application and a + * video decoder algorithm. + * + */ +#ifdef __cplusplus +extern "C" { +#endif + +#include "./vpx_codec.h" // IWYU pragma: export +#include "./vpx_frame_buffer.h" + +/*!\brief Current ABI version number + * + * \internal + * If this file is altered in any way that changes the ABI, this value + * must be bumped. Examples include, but are not limited to, changing + * types, removing or reassigning enums, adding/removing/rearranging + * fields to structures + */ +#define VPX_DECODER_ABI_VERSION \ + (3 + VPX_CODEC_ABI_VERSION) /**<\hideinitializer*/ + +/*! \brief Decoder capabilities bitfield + * + * Each decoder advertises the capabilities it supports as part of its + * ::vpx_codec_iface_t interface structure. Capabilities are extra interfaces + * or functionality, and are not required to be supported by a decoder. + * + * The available flags are specified by VPX_CODEC_CAP_* defines. + */ +#define VPX_CODEC_CAP_PUT_SLICE 0x10000 /**< Will issue put_slice callbacks */ +#define VPX_CODEC_CAP_PUT_FRAME 0x20000 /**< Will issue put_frame callbacks */ +#define VPX_CODEC_CAP_POSTPROC 0x40000 /**< Can postprocess decoded frame */ +/*!\brief Can conceal errors due to packet loss */ +#define VPX_CODEC_CAP_ERROR_CONCEALMENT 0x80000 +/*!\brief Can receive encoded frames one fragment at a time */ +#define VPX_CODEC_CAP_INPUT_FRAGMENTS 0x100000 +/*!\brief Can support frame-based multi-threading */ +#define VPX_CODEC_CAP_FRAME_THREADING 0x200000 +/*!\brief Can support external frame buffers */ +#define VPX_CODEC_CAP_EXTERNAL_FRAME_BUFFER 0x400000 + +/*! \brief Initialization-time Feature Enabling + * + * Certain codec features must be known at initialization time, to allow for + * proper memory allocation. + * + * The available flags are specified by VPX_CODEC_USE_* defines. + */ +#define VPX_CODEC_USE_POSTPROC 0x10000 /**< Postprocess decoded frame */ +/*!\brief Conceal errors in decoded frames */ +#define VPX_CODEC_USE_ERROR_CONCEALMENT 0x20000 +/*!\brief The input frame should be passed to the decoder one fragment at a + * time */ +#define VPX_CODEC_USE_INPUT_FRAGMENTS 0x40000 +/*!\brief Enable frame-based multi-threading */ +#define VPX_CODEC_USE_FRAME_THREADING 0x80000 + +/*!\brief Stream properties + * + * This structure is used to query or set properties of the decoded + * stream. Algorithms may extend this structure with data specific + * to their bitstream by setting the sz member appropriately. + */ +typedef struct vpx_codec_stream_info { + unsigned int sz; /**< Size of this structure */ + unsigned int w; /**< Width (or 0 for unknown/default) */ + unsigned int h; /**< Height (or 0 for unknown/default) */ + unsigned int is_kf; /**< Current frame is a keyframe */ +} vpx_codec_stream_info_t; + +/* REQUIRED FUNCTIONS + * + * The following functions are required to be implemented for all decoders. + * They represent the base case functionality expected of all decoders. + */ + +/*!\brief Initialization Configurations + * + * This structure is used to pass init time configuration options to the + * decoder. + */ +typedef struct vpx_codec_dec_cfg { + unsigned int threads; /**< Maximum number of threads to use, default 1 */ + unsigned int w; /**< Width */ + unsigned int h; /**< Height */ +} vpx_codec_dec_cfg_t; /**< alias for struct vpx_codec_dec_cfg */ + +/*!\brief Initialize a decoder instance + * + * Initializes a decoder context using the given interface. Applications + * should call the vpx_codec_dec_init convenience macro instead of this + * function directly, to ensure that the ABI version number parameter + * is properly initialized. + * + * If the library was configured with --disable-multithread, this call + * is not thread safe and should be guarded with a lock if being used + * in a multithreaded context. + * + * On success, vpx_codec_destroy() must be used to free resources allocated for + * the decoder context. If vpx_codec_dec_init_ver() fails, it is not necessary + * to call vpx_codec_destroy() on the decoder context. + * + * \param[in] ctx Pointer to this instance's context. + * \param[in] iface Pointer to the algorithm interface to use. + * \param[in] cfg Configuration to use, if known. May be NULL. + * \param[in] flags Bitfield of VPX_CODEC_USE_* flags + * \param[in] ver ABI version number. Must be set to + * VPX_DECODER_ABI_VERSION + * \retval #VPX_CODEC_OK + * The decoder algorithm has been initialized. + * \retval #VPX_CODEC_MEM_ERROR + * Memory allocation failed. + */ +vpx_codec_err_t vpx_codec_dec_init_ver(vpx_codec_ctx_t *ctx, + vpx_codec_iface_t *iface, + const vpx_codec_dec_cfg_t *cfg, + vpx_codec_flags_t flags, int ver); + +/*!\brief Convenience macro for vpx_codec_dec_init_ver() + * + * Ensures the ABI version parameter is properly set. + */ +#define vpx_codec_dec_init(ctx, iface, cfg, flags) \ + vpx_codec_dec_init_ver(ctx, iface, cfg, flags, VPX_DECODER_ABI_VERSION) + +/*!\brief Parse stream info from a buffer + * + * Performs high level parsing of the bitstream. Construction of a decoder + * context is not necessary. Can be used to determine if the bitstream is + * of the proper format, and to extract information from the stream. + * + * \param[in] iface Pointer to the algorithm interface + * \param[in] data Pointer to a block of data to parse + * \param[in] data_sz Size of the data buffer + * \param[in,out] si Pointer to stream info to update. The sz member + * \ref MUST be properly initialized, but \ref MAY be + * clobbered by the algorithm. This parameter \ref MAY + * be NULL. + * + * \retval #VPX_CODEC_OK + * Bitstream is parsable and stream information updated + */ +vpx_codec_err_t vpx_codec_peek_stream_info(vpx_codec_iface_t *iface, + const uint8_t *data, + unsigned int data_sz, + vpx_codec_stream_info_t *si); + +/*!\brief Return information about the current stream. + * + * Returns information about the stream that has been parsed during decoding. + * + * \param[in] ctx Pointer to this instance's context + * \param[in,out] si Pointer to stream info to update. The sz member + * \ref MUST be properly initialized, but \ref MAY be + * clobbered by the algorithm. This parameter \ref MAY + * be NULL. + * + * \retval #VPX_CODEC_OK + * Bitstream is parsable and stream information updated + */ +vpx_codec_err_t vpx_codec_get_stream_info(vpx_codec_ctx_t *ctx, + vpx_codec_stream_info_t *si); + +/*!\brief Decode data + * + * Processes a buffer of coded data. If the processing results in a new + * decoded frame becoming available, put_slice and put_frame callbacks may be + * invoked, as appropriate. Encoded data \ref MUST be passed in DTS (decode + * time stamp) order. Frames produced will always be in PTS (presentation + * time stamp) order. + * If the decoder is configured with VPX_CODEC_USE_INPUT_FRAGMENTS enabled, + * data and data_sz can contain a fragment of the encoded frame. Fragment + * \#n must contain at least partition \#n, but can also contain subsequent + * partitions (\#n+1 - \#n+i), and if so, fragments \#n+1, .., \#n+i must + * be empty. When no more data is available, this function should be called + * with NULL as data and 0 as data_sz. The memory passed to this function + * must be available until the frame has been decoded. + * + * \param[in] ctx Pointer to this instance's context + * \param[in] data Pointer to this block of new coded data. If + * NULL, the put_frame callback is invoked for + * the previously decoded frame. + * \param[in] data_sz Size of the coded data, in bytes. + * \param[in] user_priv Application specific data to associate with + * this frame. + * \param[in] deadline Soft deadline the decoder should attempt to meet, + * in us. Set to zero for unlimited. + * NOTE: The deadline parameter is ignored. Always + * pass 0. + * + * \return Returns #VPX_CODEC_OK if the coded data was processed completely + * and future pictures can be decoded without error. Otherwise, + * see the descriptions of the other error codes in ::vpx_codec_err_t + * for recoverability capabilities. + */ +vpx_codec_err_t vpx_codec_decode(vpx_codec_ctx_t *ctx, const uint8_t *data, + unsigned int data_sz, void *user_priv, + long deadline); + +/*!\brief Decoded frames iterator + * + * Iterates over a list of the frames available for display. The iterator + * storage should be initialized to NULL to start the iteration. Iteration is + * complete when this function returns NULL. + * + * The list of available frames becomes valid upon completion of the + * vpx_codec_decode call, and remains valid until the next call to + * vpx_codec_decode. + * + * \param[in] ctx Pointer to this instance's context + * \param[in,out] iter Iterator storage, initialized to NULL + * + * \return Returns a pointer to an image, if one is ready for display. Frames + * produced will always be in PTS (presentation time stamp) order. + */ +vpx_image_t *vpx_codec_get_frame(vpx_codec_ctx_t *ctx, vpx_codec_iter_t *iter); + +/*!\defgroup cap_put_frame Frame-Based Decoding Functions + * + * The following function is required to be implemented for all decoders + * that advertise the VPX_CODEC_CAP_PUT_FRAME capability. Calling this + * function for codecs that don't advertise this capability will result in + * an error code being returned, usually VPX_CODEC_INCAPABLE. + * @{ + */ + +/*!\brief put frame callback prototype + * + * This callback is invoked by the decoder to notify the application of + * the availability of decoded image data. + */ +typedef void (*vpx_codec_put_frame_cb_fn_t)(void *user_priv, + const vpx_image_t *img); + +/*!\brief Register for notification of frame completion. + * + * Registers a given function to be called when a decoded frame is + * available. + * + * \param[in] ctx Pointer to this instance's context + * \param[in] cb Pointer to the callback function + * \param[in] user_priv User's private data + * + * \retval #VPX_CODEC_OK + * Callback successfully registered. + * \retval #VPX_CODEC_ERROR + * Decoder context not initialized. + * \retval #VPX_CODEC_INCAPABLE + * Algorithm not capable of posting frame completion. + */ +vpx_codec_err_t vpx_codec_register_put_frame_cb(vpx_codec_ctx_t *ctx, + vpx_codec_put_frame_cb_fn_t cb, + void *user_priv); + +/*!@} - end defgroup cap_put_frame */ + +/*!\defgroup cap_put_slice Slice-Based Decoding Functions + * + * The following function is required to be implemented for all decoders + * that advertise the VPX_CODEC_CAP_PUT_SLICE capability. Calling this + * function for codecs that don't advertise this capability will result in + * an error code being returned, usually VPX_CODEC_INCAPABLE. + * @{ + */ + +/*!\brief put slice callback prototype + * + * This callback is invoked by the decoder to notify the application of + * the availability of partially decoded image data. + */ +typedef void (*vpx_codec_put_slice_cb_fn_t)(void *user_priv, + const vpx_image_t *img, + const vpx_image_rect_t *valid, + const vpx_image_rect_t *update); + +/*!\brief Register for notification of slice completion. + * + * Registers a given function to be called when a decoded slice is + * available. + * + * \param[in] ctx Pointer to this instance's context + * \param[in] cb Pointer to the callback function + * \param[in] user_priv User's private data + * + * \retval #VPX_CODEC_OK + * Callback successfully registered. + * \retval #VPX_CODEC_ERROR + * Decoder context not initialized. + * \retval #VPX_CODEC_INCAPABLE + * Algorithm not capable of posting slice completion. + */ +vpx_codec_err_t vpx_codec_register_put_slice_cb(vpx_codec_ctx_t *ctx, + vpx_codec_put_slice_cb_fn_t cb, + void *user_priv); + +/*!@} - end defgroup cap_put_slice*/ + +/*!\defgroup cap_external_frame_buffer External Frame Buffer Functions + * + * The following function is required to be implemented for all decoders + * that advertise the VPX_CODEC_CAP_EXTERNAL_FRAME_BUFFER capability. + * Calling this function for codecs that don't advertise this capability + * will result in an error code being returned, usually VPX_CODEC_INCAPABLE. + * + * \note + * Currently this only works with VP9. + * @{ + */ + +/*!\brief Pass in external frame buffers for the decoder to use. + * + * Registers functions to be called when libvpx needs a frame buffer + * to decode the current frame and a function to be called when libvpx does + * not internally reference the frame buffer. This set function must + * be called before the first call to decode or libvpx will assume the + * default behavior of allocating frame buffers internally. + * + * \param[in] ctx Pointer to this instance's context + * \param[in] cb_get Pointer to the get callback function + * \param[in] cb_release Pointer to the release callback function + * \param[in] cb_priv Callback's private data + * + * \retval #VPX_CODEC_OK + * External frame buffers will be used by libvpx. + * \retval #VPX_CODEC_INVALID_PARAM + * One or more of the callbacks were NULL. + * \retval #VPX_CODEC_ERROR + * Decoder context not initialized. + * \retval #VPX_CODEC_INCAPABLE + * Algorithm not capable of using external frame buffers. + * + * \note + * When decoding VP9, the application may be required to pass in at least + * #VP9_MAXIMUM_REF_BUFFERS + #VPX_MAXIMUM_WORK_BUFFERS external frame + * buffers. + */ +vpx_codec_err_t vpx_codec_set_frame_buffer_functions( + vpx_codec_ctx_t *ctx, vpx_get_frame_buffer_cb_fn_t cb_get, + vpx_release_frame_buffer_cb_fn_t cb_release, void *cb_priv); + +/*!@} - end defgroup cap_external_frame_buffer */ + +/*!@} - end defgroup decoder*/ +#ifdef __cplusplus +} +#endif +#endif // VPX_VPX_VPX_DECODER_H_ diff --git a/native/third_party/ucrt64/include/vpx/vpx_encoder.h b/native/third_party/ucrt64/include/vpx/vpx_encoder.h new file mode 100644 index 0000000..6a87f91 --- /dev/null +++ b/native/third_party/ucrt64/include/vpx/vpx_encoder.h @@ -0,0 +1,1163 @@ +/* + * Copyright (c) 2010 The WebM project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ +#ifndef VPX_VPX_VPX_ENCODER_H_ +#define VPX_VPX_VPX_ENCODER_H_ + +/*!\defgroup encoder Encoder Algorithm Interface + * \ingroup codec + * This abstraction allows applications using this encoder to easily support + * multiple video formats with minimal code duplication. This section describes + * the interface common to all encoders. + * @{ + */ + +/*!\file + * \brief Describes the encoder algorithm interface to applications. + * + * This file describes the interface between an application and a + * video encoder algorithm. + * + */ +#ifdef __cplusplus +extern "C" { +#endif + +#include "./vpx_codec.h" // IWYU pragma: export +#include "./vpx_ext_ratectrl.h" + +/*! Temporal Scalability: Maximum length of the sequence defining frame + * layer membership + */ +#define VPX_TS_MAX_PERIODICITY 16 + +/*! Temporal Scalability: Maximum number of coding layers */ +#define VPX_TS_MAX_LAYERS 5 + +/*! Temporal+Spatial Scalability: Maximum number of coding layers */ +#define VPX_MAX_LAYERS 12 // 3 temporal + 4 spatial layers are allowed. + +/*! Spatial Scalability: Maximum number of coding layers */ +#define VPX_SS_MAX_LAYERS 5 + +/*! Spatial Scalability: Default number of coding layers */ +#define VPX_SS_DEFAULT_LAYERS 1 + +/*!\brief Current ABI version number + * + * \internal + * If this file is altered in any way that changes the ABI, this value + * must be bumped. Examples include, but are not limited to, changing + * types, removing or reassigning enums, adding/removing/rearranging + * fields to structures + * + * \note + * VPX_ENCODER_ABI_VERSION has a VPX_EXT_RATECTRL_ABI_VERSION component + * because the VP9E_SET_EXTERNAL_RATE_CONTROL codec control uses + * vpx_rc_funcs_t. + */ +#define VPX_ENCODER_ABI_VERSION \ + (18 + VPX_CODEC_ABI_VERSION + \ + VPX_EXT_RATECTRL_ABI_VERSION) /**<\hideinitializer*/ + +/*! \brief Encoder capabilities bitfield + * + * Each encoder advertises the capabilities it supports as part of its + * ::vpx_codec_iface_t interface structure. Capabilities are extra + * interfaces or functionality, and are not required to be supported + * by an encoder. + * + * The available flags are specified by VPX_CODEC_CAP_* defines. + */ +#define VPX_CODEC_CAP_PSNR 0x10000 /**< Can issue PSNR packets */ + +/*! Can output one partition at a time. Each partition is returned in its + * own VPX_CODEC_CX_FRAME_PKT, with the FRAME_IS_FRAGMENT flag set for + * every partition but the last. In this mode all frames are always + * returned partition by partition. + */ +#define VPX_CODEC_CAP_OUTPUT_PARTITION 0x20000 + +/*! \brief Initialization-time Feature Enabling + * + * Certain codec features must be known at initialization time, to allow + * for proper memory allocation. + * + * The available flags are specified by VPX_CODEC_USE_* defines. + */ +#define VPX_CODEC_USE_PSNR 0x10000 /**< Calculate PSNR on each frame */ +/*!\brief Make the encoder output one partition at a time. */ +#define VPX_CODEC_USE_OUTPUT_PARTITION 0x20000 +#define VPX_CODEC_USE_HIGHBITDEPTH 0x40000 /**< Use high bitdepth */ + +/*!\brief Generic fixed size buffer structure + * + * This structure is able to hold a reference to any fixed size buffer. + */ +typedef struct vpx_fixed_buf { + void *buf; /**< Pointer to the data */ + size_t sz; /**< Length of the buffer, in chars */ +} vpx_fixed_buf_t; /**< alias for struct vpx_fixed_buf */ + +/*!\brief Time Stamp Type + * + * An integer, which when multiplied by the stream's time base, provides + * the absolute time of a sample. + */ +typedef int64_t vpx_codec_pts_t; + +/*!\brief Compressed Frame Flags + * + * This type represents a bitfield containing information about a compressed + * frame that may be useful to an application. The most significant 16 bits + * can be used by an algorithm to provide additional detail, for example to + * support frame types that are codec specific (MPEG-1 D-frames for example) + */ +typedef uint32_t vpx_codec_frame_flags_t; +#define VPX_FRAME_IS_KEY 0x1u /**< frame is the start of a GOP */ +/*!\brief frame can be dropped without affecting the stream (no future frame + * depends on this one) */ +#define VPX_FRAME_IS_DROPPABLE 0x2u +/*!\brief frame should be decoded but will not be shown */ +#define VPX_FRAME_IS_INVISIBLE 0x4u +/*!\brief this is a fragment of the encoded frame */ +#define VPX_FRAME_IS_FRAGMENT 0x8u + +/*!\brief Error Resilient flags + * + * These flags define which error resilient features to enable in the + * encoder. The flags are specified through the + * vpx_codec_enc_cfg::g_error_resilient variable. + */ +typedef uint32_t vpx_codec_er_flags_t; +/*!\brief Improve resiliency against losses of whole frames */ +#define VPX_ERROR_RESILIENT_DEFAULT 0x1u +/*!\brief The frame partitions are independently decodable by the bool decoder, + * meaning that partitions can be decoded even though earlier partitions have + * been lost. Note that intra prediction is still done over the partition + * boundary. + * \note This is only supported by VP8.*/ +#define VPX_ERROR_RESILIENT_PARTITIONS 0x2u + +/*!\brief Encoder output packet variants + * + * This enumeration lists the different kinds of data packets that can be + * returned by calls to vpx_codec_get_cx_data(). Algorithms \ref MAY + * extend this list to provide additional functionality. + */ +enum vpx_codec_cx_pkt_kind { + VPX_CODEC_CX_FRAME_PKT, /**< Compressed video frame */ + VPX_CODEC_STATS_PKT, /**< Two-pass statistics for this frame */ + VPX_CODEC_FPMB_STATS_PKT, /**< first pass mb statistics for this frame */ + VPX_CODEC_PSNR_PKT, /**< PSNR statistics for this frame */ + VPX_CODEC_CUSTOM_PKT = 256 /**< Algorithm extensions */ +}; + +/*!\brief Encoder output packet + * + * This structure contains the different kinds of output data the encoder + * may produce while compressing a frame. + */ +typedef struct vpx_codec_cx_pkt { + enum vpx_codec_cx_pkt_kind kind; /**< packet variant */ + union { + struct { + void *buf; /**< compressed data buffer */ + size_t sz; /**< length of compressed data */ + /*!\brief time stamp to show frame (in timebase units) */ + vpx_codec_pts_t pts; + /*!\brief duration to show frame (in timebase units) */ + unsigned long duration; + vpx_codec_frame_flags_t flags; /**< flags for this frame */ + /*!\brief the partition id defines the decoding order of the partitions. + * Only applicable when "output partition" mode is enabled. First + * partition has id 0.*/ + int partition_id; + /*!\brief Width and height of frames in this packet. VP8 will only use the + * first one.*/ + unsigned int width[VPX_SS_MAX_LAYERS]; /**< frame width */ + unsigned int height[VPX_SS_MAX_LAYERS]; /**< frame height */ + /*!\brief Flag to indicate if spatial layer frame in this packet is + * encoded or dropped. VP8 will always be set to 1.*/ + uint8_t spatial_layer_encoded[VPX_SS_MAX_LAYERS]; + } frame; /**< data for compressed frame packet */ + vpx_fixed_buf_t twopass_stats; /**< data for two-pass packet */ + vpx_fixed_buf_t firstpass_mb_stats; /**< first pass mb packet */ + struct vpx_psnr_pkt { + unsigned int samples[4]; /**< Number of samples, total/y/u/v */ + uint64_t sse[4]; /**< sum squared error, total/y/u/v */ + double psnr[4]; /**< PSNR, total/y/u/v */ + int spatial_layer_id; /**< Spatial layer id */ + } psnr; /**< data for PSNR packet */ + vpx_fixed_buf_t raw; /**< data for arbitrary packets */ + + /* This packet size is fixed to allow codecs to extend this + * interface without having to manage storage for raw packets, + * i.e., if it's smaller than 128 bytes, you can store in the + * packet list directly. + */ + char pad[128 - sizeof(enum vpx_codec_cx_pkt_kind)]; /**< fixed sz */ + } data; /**< packet data */ +} vpx_codec_cx_pkt_t; /**< alias for struct vpx_codec_cx_pkt */ + +/*!\brief Encoder return output buffer callback + * + * This callback function, when registered, returns with packets when each + * spatial layer is encoded. + */ +typedef void (*vpx_codec_enc_output_cx_pkt_cb_fn_t)(vpx_codec_cx_pkt_t *pkt, + void *user_data); + +/*!\brief Callback function pointer / user data pair storage */ +typedef struct vpx_codec_enc_output_cx_cb_pair { + vpx_codec_enc_output_cx_pkt_cb_fn_t output_cx_pkt; /**< Callback function */ + void *user_priv; /**< Pointer to private data */ +} vpx_codec_priv_output_cx_pkt_cb_pair_t; + +/*!\brief Rational Number + * + * This structure holds a fractional value. + */ +typedef struct vpx_rational { + int num; /**< fraction numerator */ + int den; /**< fraction denominator */ +} vpx_rational_t; /**< alias for struct vpx_rational */ + +/*!\brief Multi-pass Encoding Pass */ +typedef enum vpx_enc_pass { + VPX_RC_ONE_PASS, /**< Single pass mode */ + VPX_RC_FIRST_PASS, /**< First pass of multi-pass mode */ + VPX_RC_LAST_PASS /**< Final pass of multi-pass mode */ +} vpx_enc_pass; + +/*!\brief Rate control mode */ +enum vpx_rc_mode { + VPX_VBR, /**< Variable Bit Rate (VBR) mode */ + VPX_CBR, /**< Constant Bit Rate (CBR) mode */ + VPX_CQ, /**< Constrained Quality (CQ) mode */ + VPX_Q, /**< Constant Quality (Q) mode */ +}; + +/*!\brief Keyframe placement mode. + * + * This enumeration determines whether keyframes are placed automatically by + * the encoder or whether this behavior is disabled. Older releases of this + * SDK were implemented such that VPX_KF_FIXED meant keyframes were disabled. + * This name is confusing for this behavior, so the new symbols to be used + * are VPX_KF_AUTO and VPX_KF_DISABLED. + */ +enum vpx_kf_mode { + VPX_KF_FIXED, /**< deprecated, implies VPX_KF_DISABLED */ + VPX_KF_AUTO, /**< Encoder determines optimal placement automatically */ + VPX_KF_DISABLED = 0 /**< Encoder does not place keyframes. */ +}; + +/*!\brief Encoded Frame Flags + * + * This type indicates a bitfield to be passed to vpx_codec_encode(), defining + * per-frame boolean values. By convention, bits common to all codecs will be + * named VPX_EFLAG_*, and bits specific to an algorithm will be named + * /algo/_eflag_*. The lower order 16 bits are reserved for common use. + */ +typedef long vpx_enc_frame_flags_t; +#define VPX_EFLAG_FORCE_KF (1 << 0) /**< Force this frame to be a keyframe */ +/** Calculate PSNR on this frame, requires g_lag_in_frames to be 0 */ +#define VPX_EFLAG_CALCULATE_PSNR (1 << 1) + +/*!\brief Encoder configuration structure + * + * This structure contains the encoder settings that have common representations + * across all codecs. This doesn't imply that all codecs support all features, + * however. + */ +typedef struct vpx_codec_enc_cfg { + /* + * generic settings (g) + */ + + /*!\brief Deprecated: Algorithm specific "usage" value + * + * This value must be zero. + */ + unsigned int g_usage; + + /*!\brief Maximum number of threads to use + * + * For multi-threaded implementations, use no more than this number of + * threads. The codec may use fewer threads than allowed. The value + * 0 is equivalent to the value 1. + */ + unsigned int g_threads; + + /*!\brief Bitstream profile to use + * + * Some codecs support a notion of multiple bitstream profiles. Typically + * this maps to a set of features that are turned on or off. Often the + * profile to use is determined by the features of the intended decoder. + * Consult the documentation for the codec to determine the valid values + * for this parameter, or set to zero for a sane default. + */ + unsigned int g_profile; /**< profile of bitstream to use */ + + /*!\brief Width of the frame + * + * This value identifies the presentation resolution of the frame, + * in pixels. Note that the frames passed as input to the encoder must + * have this resolution. Frames will be presented by the decoder in this + * resolution, independent of any spatial resampling the encoder may do. + */ + unsigned int g_w; + + /*!\brief Height of the frame + * + * This value identifies the presentation resolution of the frame, + * in pixels. Note that the frames passed as input to the encoder must + * have this resolution. Frames will be presented by the decoder in this + * resolution, independent of any spatial resampling the encoder may do. + */ + unsigned int g_h; + + /*!\brief Bit-depth of the codec + * + * This value identifies the bit_depth of the codec, + * Only certain bit-depths are supported as identified in the + * vpx_bit_depth_t enum. + */ + vpx_bit_depth_t g_bit_depth; + + /*!\brief Bit-depth of the input source + * + * This value identifies the actual bit-depth of the input source in bits. It + * must not exceed codec bit-depth. Note that the frames passed as input to + * the encoder must match codec bit-depth. So, if there is a mismatch between + * source bit-depth and codec bit-depth, the application is required to + * upshift the frame to the codec bit-depth before passing it for encoding. + * This is only used for computing quality metrics relative to the actual + * input source and has no effect on the encoder's output. + */ + unsigned int g_input_bit_depth; + + /*!\brief Stream timebase units + * + * Indicates the smallest interval of time, in seconds, used by the stream. + * For fixed frame rate material, or variable frame rate material where + * frames are timed at a multiple of a given clock (ex: video capture), + * the \ref RECOMMENDED method is to set the timebase to the reciprocal + * of the frame rate (ex: 1001/30000 for 29.970 Hz NTSC). This allows the + * pts to correspond to the frame number, which can be handy. For + * re-encoding video from containers with absolute time timestamps, the + * \ref RECOMMENDED method is to set the timebase to that of the parent + * container or multimedia framework (ex: 1/1000 for ms, as in FLV). + */ + struct vpx_rational g_timebase; + + /*!\brief Enable error resilient modes. + * + * The error resilient bitfield indicates to the encoder which features + * it should enable to take measures for streaming over lossy or noisy + * links. + */ + vpx_codec_er_flags_t g_error_resilient; + + /*!\brief Multi-pass Encoding Mode + * + * This value should be set to the current phase for multi-pass encoding. + * For single pass, set to #VPX_RC_ONE_PASS. + */ + enum vpx_enc_pass g_pass; + + /*!\brief Allow lagged encoding + * + * If set, this value allows the encoder to consume a number of input + * frames before producing output frames. This allows the encoder to + * base decisions for the current frame on future frames. This does + * increase the latency of the encoding pipeline, so it is not appropriate + * in all situations (ex: realtime encoding). + * + * Note that this is a maximum value -- the encoder may produce frames + * sooner than the given limit. Set this value to 0 to disable this + * feature. + */ + unsigned int g_lag_in_frames; + + /* + * rate control settings (rc) + */ + + /*!\brief Temporal resampling configuration, if supported by the codec. + * + * Temporal resampling allows the codec to "drop" frames as a strategy to + * meet its target data rate. This can cause temporal discontinuities in + * the encoded video, which may appear as stuttering during playback. This + * trade-off is often acceptable, but for many applications is not. It can + * be disabled in these cases. + * + * This threshold is described as a percentage of the target data buffer. + * When the data buffer falls below this percentage of fullness, a + * dropped frame is indicated. Set the threshold to zero (0) to disable + * this feature. + */ + unsigned int rc_dropframe_thresh; + + /*!\brief Enable/disable spatial resampling, if supported by the codec. + * + * Spatial resampling allows the codec to compress a lower resolution + * version of the frame, which is then upscaled by the encoder to the + * correct presentation resolution. This increases visual quality at + * low data rates, at the expense of CPU time on the encoder/decoder. + */ + unsigned int rc_resize_allowed; + + /*!\brief Internal coded frame width. + * + * If spatial resampling is enabled this specifies the width of the + * encoded frame. + */ + unsigned int rc_scaled_width; + + /*!\brief Internal coded frame height. + * + * If spatial resampling is enabled this specifies the height of the + * encoded frame. + */ + unsigned int rc_scaled_height; + + /*!\brief Spatial resampling up watermark. + * + * This threshold is described as a percentage of the target data buffer. + * When the data buffer rises above this percentage of fullness, the + * encoder will step up to a higher resolution version of the frame. + */ + unsigned int rc_resize_up_thresh; + + /*!\brief Spatial resampling down watermark. + * + * This threshold is described as a percentage of the target data buffer. + * When the data buffer falls below this percentage of fullness, the + * encoder will step down to a lower resolution version of the frame. + */ + unsigned int rc_resize_down_thresh; + + /*!\brief Rate control algorithm to use. + * + * Indicates whether the end usage of this stream is to be streamed over + * a bandwidth constrained link, indicating that Constant Bit Rate (CBR) + * mode should be used, or whether it will be played back on a high + * bandwidth link, as from a local disk, where higher variations in + * bitrate are acceptable. + */ + enum vpx_rc_mode rc_end_usage; + + /*!\brief Two-pass stats buffer. + * + * A buffer containing all of the stats packets produced in the first + * pass, concatenated. + */ + vpx_fixed_buf_t rc_twopass_stats_in; + + /*!\brief first pass mb stats buffer. + * + * A buffer containing all of the first pass mb stats packets produced + * in the first pass, concatenated. + */ + vpx_fixed_buf_t rc_firstpass_mb_stats_in; + + /*!\brief Target data rate + * + * Target bitrate to use for this stream, in kilobits per second. + * Internally capped to the smaller of the uncompressed bitrate and + * 1000000 kilobits per second. + */ + unsigned int rc_target_bitrate; + + /* + * quantizer settings + */ + + /*!\brief Minimum (Best Quality) Quantizer + * + * The quantizer is the most direct control over the quality of the + * encoded image. The range of valid values for the quantizer is codec + * specific. Consult the documentation for the codec to determine the + * values to use. + */ + unsigned int rc_min_quantizer; + + /*!\brief Maximum (Worst Quality) Quantizer + * + * The quantizer is the most direct control over the quality of the + * encoded image. The range of valid values for the quantizer is codec + * specific. Consult the documentation for the codec to determine the + * values to use. + */ + unsigned int rc_max_quantizer; + + /* + * bitrate tolerance + */ + + /*!\brief Rate control adaptation undershoot control + * + * VP8: Expressed as a percentage of the target bitrate, + * controls the maximum allowed adaptation speed of the codec. + * This factor controls the maximum amount of bits that can + * be subtracted from the target bitrate in order to compensate + * for prior overshoot. + * VP9: Expressed as a percentage of the target bitrate, a threshold + * undershoot level (current rate vs target) beyond which more aggressive + * corrective measures are taken. + * * + * Valid values in the range VP8:0-100 VP9: 0-100. + */ + unsigned int rc_undershoot_pct; + + /*!\brief Rate control adaptation overshoot control + * + * VP8: Expressed as a percentage of the target bitrate, + * controls the maximum allowed adaptation speed of the codec. + * This factor controls the maximum amount of bits that can + * be added to the target bitrate in order to compensate for + * prior undershoot. + * VP9: Expressed as a percentage of the target bitrate, a threshold + * overshoot level (current rate vs target) beyond which more aggressive + * corrective measures are taken. + * + * Valid values in the range VP8:0-100 VP9: 0-100. + */ + unsigned int rc_overshoot_pct; + + /* + * decoder buffer model parameters + */ + + /*!\brief Decoder Buffer Size + * + * This value indicates the amount of data that may be buffered by the + * decoding application. Note that this value is expressed in units of + * time (milliseconds). For example, a value of 5000 indicates that the + * client will buffer (at least) 5000ms worth of encoded data. Use the + * target bitrate (#rc_target_bitrate) to convert to bits/bytes, if + * necessary. + */ + unsigned int rc_buf_sz; + + /*!\brief Decoder Buffer Initial Size + * + * This value indicates the amount of data that will be buffered by the + * decoding application prior to beginning playback. This value is + * expressed in units of time (milliseconds). Use the target bitrate + * (#rc_target_bitrate) to convert to bits/bytes, if necessary. + */ + unsigned int rc_buf_initial_sz; + + /*!\brief Decoder Buffer Optimal Size + * + * This value indicates the amount of data that the encoder should try + * to maintain in the decoder's buffer. This value is expressed in units + * of time (milliseconds). Use the target bitrate (#rc_target_bitrate) + * to convert to bits/bytes, if necessary. + */ + unsigned int rc_buf_optimal_sz; + + /* + * 2 pass rate control parameters + */ + + /*!\brief Two-pass mode CBR/VBR bias + * + * Bias, expressed on a scale of 0 to 100, for determining target size + * for the current frame. The value 0 indicates the optimal CBR mode + * value should be used. The value 100 indicates the optimal VBR mode + * value should be used. Values in between indicate which way the + * encoder should "lean." + */ + unsigned int rc_2pass_vbr_bias_pct; + + /*!\brief Two-pass mode per-GOP minimum bitrate + * + * This value, expressed as a percentage of the target bitrate, indicates + * the minimum bitrate to be used for a single GOP (aka "section") + */ + unsigned int rc_2pass_vbr_minsection_pct; + + /*!\brief Two-pass mode per-GOP maximum bitrate + * + * This value, expressed as a percentage of the target bitrate, indicates + * the maximum bitrate to be used for a single GOP (aka "section") + */ + unsigned int rc_2pass_vbr_maxsection_pct; + + /*!\brief Two-pass corpus vbr mode complexity control + * Used only in VP9: A value representing the corpus midpoint complexity + * for corpus vbr mode. This value defaults to 0 which disables corpus vbr + * mode in favour of normal vbr mode. + */ + unsigned int rc_2pass_vbr_corpus_complexity; + + /* + * keyframing settings (kf) + */ + + /*!\brief Keyframe placement mode + * + * This value indicates whether the encoder should place keyframes at a + * fixed interval, or determine the optimal placement automatically + * (as governed by the #kf_min_dist and #kf_max_dist parameters) + */ + enum vpx_kf_mode kf_mode; + + /*!\brief Keyframe minimum interval + * + * This value, expressed as a number of frames, prevents the encoder from + * placing a keyframe nearer than kf_min_dist to the previous keyframe. At + * least kf_min_dist frames non-keyframes will be coded before the next + * keyframe. Set kf_min_dist equal to kf_max_dist for a fixed interval. + */ + unsigned int kf_min_dist; + + /*!\brief Keyframe maximum interval + * + * This value, expressed as a number of frames, forces the encoder to code + * a keyframe if one has not been coded in the last kf_max_dist frames. + * A value of 0 implies all frames will be keyframes. Set kf_min_dist + * equal to kf_max_dist for a fixed interval. + */ + unsigned int kf_max_dist; + + /* + * Spatial scalability settings (ss) + */ + + /*!\brief Number of spatial coding layers. + * + * This value specifies the number of spatial coding layers to be used. + */ + unsigned int ss_number_layers; + + /*!\brief Enable auto alt reference flags for each spatial layer. + * + * These values specify if auto alt reference frame is enabled for each + * spatial layer. + */ + int ss_enable_auto_alt_ref[VPX_SS_MAX_LAYERS]; + + /*!\brief Target bitrate for each spatial layer. + * + * These values specify the target coding bitrate to be used for each + * spatial layer. (in kbps) + */ + unsigned int ss_target_bitrate[VPX_SS_MAX_LAYERS]; + + /*!\brief Number of temporal coding layers. + * + * This value specifies the number of temporal layers to be used. + */ + unsigned int ts_number_layers; + + /*!\brief Target bitrate for each temporal layer. + * + * These values specify the target coding bitrate to be used for each + * temporal layer. (in kbps) + */ + unsigned int ts_target_bitrate[VPX_TS_MAX_LAYERS]; + + /*!\brief Frame rate decimation factor for each temporal layer. + * + * These values specify the frame rate decimation factors to apply + * to each temporal layer. + */ + unsigned int ts_rate_decimator[VPX_TS_MAX_LAYERS]; + + /*!\brief Length of the sequence defining frame temporal layer membership. + * + * This value specifies the length of the sequence that defines the + * membership of frames to temporal layers. For example, if the + * ts_periodicity = 8, then the frames are assigned to coding layers with a + * repeated sequence of length 8. + */ + unsigned int ts_periodicity; + + /*!\brief Template defining the membership of frames to temporal layers. + * + * This array defines the membership of frames to temporal coding layers. + * For a 2-layer encoding that assigns even numbered frames to one temporal + * layer (0) and odd numbered frames to a second temporal layer (1) with + * ts_periodicity=8, then ts_layer_id = (0,1,0,1,0,1,0,1). + */ + unsigned int ts_layer_id[VPX_TS_MAX_PERIODICITY]; + + /*!\brief Target bitrate for each spatial/temporal layer. + * + * These values specify the target coding bitrate to be used for each + * spatial/temporal layer. (in kbps) + * + */ + unsigned int layer_target_bitrate[VPX_MAX_LAYERS]; + + /*!\brief Temporal layering mode indicating which temporal layering scheme to + * use. + * + * The value (refer to VP9E_TEMPORAL_LAYERING_MODE) specifies the + * temporal layering mode to use. + * + */ + int temporal_layering_mode; + + /*!\brief A flag indicating whether to use external rate control parameters. + * By default is 0. If set to 1, the following parameters will be used in the + * rate control system. + */ + int use_vizier_rc_params; + + /*!\brief Active worst quality factor. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t active_wq_factor; + + /*!\brief Error per macroblock adjustment factor. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t err_per_mb_factor; + + /*!\brief Second reference default decay limit. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t sr_default_decay_limit; + + /*!\brief Second reference difference factor. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t sr_diff_factor; + + /*!\brief Keyframe error per macroblock adjustment factor. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t kf_err_per_mb_factor; + + /*!\brief Keyframe minimum boost adjustment factor. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t kf_frame_min_boost_factor; + + /*!\brief Keyframe maximum boost adjustment factor, for the first keyframe + * in a chunk. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t kf_frame_max_boost_first_factor; + + /*!\brief Keyframe maximum boost adjustment factor, for subsequent keyframes. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t kf_frame_max_boost_subs_factor; + + /*!\brief Keyframe maximum total boost adjustment factor. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t kf_max_total_boost_factor; + + /*!\brief Golden frame maximum total boost adjustment factor. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t gf_max_total_boost_factor; + + /*!\brief Golden frame maximum boost adjustment factor. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t gf_frame_max_boost_factor; + + /*!\brief Zero motion power factor. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t zm_factor; + + /*!\brief Rate-distortion multiplier for inter frames. + * The multiplier is a crucial parameter in the calculation of rate distortion + * cost. It is often related to the qp (qindex) value. + * Rate control parameters, could be set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t rd_mult_inter_qp_fac; + + /*!\brief Rate-distortion multiplier for alt-ref frames. + * The multiplier is a crucial parameter in the calculation of rate distortion + * cost. It is often related to the qp (qindex) value. + * Rate control parameters, could be set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t rd_mult_arf_qp_fac; + + /*!\brief Rate-distortion multiplier for key frames. + * The multiplier is a crucial parameter in the calculation of rate distortion + * cost. It is often related to the qp (qindex) value. + * Rate control parameters, could be set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t rd_mult_key_qp_fac; +} vpx_codec_enc_cfg_t; /**< alias for struct vpx_codec_enc_cfg */ + +/*!\brief vp9 svc extra configure parameters + * + * This defines max/min quantizers and scale factors for each layer + * + */ +typedef struct vpx_svc_parameters { + int max_quantizers[VPX_MAX_LAYERS]; /**< Max Q for each layer */ + int min_quantizers[VPX_MAX_LAYERS]; /**< Min Q for each layer */ + int scaling_factor_num[VPX_MAX_LAYERS]; /**< Scaling factor-numerator */ + int scaling_factor_den[VPX_MAX_LAYERS]; /**< Scaling factor-denominator */ + int speed_per_layer[VPX_MAX_LAYERS]; /**< Speed setting for each sl */ + int temporal_layering_mode; /**< Temporal layering mode */ + int loopfilter_ctrl[VPX_MAX_LAYERS]; /**< Loopfilter ctrl for each sl */ +} vpx_svc_extra_cfg_t; + +/*!\brief Initialize an encoder instance + * + * Initializes an encoder context using the given interface. Applications + * should call the vpx_codec_enc_init convenience macro instead of this + * function directly, to ensure that the ABI version number parameter + * is properly initialized. + * + * If the library was configured with --disable-multithread, this call + * is not thread safe and should be guarded with a lock if being used + * in a multithreaded context. + * + * On success, vpx_codec_destroy() must be used to free resources allocated for + * the encoder context. If vpx_codec_enc_init_ver() fails, it is not necessary + * to call vpx_codec_destroy() on the encoder context. + * + * \param[in] ctx Pointer to this instance's context. + * \param[in] iface Pointer to the algorithm interface to use. + * \param[in] cfg Configuration to use. + * \param[in] flags Bitfield of VPX_CODEC_USE_* flags + * \param[in] ver ABI version number. Must be set to + * VPX_ENCODER_ABI_VERSION + * \retval #VPX_CODEC_OK + * The decoder algorithm initialized. + * \retval #VPX_CODEC_MEM_ERROR + * Memory allocation failed. + */ +vpx_codec_err_t vpx_codec_enc_init_ver(vpx_codec_ctx_t *ctx, + vpx_codec_iface_t *iface, + const vpx_codec_enc_cfg_t *cfg, + vpx_codec_flags_t flags, int ver); + +/*!\brief Convenience macro for vpx_codec_enc_init_ver() + * + * Ensures the ABI version parameter is properly set. + */ +#define vpx_codec_enc_init(ctx, iface, cfg, flags) \ + vpx_codec_enc_init_ver(ctx, iface, cfg, flags, VPX_ENCODER_ABI_VERSION) + +/*!\brief Initialize multi-encoder instance + * + * Initializes multiple encoder contexts using the given interface. + * Applications should call the vpx_codec_enc_init_multi convenience macro + * instead of this function directly, to ensure that the ABI version number + * parameter is properly initialized. + * + * \param[in] ctx Pointer to an array of num_enc instances' contexts. + * \param[in] iface Pointer to the algorithm interface to use. + * \param[in] cfg An array of num_enc configurations to use. + * \param[in] num_enc Total number of encoders. + * \param[in] flags Bitfield of VPX_CODEC_USE_* flags + * \param[in] dsf Pointer to an array of num_enc down-sampling factors. + * \param[in] ver ABI version number. Must be set to + * VPX_ENCODER_ABI_VERSION + * \retval #VPX_CODEC_OK + * The encoder algorithm has been initialized. + * \retval #VPX_CODEC_MEM_ERROR + * Memory allocation failed. + * + * \note + * This is only supported by VP8. iface must point to the interface to the VP8 + * encoder. + */ +vpx_codec_err_t vpx_codec_enc_init_multi_ver( + vpx_codec_ctx_t *ctx, vpx_codec_iface_t *iface, + const vpx_codec_enc_cfg_t *cfg, int num_enc, vpx_codec_flags_t flags, + const vpx_rational_t *dsf, int ver); + +/*!\brief Convenience macro for vpx_codec_enc_init_multi_ver() + * + * Ensures the ABI version parameter is properly set. + */ +#define vpx_codec_enc_init_multi(ctx, iface, cfg, num_enc, flags, dsf) \ + vpx_codec_enc_init_multi_ver(ctx, iface, cfg, num_enc, flags, dsf, \ + VPX_ENCODER_ABI_VERSION) + +/*!\brief Get a default configuration + * + * Initializes a encoder configuration structure with default values. Supports + * the notion of "usages" so that an algorithm may offer different default + * settings depending on the user's intended goal. This function \ref SHOULD + * be called by all applications to initialize the configuration structure + * before specializing the configuration with application specific values. + * + * \param[in] iface Pointer to the algorithm interface to use. + * \param[out] cfg Configuration buffer to populate. + * \param[in] usage Must be set to 0. + * + * \retval #VPX_CODEC_OK + * The configuration was populated. + * \retval #VPX_CODEC_INCAPABLE + * Interface is not an encoder interface. + * \retval #VPX_CODEC_INVALID_PARAM + * A parameter was NULL, or the usage value was not recognized. + */ +vpx_codec_err_t vpx_codec_enc_config_default(vpx_codec_iface_t *iface, + vpx_codec_enc_cfg_t *cfg, + unsigned int usage); + +/*!\brief Set or change configuration + * + * Reconfigures an encoder instance according to the given configuration. + * + * \param[in] ctx Pointer to this instance's context + * \param[in] cfg Configuration buffer to use + * + * \retval #VPX_CODEC_OK + * The configuration was populated. + * \retval #VPX_CODEC_INCAPABLE + * Interface is not an encoder interface. + * \retval #VPX_CODEC_INVALID_PARAM + * A parameter was NULL, or the usage value was not recognized. + */ +vpx_codec_err_t vpx_codec_enc_config_set(vpx_codec_ctx_t *ctx, + const vpx_codec_enc_cfg_t *cfg); + +/*!\brief Get global stream headers + * + * Retrieves a stream level global header packet, if supported by the codec. + * + * \li VP8: Unsupported + * \li VP9: Returns a buffer of ID (1 byte)|Length (1 byte)|Length + * bytes values. The function should be called after encoding to retrieve + * the most accurate information. + * + * \param[in] ctx Pointer to this instance's context + * + * \retval NULL + * Encoder does not support global header + * \retval Non-NULL + * Pointer to buffer containing global header packet. The buffer pointer + * and its contents are only valid for the lifetime of \a ctx. The contents + * may change in subsequent calls to the function. + * \sa + * https://www.webmproject.org/docs/container/#vp9-codec-feature-metadata-codecprivate + */ +vpx_fixed_buf_t *vpx_codec_get_global_headers(vpx_codec_ctx_t *ctx); + +/*!\brief Encode Deadline + * + * This type indicates a deadline, in microseconds, to be passed to + * vpx_codec_encode(). + */ +typedef unsigned long vpx_enc_deadline_t; +/*!\brief deadline parameter analogous to VPx REALTIME mode. */ +#define VPX_DL_REALTIME 1ul +/*!\brief deadline parameter analogous to VPx GOOD QUALITY mode. */ +#define VPX_DL_GOOD_QUALITY 1000000ul +/*!\brief deadline parameter analogous to VPx BEST QUALITY mode. */ +#define VPX_DL_BEST_QUALITY 0ul +/*!\brief Encode a frame + * + * Encodes a video frame at the given "presentation time." The presentation + * time stamp (PTS) \ref MUST be strictly increasing. + * + * The encoder supports the notion of a soft real-time deadline. Given a + * non-zero value to the deadline parameter, the encoder will make a "best + * effort" guarantee to return before the given time slice expires. It is + * implicit that limiting the available time to encode will degrade the + * output quality. The encoder can be given an unlimited time to produce the + * best possible frame by specifying a deadline of '0'. This deadline + * supersedes the VPx notion of "best quality, good quality, realtime". + * Applications that wish to map these former settings to the new deadline + * based system can use the symbols #VPX_DL_REALTIME, #VPX_DL_GOOD_QUALITY, + * and #VPX_DL_BEST_QUALITY. + * + * When the last frame has been passed to the encoder, this function should + * continue to be called, with the img parameter set to NULL. This will + * signal the end-of-stream condition to the encoder and allow it to encode + * any held buffers. Encoding is complete when vpx_codec_encode() is called + * and vpx_codec_get_cx_data() returns no data. + * + * \param[in] ctx Pointer to this instance's context + * \param[in] img Image data to encode, NULL to flush. + * Encoding sample values outside the range + * [0..(1<bit_depth)-1] is undefined behavior. + * \param[in] pts Presentation time stamp, in timebase units. + * \param[in] duration Duration to show frame, in timebase units. + * \param[in] flags Flags to use for encoding this frame. + * \param[in] deadline Time to spend encoding, in microseconds. (0=infinite) + * + * \retval #VPX_CODEC_OK + * The configuration was populated. + * \retval #VPX_CODEC_INCAPABLE + * Interface is not an encoder interface. + * \retval #VPX_CODEC_INVALID_PARAM + * A parameter was NULL, the image format is unsupported, etc. + */ +vpx_codec_err_t vpx_codec_encode(vpx_codec_ctx_t *ctx, const vpx_image_t *img, + vpx_codec_pts_t pts, unsigned long duration, + vpx_enc_frame_flags_t flags, + vpx_enc_deadline_t deadline); + +/*!\brief Set compressed data output buffer + * + * Sets the buffer that the codec should output the compressed data + * into. This call effectively sets the buffer pointer returned in the + * next VPX_CODEC_CX_FRAME_PKT packet. Subsequent packets will be + * appended into this buffer. The buffer is preserved across frames, + * so applications must periodically call this function after flushing + * the accumulated compressed data to disk or to the network to reset + * the pointer to the buffer's head. + * + * `pad_before` bytes will be skipped before writing the compressed + * data, and `pad_after` bytes will be appended to the packet. The size + * of the packet will be the sum of the size of the actual compressed + * data, pad_before, and pad_after. The padding bytes will be preserved + * (not overwritten). + * + * Note that calling this function does not guarantee that the returned + * compressed data will be placed into the specified buffer. In the + * event that the encoded data will not fit into the buffer provided, + * the returned packet \ref MAY point to an internal buffer, as it would + * if this call were never used. In this event, the output packet will + * NOT have any padding, and the application must free space and copy it + * to the proper place. This is of particular note in configurations + * that may output multiple packets for a single encoded frame (e.g., lagged + * encoding) or if the application does not reset the buffer periodically. + * + * Applications may restore the default behavior of the codec providing + * the compressed data buffer by calling this function with a NULL + * buffer. + * + * Applications \ref MUSTNOT call this function during iteration of + * vpx_codec_get_cx_data(). + * + * \param[in] ctx Pointer to this instance's context + * \param[in] buf Buffer to store compressed data into + * \param[in] pad_before Bytes to skip before writing compressed data + * \param[in] pad_after Bytes to skip after writing compressed data + * + * \retval #VPX_CODEC_OK + * The buffer was set successfully. + * \retval #VPX_CODEC_INVALID_PARAM + * A parameter was NULL, the image format is unsupported, etc. + * + * \note + * `duration` and `deadline` are of the unsigned long type, which can be 32 + * or 64 bits. `duration` and `deadline` must be less than or equal to + * UINT32_MAX so that their ranges are independent of the size of unsigned + * long. + */ +vpx_codec_err_t vpx_codec_set_cx_data_buf(vpx_codec_ctx_t *ctx, + const vpx_fixed_buf_t *buf, + unsigned int pad_before, + unsigned int pad_after); + +/*!\brief Encoded data iterator + * + * Iterates over a list of data packets to be passed from the encoder to the + * application. The different kinds of packets available are enumerated in + * #vpx_codec_cx_pkt_kind. + * + * #VPX_CODEC_CX_FRAME_PKT packets should be passed to the application's + * muxer. Multiple compressed frames may be in the list. + * #VPX_CODEC_STATS_PKT packets should be appended to a global buffer. + * + * The application \ref MUST silently ignore any packet kinds that it does + * not recognize or support. + * + * The data buffers returned from this function are only guaranteed to be + * valid until the application makes another call to any vpx_codec_* function. + * + * \param[in] ctx Pointer to this instance's context + * \param[in,out] iter Iterator storage, initialized to NULL + * + * \return Returns a pointer to an output data packet (compressed frame data, + * two-pass statistics, etc.) or NULL to signal end-of-list. + * + */ +const vpx_codec_cx_pkt_t *vpx_codec_get_cx_data(vpx_codec_ctx_t *ctx, + vpx_codec_iter_t *iter); + +/*!\brief Get Preview Frame + * + * Returns an image that can be used as a preview. Shows the image as it would + * exist at the decompressor. The application \ref MUST NOT write into this + * image buffer. + * + * \param[in] ctx Pointer to this instance's context + * + * \return Returns a pointer to a preview image, or NULL if no image is + * available. + * + */ +const vpx_image_t *vpx_codec_get_preview_frame(vpx_codec_ctx_t *ctx); + +/*!@} - end defgroup encoder*/ +#ifdef __cplusplus +} +#endif +#endif // VPX_VPX_VPX_ENCODER_H_ diff --git a/native/third_party/ucrt64/include/vpx/vpx_ext_ratectrl.h b/native/third_party/ucrt64/include/vpx/vpx_ext_ratectrl.h new file mode 100644 index 0000000..9631d40 --- /dev/null +++ b/native/third_party/ucrt64/include/vpx/vpx_ext_ratectrl.h @@ -0,0 +1,605 @@ +/* + * Copyright (c) 2020 The WebM project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +/*!\file + * \brief Defines structs and callbacks needed for external rate control. + * + */ +#ifndef VPX_VPX_VPX_EXT_RATECTRL_H_ +#define VPX_VPX_VPX_EXT_RATECTRL_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include "./vpx_integer.h" +#include "./vpx_tpl.h" + +/*!\brief Current ABI version number + * + * \internal + * If this file is altered in any way that changes the ABI, this value + * must be bumped. Examples include, but are not limited to, changing + * types, removing or reassigning enums, adding/removing/rearranging + * fields to structures. + */ +#define VPX_EXT_RATECTRL_ABI_VERSION (7 + VPX_TPL_ABI_VERSION) + +/*!\brief Corresponds to MAX_STATIC_GF_GROUP_LENGTH defined in vp9_ratectrl.h + */ +#define VPX_RC_MAX_STATIC_GF_GROUP_LENGTH 250 + +/*!\brief Max number of ref frames returned by the external RC. + * + * Corresponds to MAX_REF_FRAMES defined in vp9_blockd.h. + */ +#define VPX_RC_MAX_REF_FRAMES 4 + +/*!\brief The type of the external rate control. + * + * This controls what encoder parameters are determined by the external rate + * control. + */ +typedef enum vpx_rc_type { + /*! + * The external rate control doesn't determine anything. + * This mode is used as baseline. + */ + VPX_RC_NONE = 0, + /*! + * The external rate control model determines the quantization parameter (QP) + * for each frame. + */ + VPX_RC_QP = 1 << 0, + /*! + * The external rate control model determines the group of picture (GOP) of + * the video sequence. + */ + VPX_RC_GOP = 1 << 1, + /*! + * The external rate control model determines the rate-distortion multiplier + * (rdmult) for the current frame. + */ + VPX_RC_RDMULT = 1 << 2, + /*! + * The external rate control model determines both QP and GOP. + */ + VPX_RC_GOP_QP = VPX_RC_QP | VPX_RC_GOP, + /*! + * The external rate control model determines the QP, GOP and the rdmult. + */ + VPX_RC_GOP_QP_RDMULT = VPX_RC_QP | VPX_RC_GOP | VPX_RC_RDMULT +} vpx_rc_type_t; + +/*!\brief The rate control mode for the external rate control model. + */ +typedef enum vpx_ext_rc_mode { + VPX_RC_QMODE = 0, + VPX_RC_VBR = 1, + VPX_RC_CQ = 2, +} vpx_ext_rc_mode_t; + +/*!\brief Corresponds to FRAME_UPDATE_TYPE defined in vp9_firstpass.h. + */ +typedef enum vpx_rc_frame_update_type { + VPX_RC_INVALID_UPDATE_TYPE = -1, + VPX_RC_KF_UPDATE = 0, + VPX_RC_LF_UPDATE = 1, + VPX_RC_GF_UPDATE = 2, + VPX_RC_ARF_UPDATE = 3, + VPX_RC_OVERLAY_UPDATE = 4, + VPX_RC_MID_OVERLAY_UPDATE = 5, + VPX_RC_USE_BUF_FRAME = 6, +} vpx_rc_frame_update_type_t; + +/*!\brief Name for the ref frames returned by the external RC. + * + * Corresponds to the ref frames defined in vp9_blockd.h. + */ +typedef enum vpx_rc_ref_name { + VPX_RC_INVALID_REF_FRAME = -1, + VPX_RC_INTRA_FRAME = 0, + VPX_RC_LAST_FRAME = 1, + VPX_RC_GOLDEN_FRAME = 2, + VPX_RC_ALTREF_FRAME = 3, +} vpx_rc_ref_name_t; + +/*!\brief Abstract rate control model handler + * + * The encoder will receive the model handler from + * vpx_rc_funcs_t::create_model(). + */ +typedef void *vpx_rc_model_t; + +/*!\brief A reserved value for the q index. + * If the external rate control model returns this value, + * the encoder will use the default q selected by libvpx's rate control + * system. + */ +#define VPX_DEFAULT_Q -1 + +/*!\brief A reserved value for the rdmult. + * If the external rate control model returns this value, + * the encoder will use the default rdmult selected by libvpx's rate control + * system. + */ +#define VPX_DEFAULT_RDMULT -1 + +/*!\brief Superblock quantization parameters + * Store the superblock quantization parameters + */ +typedef struct sb_parameters { + int q_index; /**< Quantizer step index [0..255]*/ + int rdmult; /**< Superblock level Lagrangian multiplier*/ +} sb_params; + +/*!\brief Encode frame decision made by the external rate control model + * + * The encoder will receive the decision from the external rate control model + * through vpx_rc_funcs_t::get_encodeframe_decision(). + */ +typedef struct vpx_rc_encodeframe_decision { + int q_index; /**< Required: Quantizer step index [0..255]*/ + int rdmult; /**< Required: Frame level Lagrangian multiplier*/ + int delta_q_uv; /**< Required: Delta QP for UV */ + /*! + * Optional: Superblock quantization parameters + * It is zero initialized by default. It will be set for key and ARF frames + * but not leaf frames. + */ + sb_params *sb_params_list; +} vpx_rc_encodeframe_decision_t; + +/*!\brief Information for the frame to be encoded. + * + * The encoder will send the information to external rate control model through + * vpx_rc_funcs_t::get_encodeframe_decision(). + * + */ +typedef struct vpx_rc_encodeframe_info { + /*! + * 0: Key frame + * 1: Inter frame + * 2: Alternate reference frame + * 3: Overlay frame + * 4: Golden frame + */ + int frame_type; + int show_index; /**< display index, starts from zero*/ + int coding_index; /**< coding index, starts from zero*/ + /*! + * index of the current frame in this group of picture, starts from zero. + */ + int gop_index; + int ref_frame_coding_indexes[3]; /**< three reference frames' coding indices*/ + /*! + * The validity of the three reference frames. + * 0: Invalid + * 1: Valid + */ + int ref_frame_valid_list[3]; + /*! + * The length of the current GOP. + */ + int gop_size; + /*! + * Whether the current GOP uses an alt ref. + */ + int use_alt_ref; +} vpx_rc_encodeframe_info_t; + +/*!\brief Frame coding result + * + * The encoder will send the result to the external rate control model through + * vpx_rc_funcs_t::update_encodeframe_result(). + */ +typedef struct vpx_rc_encodeframe_result { + int64_t bit_count; /**< number of bits spent on coding the frame*/ + int actual_encoding_qindex; /**< the actual qindex used to encode the frame*/ +} vpx_rc_encodeframe_result_t; + +/*!\brief Status returned by rate control callback functions. + */ +typedef enum vpx_rc_status { + VPX_RC_OK = 0, + VPX_RC_ERROR = 1, +} vpx_rc_status_t; + +/*!\brief First pass frame stats + * This is a mirror of vp9's FIRSTPASS_STATS except that spatial_layer_id is + * omitted + */ +typedef struct vpx_rc_frame_stats { + /*! + * Frame number in display order, if stats are for a single frame. + * No real meaning for a collection of frames. + */ + double frame; + /*! + * Weight assigned to this frame (or total weight for the collection of + * frames) currently based on intra factor and brightness factor. This is used + * to distribute bits between easier and harder frames. + */ + double weight; + /*! + * Intra prediction error. + */ + double intra_error; + /*! + * Best of intra pred error and inter pred error using last frame as ref. + */ + double coded_error; + /*! + * Best of intra pred error and inter pred error using golden frame as ref. + */ + double sr_coded_error; + /*! + * Estimate the noise energy of the current frame. + */ + double frame_noise_energy; + /*! + * Percentage of blocks with inter pred error < intra pred error. + */ + double pcnt_inter; + /*! + * Percentage of blocks using (inter prediction and) non-zero motion vectors. + */ + double pcnt_motion; + /*! + * Percentage of blocks where golden frame was better than last or intra: + * inter pred error using golden frame < inter pred error using last frame and + * inter pred error using golden frame < intra pred error + */ + double pcnt_second_ref; + /*! + * Percentage of blocks where intra and inter prediction errors were very + * close. + */ + double pcnt_neutral; + /*! + * Percentage of blocks that have intra error < inter error and inter error < + * LOW_I_THRESH + * - bit_depth 8: LOW_I_THRESH = 24000 + * - bit_depth 10: LOW_I_THRESH = 24000 << 4 + * - bit_depth 12: LOW_I_THRESH = 24000 << 8 + */ + double pcnt_intra_low; + /*! + * Percentage of blocks that have intra error < inter error and intra error < + * LOW_I_THRESH but inter error >= LOW_I_THRESH LOW_I_THRESH + * - bit_depth 8: LOW_I_THRESH = 24000 + * - bit_depth 10: LOW_I_THRESH = 24000 << 4 + * - bit_depth 12: LOW_I_THRESH = 24000 << 8 + */ + double pcnt_intra_high; + /*! + * Percentage of blocks that have almost no intra error residual + * (i.e. are in effect completely flat and untextured in the intra + * domain). In natural videos this is uncommon, but it is much more + * common in animations, graphics and screen content, so may be used + * as a signal to detect these types of content. + */ + double intra_skip_pct; + /*! + * Percentage of blocks that have intra error < SMOOTH_INTRA_THRESH + * - bit_depth 8: SMOOTH_INTRA_THRESH = 4000 + * - bit_depth 10: SMOOTH_INTRA_THRESH = 4000 << 4 + * - bit_depth 12: SMOOTH_INTRA_THRESH = 4000 << 8 + */ + double intra_smooth_pct; + /*! + * Image mask rows top and bottom. + */ + double inactive_zone_rows; + /*! + * Image mask columns at left and right edges. + */ + double inactive_zone_cols; + /*! + * Mean of row motion vectors. + */ + double MVr; + /*! + * Mean of absolute value of row motion vectors. + */ + double mvr_abs; + /*! + * Mean of column motion vectors. + */ + double MVc; + /*! + * Mean of absolute value of column motion vectors. + */ + double mvc_abs; + /*! + * Variance of row motion vectors. + */ + double MVrv; + /*! + * Variance of column motion vectors. + */ + double MVcv; + /*! + * Value in range [-1,1] indicating fraction of row and column motion vectors + * that point inwards (negative MV value) or outwards (positive MV value). + * For example, value of 1 indicates, all row/column MVs are inwards. + */ + double mv_in_out_count; + /*! + * Duration of the frame / collection of frames. + */ + double duration; + /*! + * 1.0 if stats are for a single frame, or + * number of frames whose stats are accumulated. + */ + double count; + /*! + * Number of new mv in a frame. + */ + double new_mv_count; +} vpx_rc_frame_stats_t; + +/*!\brief Collection of first pass frame stats + */ +typedef struct vpx_rc_firstpass_stats { + /*! + * Pointer to first pass frame stats. + * The pointed array of vpx_rc_frame_stats_t should have length equal to + * number of show frames in the video. + */ + vpx_rc_frame_stats_t *frame_stats; + /*! + * Number of show frames in the video. + */ + int num_frames; +} vpx_rc_firstpass_stats_t; + +/*!\brief Encode config sent to external rate control model + */ +typedef struct vpx_rc_config { + int frame_width; /**< frame width */ + int frame_height; /**< frame height */ + int show_frame_count; /**< number of visible frames in the video */ + int max_gf_interval; /**< max GOP size in number of show frames */ + int min_gf_interval; /**< min GOP size in number of show frames */ + /*! + * Target bitrate in kilobytes per second + */ + int target_bitrate_kbps; + int frame_rate_num; /**< numerator of frame rate */ + int frame_rate_den; /**< denominator of frame rate */ + /*! + * The following fields are only for external rate control models that support + * different rate control modes. + */ + vpx_ext_rc_mode_t rc_mode; /**< Q mode or VBR mode */ + int overshoot_percent; /**< for VBR mode only */ + int undershoot_percent; /**< for VBR mode only */ + int min_base_q_index; /**< for VBR mode only */ + int max_base_q_index; /**< for VBR mode only */ + int base_qp; /**< base QP for leaf frames, 0-255 */ +} vpx_rc_config_t; + +/*!\brief Control what ref frame to use and its index. + */ +typedef struct vpx_rc_ref_frame { + /*! + * Ref frame index. Corresponding to |lst_fb_idx|, |gld_fb_idx| or + * |alt_fb_idx| in VP9_COMP depending on the ref frame #name. + */ + int index[VPX_RC_MAX_REF_FRAMES]; + /*! + * Ref frame name. This decides whether the #index is used as + * |lst_fb_idx|, |gld_fb_idx| or |alt_fb_idx| in VP9_COMP. + * + */ + vpx_rc_ref_name_t name[VPX_RC_MAX_REF_FRAMES]; +} vpx_rc_ref_frame_t; + +/*!\brief The decision made by the external rate control model to set the + * group of picture. + */ +typedef struct vpx_rc_gop_decision { + int gop_coding_frames; /**< The number of frames of this GOP */ + int use_alt_ref; /**< Whether to use alt ref for this GOP */ + int use_key_frame; /**< Whether to set key frame for this GOP */ + /*! + * Frame type for each frame in this GOP. + * This will be populated to |update_type| in GF_GROUP defined in + * vp9_firstpass.h + */ + vpx_rc_frame_update_type_t update_type[VPX_RC_MAX_STATIC_GF_GROUP_LENGTH + 2]; + /*! Ref frame buffer index to be updated for each frame in this GOP. */ + int update_ref_index[VPX_RC_MAX_STATIC_GF_GROUP_LENGTH + 2]; + /*! Ref frame list to be used for each frame in this GOP. */ + vpx_rc_ref_frame_t ref_frame_list[VPX_RC_MAX_STATIC_GF_GROUP_LENGTH + 2]; +} vpx_rc_gop_decision_t; + +/*!\brief The decision made by the external rate control model to set the + * key frame location and the show frame count in the key frame group + */ +typedef struct vpx_rc_key_frame_decision { + int key_frame_show_index; /**< This key frame's show index in the video */ + int key_frame_group_size; /**< Show frame count of this key frame group */ +} vpx_rc_key_frame_decision_t; + +/*!\brief Create an external rate control model callback prototype + * + * This callback is invoked by the encoder to create an external rate control + * model. + * + * \param[in] priv Callback's private data + * \param[in] ratectrl_config Pointer to vpx_rc_config_t + * \param[out] rate_ctrl_model_ptr Pointer to vpx_rc_model_t + */ +typedef vpx_rc_status_t (*vpx_rc_create_model_cb_fn_t)( + void *priv, const vpx_rc_config_t *ratectrl_config, + vpx_rc_model_t *rate_ctrl_model_ptr); + +/*!\brief Send first pass stats to the external rate control model callback + * prototype + * + * This callback is invoked by the encoder to send first pass stats to the + * external rate control model. + * + * \param[in] rate_ctrl_model rate control model + * \param[in] first_pass_stats first pass stats + */ +typedef vpx_rc_status_t (*vpx_rc_send_firstpass_stats_cb_fn_t)( + vpx_rc_model_t rate_ctrl_model, + const vpx_rc_firstpass_stats_t *first_pass_stats); + +/*!\brief Send TPL stats for the current GOP to the external rate control model + * callback prototype + * + * This callback is invoked by the encoder to send TPL stats for the GOP to the + * external rate control model. + * + * \param[in] rate_ctrl_model rate control model + * \param[in] tpl_gop_stats TPL stats for current GOP + */ +typedef vpx_rc_status_t (*vpx_rc_send_tpl_gop_stats_cb_fn_t)( + vpx_rc_model_t rate_ctrl_model, const VpxTplGopStats *tpl_gop_stats); + +/*!\brief Receive encode frame decision callback prototype + * + * This callback is invoked by the encoder to receive encode frame decision from + * the external rate control model. + * + * \param[in] rate_ctrl_model rate control model + * \param[in] frame_gop_index index of the frame in current gop + * \param[out] frame_decision encode decision of the coding frame + */ +typedef vpx_rc_status_t (*vpx_rc_get_encodeframe_decision_cb_fn_t)( + vpx_rc_model_t rate_ctrl_model, const int frame_gop_index, + vpx_rc_encodeframe_decision_t *frame_decision); + +/*!\brief Update encode frame result callback prototype + * + * This callback is invoked by the encoder to update encode frame result to the + * external rate control model. + * + * \param[in] rate_ctrl_model rate control model + * \param[out] encode_frame_result encode result of the coding frame + */ +typedef vpx_rc_status_t (*vpx_rc_update_encodeframe_result_cb_fn_t)( + vpx_rc_model_t rate_ctrl_model, + const vpx_rc_encodeframe_result_t *encode_frame_result); + +/*!\brief Get the key frame decision from the external rate control model. + * + * This callback is invoked by the encoder to get key frame decision from + * the external rate control model. + * + * \param[in] rate_ctrl_model rate control model + * \param[out] key_frame_decision key frame decision from the model + */ +typedef vpx_rc_status_t (*vpx_rc_get_key_frame_decision_cb_fn_t)( + vpx_rc_model_t rate_ctrl_model, + vpx_rc_key_frame_decision_t *key_frame_decision); + +/*!\brief Get the GOP structure from the external rate control model. + * + * This callback is invoked by the encoder to get GOP decisions from + * the external rate control model. + * + * \param[in] rate_ctrl_model rate control model + * \param[out] gop_decision GOP decision from the model + */ +typedef vpx_rc_status_t (*vpx_rc_get_gop_decision_cb_fn_t)( + vpx_rc_model_t rate_ctrl_model, vpx_rc_gop_decision_t *gop_decision); + +/*!\brief Get the frame rdmult from the external rate control model. + * + * This callback is invoked by the encoder to get rdmult from + * the external rate control model. + * + * \param[in] rate_ctrl_model rate control model + * \param[in] frame_info information collected from the encoder + * \param[out] rdmult frame rate-distortion multiplier from the model + */ +typedef vpx_rc_status_t (*vpx_rc_get_frame_rdmult_cb_fn_t)( + vpx_rc_model_t rate_ctrl_model, const vpx_rc_encodeframe_info_t *frame_info, + int *rdmult); + +/*!\brief Delete the external rate control model callback prototype + * + * This callback is invoked by the encoder to delete the external rate control + * model. + * + * \param[in] rate_ctrl_model rate control model + */ +typedef vpx_rc_status_t (*vpx_rc_delete_model_cb_fn_t)( + vpx_rc_model_t rate_ctrl_model); + +/*!\brief Callback function set for external rate control. + * + * The user can enable external rate control by registering + * a set of callback functions with the codec control flag + * #VP9E_SET_EXTERNAL_RATE_CONTROL. + */ +typedef struct vpx_rc_funcs { + /*! + * The rate control type of this API. + */ + vpx_rc_type_t rc_type; + /*! + * Create an external rate control model. + */ + vpx_rc_create_model_cb_fn_t create_model; + /*! + * Send first pass stats to the external rate control model. + */ + vpx_rc_send_firstpass_stats_cb_fn_t send_firstpass_stats; + /*! + * Send TPL stats for current GOP to the external rate control model. + */ + vpx_rc_send_tpl_gop_stats_cb_fn_t send_tpl_gop_stats; + /*! + * Get encodeframe decision from the external rate control model. + */ + vpx_rc_get_encodeframe_decision_cb_fn_t get_encodeframe_decision; + /*! + * Update encodeframe result to the external rate control model. + */ + vpx_rc_update_encodeframe_result_cb_fn_t update_encodeframe_result; + /*! + * Get key frame decision from the external rate control model. + */ + vpx_rc_get_key_frame_decision_cb_fn_t get_key_frame_decision; + /*! + * Get GOP decisions from the external rate control model. + */ + vpx_rc_get_gop_decision_cb_fn_t get_gop_decision; + /*! + * Get rdmult for the frame from the external rate control model. + */ + vpx_rc_get_frame_rdmult_cb_fn_t get_frame_rdmult; + /*! + * Delete the external rate control model. + */ + vpx_rc_delete_model_cb_fn_t delete_model; + + /*! + * Rate control log path. + */ + const char *rate_ctrl_log_path; + /*! + * Private data for the external rate control model. + */ + void *priv; +} vpx_rc_funcs_t; + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // VPX_VPX_VPX_EXT_RATECTRL_H_ diff --git a/native/third_party/ucrt64/include/vpx/vpx_frame_buffer.h b/native/third_party/ucrt64/include/vpx/vpx_frame_buffer.h new file mode 100644 index 0000000..b8a0ec2 --- /dev/null +++ b/native/third_party/ucrt64/include/vpx/vpx_frame_buffer.h @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2014 The WebM project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +#ifndef VPX_VPX_VPX_FRAME_BUFFER_H_ +#define VPX_VPX_VPX_FRAME_BUFFER_H_ + +/*!\file + * \brief Describes the decoder external frame buffer interface. + */ + +#ifdef __cplusplus +extern "C" { +#endif + +#include "./vpx_integer.h" + +/*!\brief The maximum number of work buffers used by libvpx. + * Support maximum 4 threads to decode video in parallel. + * Each thread will use one work buffer. + * TODO(hkuang): Add support to set number of worker threads dynamically. + */ +#define VPX_MAXIMUM_WORK_BUFFERS 8 + +/*!\brief The maximum number of reference buffers that a VP9 encoder may use. + */ +#define VP9_MAXIMUM_REF_BUFFERS 8 + +/*!\brief External frame buffer + * + * This structure holds allocated frame buffers used by the decoder. + */ +typedef struct vpx_codec_frame_buffer { + uint8_t *data; /**< Pointer to the data buffer */ + size_t size; /**< Size of data in bytes */ + void *priv; /**< Frame's private data */ +} vpx_codec_frame_buffer_t; + +/*!\brief get frame buffer callback prototype + * + * This callback is invoked by the decoder to retrieve data for the frame + * buffer in order for the decode call to complete. The callback must + * allocate at least min_size in bytes and assign it to fb->data. The callback + * must zero out all the data allocated. Then the callback must set fb->size + * to the allocated size. The application does not need to align the allocated + * data. The callback is triggered when the decoder needs a frame buffer to + * decode a compressed image into. This function may be called more than once + * for every call to vpx_codec_decode. The application may set fb->priv to + * some data which will be passed back in the vpx_image_t and the release + * function call. |fb| is guaranteed to not be NULL. On success the callback + * must return 0. Any failure the callback must return a value less than 0. + * + * \param[in] priv Callback's private data + * \param[in] min_size Size in bytes needed by the buffer + * \param[in,out] fb Pointer to vpx_codec_frame_buffer_t + */ +typedef int (*vpx_get_frame_buffer_cb_fn_t)(void *priv, size_t min_size, + vpx_codec_frame_buffer_t *fb); + +/*!\brief release frame buffer callback prototype + * + * This callback is invoked by the decoder when the frame buffer is not + * referenced by any other buffers. |fb| is guaranteed to not be NULL. On + * success the callback must return 0. Any failure the callback must return + * a value less than 0. + * + * \param[in] priv Callback's private data + * \param[in] fb Pointer to vpx_codec_frame_buffer_t + */ +typedef int (*vpx_release_frame_buffer_cb_fn_t)(void *priv, + vpx_codec_frame_buffer_t *fb); + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // VPX_VPX_VPX_FRAME_BUFFER_H_ diff --git a/native/third_party/ucrt64/include/vpx/vpx_image.h b/native/third_party/ucrt64/include/vpx/vpx_image.h new file mode 100644 index 0000000..a998393 --- /dev/null +++ b/native/third_party/ucrt64/include/vpx/vpx_image.h @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2010 The WebM project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +/*!\file + * \brief Describes the vpx image descriptor and associated operations + * + */ +#ifndef VPX_VPX_VPX_IMAGE_H_ +#define VPX_VPX_VPX_IMAGE_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +/*!\brief Current ABI version number + * + * \internal + * If this file is altered in any way that changes the ABI, this value + * must be bumped. Examples include, but are not limited to, changing + * types, removing or reassigning enums, adding/removing/rearranging + * fields to structures + */ +#define VPX_IMAGE_ABI_VERSION (5) /**<\hideinitializer*/ + +#define VPX_IMG_FMT_PLANAR 0x100 /**< Image is a planar format. */ +#define VPX_IMG_FMT_UV_FLIP 0x200 /**< V plane precedes U in memory. */ +#define VPX_IMG_FMT_HAS_ALPHA 0x400 /**< Image has an alpha channel. */ +#define VPX_IMG_FMT_HIGHBITDEPTH 0x800 /**< Image uses 16bit framebuffer. */ + +/*!\brief List of supported image formats */ +typedef enum vpx_img_fmt { + VPX_IMG_FMT_NONE, + VPX_IMG_FMT_YV12 = + VPX_IMG_FMT_PLANAR | VPX_IMG_FMT_UV_FLIP | 1, /**< planar YVU */ + VPX_IMG_FMT_I420 = VPX_IMG_FMT_PLANAR | 2, + VPX_IMG_FMT_I422 = VPX_IMG_FMT_PLANAR | 5, + VPX_IMG_FMT_I444 = VPX_IMG_FMT_PLANAR | 6, + VPX_IMG_FMT_I440 = VPX_IMG_FMT_PLANAR | 7, + VPX_IMG_FMT_NV12 = VPX_IMG_FMT_PLANAR | 9, + VPX_IMG_FMT_I42016 = VPX_IMG_FMT_I420 | VPX_IMG_FMT_HIGHBITDEPTH, + VPX_IMG_FMT_I42216 = VPX_IMG_FMT_I422 | VPX_IMG_FMT_HIGHBITDEPTH, + VPX_IMG_FMT_I44416 = VPX_IMG_FMT_I444 | VPX_IMG_FMT_HIGHBITDEPTH, + VPX_IMG_FMT_I44016 = VPX_IMG_FMT_I440 | VPX_IMG_FMT_HIGHBITDEPTH +} vpx_img_fmt_t; /**< alias for enum vpx_img_fmt */ + +/*!\brief List of supported color spaces */ +typedef enum vpx_color_space { + VPX_CS_UNKNOWN = 0, /**< Unknown */ + VPX_CS_BT_601 = 1, /**< BT.601 */ + VPX_CS_BT_709 = 2, /**< BT.709 */ + VPX_CS_SMPTE_170 = 3, /**< SMPTE.170 */ + VPX_CS_SMPTE_240 = 4, /**< SMPTE.240 */ + VPX_CS_BT_2020 = 5, /**< BT.2020 */ + VPX_CS_RESERVED = 6, /**< Reserved */ + VPX_CS_SRGB = 7 /**< sRGB */ +} vpx_color_space_t; /**< alias for enum vpx_color_space */ + +/*!\brief List of supported color range */ +typedef enum vpx_color_range { + VPX_CR_STUDIO_RANGE = 0, /**<- Y [16..235], UV [16..240] (bit depth 8) */ + /**<- Y [64..940], UV [64..960] (bit depth 10) */ + /**<- Y [256..3760], UV [256..3840] (bit depth 12) */ + VPX_CR_FULL_RANGE = 1 /**<- YUV/RGB [0..255] (bit depth 8) */ + /**<- YUV/RGB [0..1023] (bit depth 10) */ + /**<- YUV/RGB [0..4095] (bit depth 12) */ +} vpx_color_range_t; /**< alias for enum vpx_color_range */ + +/**\brief Image Descriptor */ +typedef struct vpx_image { + vpx_img_fmt_t fmt; /**< Image Format */ + vpx_color_space_t cs; /**< Color Space */ + vpx_color_range_t range; /**< Color Range */ + + /* Image storage dimensions */ + unsigned int w; /**< Stored image width */ + unsigned int h; /**< Stored image height */ + unsigned int bit_depth; /**< Stored image bit-depth */ + + /* Image display dimensions */ + unsigned int d_w; /**< Displayed image width */ + unsigned int d_h; /**< Displayed image height */ + + /* Image intended rendering dimensions */ + unsigned int r_w; /**< Intended rendering image width */ + unsigned int r_h; /**< Intended rendering image height */ + + /* Chroma subsampling info */ + unsigned int x_chroma_shift; /**< subsampling order, X */ + unsigned int y_chroma_shift; /**< subsampling order, Y */ + +/* Image data pointers. */ +#define VPX_PLANE_PACKED 0 /**< To be used for all packed formats */ +#define VPX_PLANE_Y 0 /**< Y (Luminance) plane */ +#define VPX_PLANE_U 1 /**< U (Chroma) plane */ +#define VPX_PLANE_V 2 /**< V (Chroma) plane */ +#define VPX_PLANE_ALPHA 3 /**< A (Transparency) plane */ + unsigned char *planes[4]; /**< pointer to the top left pixel for each plane */ + /*!Stride between rows for each plane + * + * \note With planar formats, \c stride[VPX_PLANE_U] must be the same as \c + * stride[VPX_PLANE_V]. + */ + int stride[4]; + + int bps; /**< bits per sample (for packed formats) */ + + /*!\brief The following member may be set by the application to associate + * data with this image. + */ + void *user_priv; + + /* The following members should be treated as private. */ + unsigned char *img_data; /**< private */ + int img_data_owner; /**< private */ + int self_allocd; /**< private */ + + void *fb_priv; /**< Frame buffer data associated with the image. */ +} vpx_image_t; /**< alias for struct vpx_image */ + +/**\brief Representation of a rectangle on a surface */ +typedef struct vpx_image_rect { + unsigned int x; /**< leftmost column */ + unsigned int y; /**< topmost row */ + unsigned int w; /**< width */ + unsigned int h; /**< height */ +} vpx_image_rect_t; /**< alias for struct vpx_image_rect */ + +/*!\brief Open a descriptor, allocating storage for the underlying image + * + * Returns a descriptor for storing an image of the given format. The + * storage for the image is allocated on the heap. + * + * \param[in] img Pointer to storage for descriptor. If this parameter + * is NULL, the storage for the descriptor will be + * allocated on the heap. + * \param[in] fmt Format for the image + * \param[in] d_w Width of the image. Must not exceed 0x08000000 + * (2^27). + * \param[in] d_h Height of the image. Must not exceed 0x08000000 + * (2^27). + * \param[in] align Alignment, in bytes, of the image buffer and + * each row in the image (stride). Must not exceed + * 65536. + * + * \return Returns a pointer to the initialized image descriptor. If the img + * parameter is non-null, the value of the img parameter will be + * returned. + */ +vpx_image_t *vpx_img_alloc(vpx_image_t *img, vpx_img_fmt_t fmt, + unsigned int d_w, unsigned int d_h, + unsigned int align); + +/*!\brief Open a descriptor, using existing storage for the underlying image + * + * Returns a descriptor for storing an image of the given format. The + * storage for the image has been allocated elsewhere, and a descriptor is + * desired to "wrap" that storage. + * + * \param[in] img Pointer to storage for descriptor. If this + * parameter is NULL, the storage for the descriptor + * will be allocated on the heap. + * \param[in] fmt Format for the image + * \param[in] d_w Width of the image. Must not exceed 0x08000000 + * (2^27). + * \param[in] d_h Height of the image. Must not exceed 0x08000000 + * (2^27). + * \param[in] stride_align Alignment, in bytes, of each row in the image + * (stride). Must not exceed 65536. + * \param[in] img_data Storage to use for the image. The storage must + * outlive the returned image descriptor; it can be + * disposed of after calling vpx_img_free(). + * + * \return Returns a pointer to the initialized image descriptor. If the img + * parameter is non-null, the value of the img parameter will be + * returned. + * + * \note \a img_data is required to have a minimum allocation size that + * satisfies the requirements of the \a fmt, \a d_w, \a d_h and \a + * stride_align parameters. This size can be calculated as follows (see + * \c img_alloc_helper in the vpx_image.c file in the libvpx source tree + * for more detail): + * \code + * align = (1 << x_chroma_shift) - 1; + * w = (d_w + align) & ~align; + * align = (1 << y_chroma_shift) - 1; + * h = (d_h + align) & ~align; + * + * s = (fmt & VPX_IMG_FMT_PLANAR) ? w : (uint64_t)bps * w / 8; + * s = (fmt & VPX_IMG_FMT_HIGHBITDEPTH) ? s * 2 : s; + * s = (s + stride_align - 1) & ~((uint64_t)stride_align - 1); + * s = (fmt & VPX_IMG_FMT_HIGHBITDEPTH) ? s / 2 : s; + * alloc_size = (fmt & VPX_IMG_FMT_PLANAR) ? (uint64_t)h * s * bps / 8 + * : (uint64_t)h * s; + * \endcode + * \a x_chroma_shift, \a y_chroma_shift and \a bps can be obtained by calling + * \ref vpx_img_wrap with a non-\c NULL \a img_data parameter. The \c + * vpx_image_t pointer should \em not be used in other API calls until \em after + * a successful call to \ref vpx_img_wrap with a valid image buffer. For + * example: + * \code + * vpx_img_wrap(img, fmt, d_w, d_h, stride_align, (unsigned char *)1); + * ... calculate buffer size and allocate buffer as described earlier + * vpx_img_wrap(img, fmt, d_w, d_h, stride_align, img_data); + * \endcode + */ +vpx_image_t *vpx_img_wrap(vpx_image_t *img, vpx_img_fmt_t fmt, unsigned int d_w, + unsigned int d_h, unsigned int stride_align, + unsigned char *img_data); + +/*!\brief Set the rectangle identifying the displayed portion of the image + * + * Updates the displayed rectangle (aka viewport) on the image surface to + * match the specified coordinates and size. Specifically, sets img->d_w, + * img->d_h, and elements of the img->planes[] array. + * + * \param[in] img Image descriptor + * \param[in] x leftmost column + * \param[in] y topmost row + * \param[in] w width + * \param[in] h height + * + * \return 0 if the requested rectangle is valid, nonzero (-1) otherwise. + */ +int vpx_img_set_rect(vpx_image_t *img, unsigned int x, unsigned int y, + unsigned int w, unsigned int h); + +/*!\brief Flip the image vertically (top for bottom) + * + * Adjusts the image descriptor's pointers and strides to make the image + * be referenced upside-down. + * + * \param[in] img Image descriptor + */ +void vpx_img_flip(vpx_image_t *img); + +/*!\brief Close an image descriptor + * + * Frees all allocated storage associated with an image descriptor. + * + * \param[in] img Image descriptor + */ +void vpx_img_free(vpx_image_t *img); + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // VPX_VPX_VPX_IMAGE_H_ diff --git a/native/third_party/ucrt64/include/vpx/vpx_integer.h b/native/third_party/ucrt64/include/vpx/vpx_integer.h new file mode 100644 index 0000000..460bcc8 --- /dev/null +++ b/native/third_party/ucrt64/include/vpx/vpx_integer.h @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2010 The WebM project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +#ifndef VPX_VPX_VPX_INTEGER_H_ +#define VPX_VPX_VPX_INTEGER_H_ + +/* get ptrdiff_t, size_t, wchar_t, NULL */ +#include // IWYU pragma: export + +#if defined(_MSC_VER) +#define VPX_FORCE_INLINE __forceinline +#define VPX_INLINE __inline +#else +#define VPX_FORCE_INLINE __inline__ __attribute__((always_inline)) +// TODO(jbb): Allow a way to force inline off for older compilers. +#define VPX_INLINE inline +#endif + +/* Assume platforms have the C99 standard integer types. */ + +#if defined(__cplusplus) +#if !defined(__STDC_FORMAT_MACROS) +#define __STDC_FORMAT_MACROS +#endif +#if !defined(__STDC_LIMIT_MACROS) +#define __STDC_LIMIT_MACROS +#endif +#endif // __cplusplus + +#include // IWYU pragma: export +#include // IWYU pragma: export + +#endif // VPX_VPX_VPX_INTEGER_H_ diff --git a/native/third_party/ucrt64/include/vpx/vpx_tpl.h b/native/third_party/ucrt64/include/vpx/vpx_tpl.h new file mode 100644 index 0000000..9bb6d0f --- /dev/null +++ b/native/third_party/ucrt64/include/vpx/vpx_tpl.h @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2023 The WebM project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +/*!\file + * \brief Describes the TPL stats descriptor and associated operations + * + */ +#ifndef VPX_VPX_VPX_TPL_H_ +#define VPX_VPX_VPX_TPL_H_ + +#include "./vpx_integer.h" +#include "./vpx_codec.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/*!\brief Current ABI version number + * + * \internal + * If this file is altered in any way that changes the ABI, this value + * must be bumped. Examples include, but are not limited to, changing + * types, removing or reassigning enums, adding/removing/rearranging + * fields to structures + */ +#define VPX_TPL_ABI_VERSION 5 /**<\hideinitializer*/ + +/*!\brief Temporal dependency model stats for each block before propagation */ +typedef struct VpxTplBlockStats { + int16_t row; /**< Pixel row of the top left corner */ + int16_t col; /**< Pixel col of the top left corner */ + int64_t intra_cost; /**< Intra cost */ + int64_t inter_cost; /**< Inter cost */ + int16_t mv_r; /**< Motion vector row in pixel */ + int16_t mv_c; /**< Motion vector col in pixel */ + int64_t srcrf_rate; /**< Rate from source ref frame */ + int64_t srcrf_dist; /**< Distortion from source ref frame */ + int64_t pred_error; /**< Prediction error */ + int64_t inter_pred_err; /**< Inter prediction error */ + int64_t intra_pred_err; /**< Intra prediction error */ + int ref_frame_index; /**< Ref frame index in the ref frame buffer */ +} VpxTplBlockStats; + +/*!\brief Temporal dependency model stats for each frame before propagation */ +typedef struct VpxTplFrameStats { + int frame_width; /**< Frame width */ + int frame_height; /**< Frame height */ + int num_blocks; /**< Number of blocks. Size of block_stats_list */ + VpxTplBlockStats *block_stats_list; /**< List of tpl stats for each block */ +} VpxTplFrameStats; + +/*!\brief Temporal dependency model stats for each GOP before propagation */ +typedef struct VpxTplGopStats { + int size; /**< GOP size, also the size of frame_stats_list. */ + VpxTplFrameStats *frame_stats_list; /**< List of tpl stats for each frame */ +} VpxTplGopStats; + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // VPX_VPX_VPX_TPL_H_ diff --git a/native/third_party/ucrt64/lib/libopus.a b/native/third_party/ucrt64/lib/libopus.a new file mode 100644 index 0000000..241b47a Binary files /dev/null and b/native/third_party/ucrt64/lib/libopus.a differ diff --git a/native/third_party/ucrt64/lib/libvpx.a b/native/third_party/ucrt64/lib/libvpx.a new file mode 100644 index 0000000..e30a170 Binary files /dev/null and b/native/third_party/ucrt64/lib/libvpx.a differ diff --git a/native/ui/.gitignore b/native/ui/.gitignore new file mode 100644 index 0000000..36905b4 --- /dev/null +++ b/native/ui/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Widget Preview related +.widget_preview/ diff --git a/native/ui/.metadata b/native/ui/.metadata new file mode 100644 index 0000000..12552b9 --- /dev/null +++ b/native/ui/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "abaf9c523780a608bd46686fd5e53740a07077f8" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: abaf9c523780a608bd46686fd5e53740a07077f8 + base_revision: abaf9c523780a608bd46686fd5e53740a07077f8 + - platform: android + create_revision: abaf9c523780a608bd46686fd5e53740a07077f8 + base_revision: abaf9c523780a608bd46686fd5e53740a07077f8 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/native/ui/README.md b/native/ui/README.md new file mode 100644 index 0000000..5361502 --- /dev/null +++ b/native/ui/README.md @@ -0,0 +1,89 @@ +# NetDesk UI (Flutter) + +The premium cross-platform front-end for **NetDesk by Akronlogic** — a +self-hosted remote-desktop client targeting **Windows, macOS, Linux, iOS and +Android** from one Flutter codebase. + +This package is the *presentation layer only*. The heavy lifting — signaling, +NAT traversal, the encrypted transport, capture/encode, decode and input +injection — lives in the Rust crate [`netdesk-core`](../core). The two are +joined in **Phase 3** via [`flutter_rust_bridge`](https://pub.dev/packages/flutter_rust_bridge). + +--- + +## Layout + +``` +ui/ +├─ pubspec.yaml # package + deps (flutter_rust_bridge/ffi land in Phase 3) +├─ lib/ +│ ├─ main.dart # NetDeskApp root, dark theme, home = ConnectScreen +│ ├─ theme.dart # color tokens (#0B0F15 bg, #22C55E accent) + ThemeData +│ ├─ connect_screen.dart # wordmark, Partner ID + password, Connect, "this device" ID +│ └─ session_screen.dart # AppBar "NetDesk — " + toolbar; hosts remote texture later +└─ README.md +``` + +## Design language + +- **Background** `#0B0F15` (deep near-black), **surface** `#121821`, hairline + borders `#1E2733`. +- **Accent** `#22C55E` green for the primary action, the brand mark and the + "connected" indicator. +- Rounded cards, generous spacing, TeamViewer / RustDesk-grade information + hierarchy. Dark-only for now. + +All values are tokens in `lib/theme.dart` (`NetDeskColors`, `NetDeskRadius`, +`NetDeskSpacing`) — no hardcoded hex elsewhere in the app. + +--- + +## How this binds to `netdesk-core` (Phase 3) + +1. **Add the bridge deps.** Uncomment `flutter_rust_bridge` and `ffi` in + `pubspec.yaml`, then `flutter pub get`. +2. **Expose a Rust API.** In `../core`, add a `#[frb]`-annotated API module + (e.g. `src/api/session.rs`) with the calls the UI needs: + `local_id()`, `connect(partner_id, password)`, `disconnect()`, + `list_monitors()`, a frame/event stream, etc. +3. **Generate the glue.** Run `flutter_rust_bridge_codegen generate`, which + emits typed Dart bindings under `lib/src/rust/` and the matching Rust + `frb_generated.rs`. The core is compiled as a C-ABI dylib/staticlib and + linked per platform. +4. **Wire the screens.** + - `ConnectScreen` — the Connect button calls `connect(...)`; the + "This device's ID" area shows `local_id()` instead of the current + placeholder. + - `SessionScreen` — the central placeholder becomes a `Texture` whose + frames are pushed from the core's decoder; pointer/keyboard events are + forwarded back for injection; the toolbar actions (disconnect, + fullscreen, monitors, file transfer, settings) call their core + counterparts. + +Until then every control is a styled, non-functional placeholder so the UX can +be reviewed and polished independently of the core. + +--- + +## Running (once Flutter is installed) + +> Flutter is **not** required to author these files, and was not run to create +> them. Install the Flutter SDK (>= 3.22) to build. + +```bash +cd ui +flutter pub get + +# Desktop +flutter run -d windows +flutter run -d macos +flutter run -d linux + +# Mobile (device/emulator attached) +flutter run -d android +flutter run -d ios +``` + +Enable the desktop targets once with `flutter config --enable-windows-desktop` +(and the `--enable-macos-desktop` / `--enable-linux-desktop` equivalents) if +they are not already on. diff --git a/native/ui/analysis_options.yaml b/native/ui/analysis_options.yaml new file mode 100644 index 0000000..08e8251 --- /dev/null +++ b/native/ui/analysis_options.yaml @@ -0,0 +1,33 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - build/** + - android/** + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/native/ui/android/.gitignore b/native/ui/android/.gitignore new file mode 100644 index 0000000..137dca9 --- /dev/null +++ b/native/ui/android/.gitignore @@ -0,0 +1,18 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks + +# NetDesk: prebuilt Rust .so are build artifacts, regenerated by cargo-ndk +# (packaging\build-android.ps1). Do not commit them. +/app/src/main/jniLibs/ diff --git a/native/ui/android/app/build.gradle.kts b/native/ui/android/app/build.gradle.kts new file mode 100644 index 0000000..465f7de --- /dev/null +++ b/native/ui/android/app/build.gradle.kts @@ -0,0 +1,83 @@ +import java.util.Properties +import java.io.FileInputStream + +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +// --------------------------------------------------------------------------- +// NetDesk release signing. +// Credentials live in android/key.properties (git-ignored). Generate the +// keystore + this file with: packaging\build-android.ps1 -GenerateKeystore +// When key.properties is absent the release build falls back to DEBUG signing +// so `flutter run --release` keeps working for local development. +// --------------------------------------------------------------------------- +val keystorePropertiesFile = rootProject.file("key.properties") +val keystoreProperties = Properties() +if (keystorePropertiesFile.exists()) { + FileInputStream(keystorePropertiesFile).use { keystoreProperties.load(it) } +} + +android { + namespace = "space.netdesk" + compileSdk = flutter.compileSdkVersion + + // NetDesk ships prebuilt Rust .so under src/main/jniLibs//libnetdesk_core.so + // (produced by cargo-ndk). Point AGP at the NDK already on disk via + // ANDROID_NDK_HOME so a Gradle build never triggers an NDK download; fall + // back to Flutter's pinned NDK version when the env var is not set. + val ndkHomeEnv = System.getenv("ANDROID_NDK_HOME") + if (ndkHomeEnv != null && file(ndkHomeEnv).exists()) { + ndkPath = ndkHomeEnv + } else { + ndkVersion = flutter.ndkVersion + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // NetDesk application id (reverse of netdesk.space). + applicationId = "space.netdesk" + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + signingConfigs { + create("release") { + if (keystorePropertiesFile.exists()) { + keyAlias = keystoreProperties["keyAlias"] as String? + keyPassword = keystoreProperties["keyPassword"] as String? + storeFile = (keystoreProperties["storeFile"] as String?)?.let { file(it) } + storePassword = keystoreProperties["storePassword"] as String? + } + } + } + + buildTypes { + release { + // Use the real upload key when key.properties is present, else debug. + signingConfig = if (keystorePropertiesFile.exists()) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/native/ui/android/app/src/debug/AndroidManifest.xml b/native/ui/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..8ffe024 --- /dev/null +++ b/native/ui/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/native/ui/android/app/src/main/AndroidManifest.xml b/native/ui/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b7ea6b4 --- /dev/null +++ b/native/ui/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/native/ui/android/app/src/main/kotlin/space/netdesk/BootReceiver.kt b/native/ui/android/app/src/main/kotlin/space/netdesk/BootReceiver.kt new file mode 100644 index 0000000..4f5d5bb --- /dev/null +++ b/native/ui/android/app/src/main/kotlin/space/netdesk/BootReceiver.kt @@ -0,0 +1,42 @@ +package space.netdesk + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +/** + * Unattended auto-start: when the device finishes booting, bring the NetDesk host + * back up (if the user enabled unattended access) so the device is reachable + * without anyone opening the app — the Android equivalent of the Windows service. + * + * The receiver only acts when unattended mode is on (stored in SharedPreferences + * by the Flutter settings screen); otherwise it does nothing. + * + * Developed by Akronlogic. + */ +class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + val action = intent?.action ?: return + if (action != Intent.ACTION_BOOT_COMPLETED && + action != "android.intent.action.QUICKBOOT_POWERON" + ) { + return + } + val prefs = context.getSharedPreferences("netdesk", Context.MODE_PRIVATE) + if (!prefs.getBoolean("unattended", false)) return + + val svc = Intent(context, HostService::class.java).apply { + this.action = HostService.ACTION_START + } + try { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + context.startForegroundService(svc) + } else { + context.startService(svc) + } + } catch (_: Throwable) { + // Some OEMs block background foreground-service starts at boot; the + // user can still start the host from the app. + } + } +} diff --git a/native/ui/android/app/src/main/kotlin/space/netdesk/HostService.kt b/native/ui/android/app/src/main/kotlin/space/netdesk/HostService.kt new file mode 100644 index 0000000..e514d68 --- /dev/null +++ b/native/ui/android/app/src/main/kotlin/space/netdesk/HostService.kt @@ -0,0 +1,311 @@ +package space.netdesk + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.hardware.display.DisplayManager +import android.hardware.display.VirtualDisplay +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaFormat +import android.media.projection.MediaProjection +import android.media.projection.MediaProjectionManager +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import android.util.DisplayMetrics +import android.view.Surface +import android.view.WindowManager +import kotlin.concurrent.thread + +/** + * NetDesk host foreground service: mirrors the screen through MediaProjection -> + * VirtualDisplay -> a MediaCodec H.264 (AVC) encoder and produces Annex-B frames. + * + * Right now the frames are counted (so on-device / BlueStacks testing confirms + * capture works) — the next step hands each Annex-B access unit to the host side + * of the NetDesk protocol (register by Partner ID, challenge/auth, E2E) so a + * remote viewer can connect, exactly like netdesk_host.py on Windows. + * + * Developed by Akronlogic. + */ +class HostService : Service() { + + companion object { + const val CHANNEL_ID = "netdesk_host" + const val NOTIF_ID = 1001 + const val ACTION_START = "space.netdesk.HOST_START" + const val ACTION_STOP = "space.netdesk.HOST_STOP" + private const val MIME = "video/avc" + + /** Screen-capture grant handed over by MainActivity.onActivityResult. */ + @Volatile + var projectionResultCode: Int = 0 + + @Volatile + var projectionData: Intent? = null + + @Volatile + var running: Boolean = false + + /** Encoded H.264 access units produced so far (capture liveness check). */ + @Volatile + var frameCount: Long = 0 + + /** Capture resolution (advertised to the viewer via host_start). */ + @Volatile + var captureW: Int = 0 + @Volatile + var captureH: Int = 0 + + /** Real display resolution — viewer coords (in capture space) scale to it. */ + @Volatile + var realW: Int = 0 + @Volatile + var realH: Int = 0 + + /** + * Sink for each outbound access unit: payload[0] = keyframe flag (1/0), + * payload[1..] = Annex-B H.264 (SPS/PPS is prepended to every keyframe so + * the frame is self-contained for mid-stream joins + decoder recovery). + * MainActivity sets this to forward over the `space.netdesk/frames` + * EventChannel into Dart -> host_push_frame. + */ + @Volatile + var frameSink: ((ByteArray) -> Unit)? = null + + /** Live instance, so MainActivity can ask for an on-demand keyframe. */ + @Volatile + var instance: HostService? = null + } + + private var projection: MediaProjection? = null + private var virtualDisplay: VirtualDisplay? = null + private var encoder: MediaCodec? = null + private var inputSurface: Surface? = null + @Volatile + private var draining: Boolean = false + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_STOP) { + stopHost() + return START_NOT_STICKY + } + goForeground() + running = true + instance = this + startCapture() + return START_STICKY + } + + private fun goForeground() { + val notif = buildNotification() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NOTIF_ID, + notif, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION + ) + } else { + startForeground(NOTIF_ID, notif) + } + } + + private fun startCapture() { + val data = projectionData ?: return + val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager + val proj = mpm.getMediaProjection(projectionResultCode, data) ?: return + projection = proj + projectionData = null + + // The system requires a registered callback before createVirtualDisplay. + proj.registerCallback(object : MediaProjection.Callback() { + override fun onStop() { + stopHost() + } + }, null) + + val metrics = DisplayMetrics() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val wm = getSystemService(Context.WINDOW_SERVICE) as WindowManager + val b = wm.currentWindowMetrics.bounds + metrics.widthPixels = b.width() + metrics.heightPixels = b.height() + metrics.densityDpi = resources.configuration.densityDpi + } else { + @Suppress("DEPRECATION") + (getSystemService(Context.WINDOW_SERVICE) as WindowManager) + .defaultDisplay.getRealMetrics(metrics) + } + + // Cap the long edge at 1600 px for bandwidth; keep it even. + var w = metrics.widthPixels + var h = metrics.heightPixels + realW = w + realH = h + val longEdge = maxOf(w, h) + if (longEdge > 1600) { + val s = 1600.0 / longEdge + w = (w * s).toInt() + h = (h * s).toInt() + } + w = w and 1.inv() + h = h and 1.inv() + captureW = w + captureH = h + val dpi = if (metrics.densityDpi > 0) metrics.densityDpi else 320 + + val format = MediaFormat.createVideoFormat(MIME, w, h).apply { + setInteger( + MediaFormat.KEY_COLOR_FORMAT, + MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface + ) + setInteger(MediaFormat.KEY_BIT_RATE, 6_000_000) + setInteger(MediaFormat.KEY_FRAME_RATE, 30) + setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2) + } + val enc = MediaCodec.createEncoderByType(MIME) + enc.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + inputSurface = enc.createInputSurface() + enc.start() + encoder = enc + + virtualDisplay = proj.createVirtualDisplay( + "netdesk-host", + w, h, dpi, + DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, + inputSurface, null, null + ) + + draining = true + thread(name = "netdesk-encoder", isDaemon = true) { drainLoop() } + } + + /** Cached SPS/PPS (BUFFER_FLAG_CODEC_CONFIG), prepended to each keyframe. */ + private var csd: ByteArray? = null + + /** Ask the encoder for an immediate IDR (viewer keyframe request / recovery). */ + fun requestKeyframe() { + try { + encoder?.setParameters(Bundle().apply { + putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0) + }) + } catch (_: Throwable) { + } + } + + private fun drainLoop() { + val enc = encoder ?: return + val info = MediaCodec.BufferInfo() + while (draining) { + val idx = try { + enc.dequeueOutputBuffer(info, 20_000) + } catch (_: IllegalStateException) { + break + } + if (idx >= 0) { + val buf = enc.getOutputBuffer(idx) + if (buf != null && info.size > 0) { + buf.position(info.offset) + buf.limit(info.offset + info.size) + val bytes = ByteArray(info.size) + buf.get(bytes) + val isConfig = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0 + if (isConfig) { + // SPS/PPS — cache it, don't emit on its own. + csd = bytes + } else { + frameCount++ + val isKey = info.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME != 0 + // Self-contained keyframe = SPS/PPS || IDR (like libx264). + val au = if (isKey && csd != null) csd!! + bytes else bytes + val sink = frameSink + if (sink != null) { + val payload = ByteArray(au.size + 1) + payload[0] = if (isKey) 1 else 0 + System.arraycopy(au, 0, payload, 1, au.size) + try { + sink(payload) + } catch (_: Throwable) { + } + } + } + } + enc.releaseOutputBuffer(idx, false) + } + } + } + + private fun stopHost() { + running = false + draining = false + if (instance === this) instance = null + csd = null + captureW = 0 + captureH = 0 + try { + virtualDisplay?.release() + } catch (_: Throwable) { + } + virtualDisplay = null + try { + encoder?.stop() + encoder?.release() + } catch (_: Throwable) { + } + encoder = null + try { + inputSurface?.release() + } catch (_: Throwable) { + } + inputSurface = null + try { + projection?.stop() + } catch (_: Throwable) { + } + projection = null + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + @Suppress("DEPRECATION") + stopForeground(true) + } + stopSelf() + } + + override fun onDestroy() { + stopHost() + super.onDestroy() + } + + private fun buildNotification(): Notification { + val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val ch = NotificationChannel( + CHANNEL_ID, + "NetDesk Host", + NotificationManager.IMPORTANCE_LOW + ) + ch.description = "Shown while this device can be controlled remotely." + nm.createNotificationChannel(ch) + } + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(this, CHANNEL_ID) + } else { + @Suppress("DEPRECATION") + Notification.Builder(this) + } + return builder + .setContentTitle("NetDesk host is active") + .setContentText("This device can be controlled remotely") + .setSmallIcon(android.R.drawable.ic_menu_view) + .setOngoing(true) + .build() + } +} diff --git a/native/ui/android/app/src/main/kotlin/space/netdesk/MainActivity.kt b/native/ui/android/app/src/main/kotlin/space/netdesk/MainActivity.kt new file mode 100644 index 0000000..b89e916 --- /dev/null +++ b/native/ui/android/app/src/main/kotlin/space/netdesk/MainActivity.kt @@ -0,0 +1,212 @@ +package space.netdesk + +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.media.projection.MediaProjectionManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.text.TextUtils +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodChannel + +/** + * Hosts the Flutter UI and bridges the Android "host" role to Dart over the + * `space.netdesk/host` MethodChannel: request the screen-capture permission, + * start/stop the host service, toggle the hidden launcher icon + unattended + * mode, and check / open the accessibility settings. + * + * Developed by Akronlogic. + */ +class MainActivity : FlutterActivity() { + private val channelName = "space.netdesk/host" + private val framesChannel = "space.netdesk/frames" + private val reqProjection = 7001 + private var pendingResult: MethodChannel.Result? = null + private val mainHandler = Handler(Looper.getMainLooper()) + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + // Encoded H.264 access units flow HostService -> here -> Dart, which feeds + // them to the Rust host via host_push_frame. payload[0]=keyframe flag. + EventChannel(flutterEngine.dartExecutor.binaryMessenger, framesChannel) + .setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + HostService.frameSink = { payload -> + mainHandler.post { events?.success(payload) } + } + } + + override fun onCancel(arguments: Any?) { + HostService.frameSink = null + } + }) + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName) + .setMethodCallHandler { call, result -> + when (call.method) { + "startHost" -> requestProjection(result) + "stopHost" -> { + stopHost(); result.success(true) + } + "isHostRunning" -> result.success(HostService.running) + "lastFrameCount" -> result.success(HostService.frameCount) + "hostScreenSize" -> result.success( + mapOf("w" to HostService.captureW, "h" to HostService.captureH) + ) + "requestKeyframe" -> { + HostService.instance?.requestKeyframe(); result.success(true) + } + "injectTap" -> { + NetDeskAccessibilityService.instance?.tap( + sx(call.argument("x") ?: 0.0), + sy(call.argument("y") ?: 0.0) + ) + result.success(true) + } + "injectSwipe" -> { + NetDeskAccessibilityService.instance?.swipe( + sx(call.argument("x1") ?: 0.0), + sy(call.argument("y1") ?: 0.0), + sx(call.argument("x2") ?: 0.0), + sy(call.argument("y2") ?: 0.0), + (call.argument("dur") ?: 60).toLong() + ) + result.success(true) + } + "injectKey" -> { + NetDeskAccessibilityService.instance?.key( + call.argument("code") ?: "", + call.argument("down") ?: false + ) + result.success(true) + } + "getHostId" -> result.success( + getSharedPreferences("netdesk", Context.MODE_PRIVATE) + .getString("hostId", "") + ) + "getHostPassword" -> result.success( + getSharedPreferences("netdesk", Context.MODE_PRIVATE) + .getString("hostPassword", "") + ) + "setHostCreds" -> { + getSharedPreferences("netdesk", Context.MODE_PRIVATE).edit() + .putString("hostId", call.argument("id") ?: "") + .putString("hostPassword", call.argument("password") ?: "") + .apply() + result.success(true) + } + "setHideIcon" -> { + setHideIcon(call.argument("hide") ?: false) + result.success(true) + } + "isIconHidden" -> result.success(isIconHidden()) + "hasAccessibility" -> result.success(isAccessibilityEnabled()) + "openAccessibilitySettings" -> { + startActivity( + Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + result.success(true) + } + "setUnattended" -> { + getSharedPreferences("netdesk", Context.MODE_PRIVATE).edit() + .putBoolean("unattended", call.argument("on") ?: false) + .apply() + result.success(true) + } + "isUnattended" -> result.success( + getSharedPreferences("netdesk", Context.MODE_PRIVATE) + .getBoolean("unattended", false) + ) + else -> result.notImplemented() + } + } + } + + private fun requestProjection(result: MethodChannel.Result) { + pendingResult = result + val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager + startActivityForResult(mpm.createScreenCaptureIntent(), reqProjection) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode != reqProjection) return + if (resultCode == Activity.RESULT_OK && data != null) { + // Hand the capture token to the foreground service, which obtains the + // MediaProjection after it has gone foreground (required on API 34+). + HostService.projectionResultCode = resultCode + HostService.projectionData = data + val svc = Intent(this, HostService::class.java).apply { + action = HostService.ACTION_START + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(svc) + } else { + startService(svc) + } + pendingResult?.success(true) + } else { + pendingResult?.success(false) + } + pendingResult = null + } + + private fun stopHost() { + val svc = Intent(this, HostService::class.java).apply { + action = HostService.ACTION_STOP + } + startService(svc) + } + + /** Scale a viewer x-coord (capture space) to the real display for injection. */ + private fun sx(x: Double): Float { + val cw = HostService.captureW + val rw = HostService.realW + return if (cw > 0 && rw > 0) (x * rw / cw).toFloat() else x.toFloat() + } + + private fun sy(y: Double): Float { + val ch = HostService.captureH + val rh = HostService.realH + return if (ch > 0 && rh > 0) (y * rh / ch).toFloat() else y.toFloat() + } + + private fun setHideIcon(hide: Boolean) { + val alias = ComponentName(this, "space.netdesk.LauncherAlias") + val state = if (hide) { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } + packageManager.setComponentEnabledSetting(alias, state, PackageManager.DONT_KILL_APP) + } + + private fun isIconHidden(): Boolean { + val alias = ComponentName(this, "space.netdesk.LauncherAlias") + return packageManager.getComponentEnabledSetting(alias) == + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + + private fun isAccessibilityEnabled(): Boolean { + val expected = + ComponentName(this, NetDeskAccessibilityService::class.java).flattenToString() + val enabled = Settings.Secure.getString( + contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES + ) ?: return false + val splitter = TextUtils.SimpleStringSplitter(':') + splitter.setString(enabled) + while (splitter.hasNext()) { + if (splitter.next().equals(expected, ignoreCase = true)) return true + } + return false + } +} diff --git a/native/ui/android/app/src/main/kotlin/space/netdesk/NetDeskAccessibilityService.kt b/native/ui/android/app/src/main/kotlin/space/netdesk/NetDeskAccessibilityService.kt new file mode 100644 index 0000000..77dade1 --- /dev/null +++ b/native/ui/android/app/src/main/kotlin/space/netdesk/NetDeskAccessibilityService.kt @@ -0,0 +1,84 @@ +package space.netdesk + +import android.accessibilityservice.AccessibilityService +import android.accessibilityservice.GestureDescription +import android.graphics.Path +import android.view.accessibility.AccessibilityEvent + +/** + * Remote-input injection for the Android host role. + * + * A normal app can't inject touch events, so NetDesk (like RustDesk) uses an + * AccessibilityService: once the user enables it, [dispatchGesture] can tap, + * swipe and drag anywhere on screen. This is the skeleton; the host session + * calls [tap] / [swipe] with the coordinates a remote viewer sends. + * + * (Full keyboard/text injection and the on-lock-screen limitation are handled in + * later steps — injected input can't reach the secure lock screen, same as + * RustDesk.) + * + * Developed by Akronlogic. + */ +class NetDeskAccessibilityService : AccessibilityService() { + + companion object { + @Volatile + var instance: NetDeskAccessibilityService? = null + } + + override fun onServiceConnected() { + super.onServiceConnected() + instance = this + } + + override fun onAccessibilityEvent(event: AccessibilityEvent?) { + // The host role drives the device OUTward (injects input); it does not + // need to react to UI events, so this is intentionally empty. + } + + override fun onInterrupt() {} + + override fun onDestroy() { + if (instance === this) instance = null + super.onDestroy() + } + + /** Tap (x, y) in screen pixels. */ + fun tap(x: Float, y: Float) { + val path = Path().apply { moveTo(x, y) } + val stroke = GestureDescription.StrokeDescription(path, 0, 1) + dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null) + } + + /** Swipe/drag from (x1, y1) to (x2, y2) over [durationMs]. */ + fun swipe(x1: Float, y1: Float, x2: Float, y2: Float, durationMs: Long) { + val path = Path().apply { + moveTo(x1, y1) + lineTo(x2, y2) + } + val stroke = + GestureDescription.StrokeDescription(path, 0, durationMs.coerceAtLeast(1)) + dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null) + } + + /** + * Map a viewer key (DOM-style code) to an Android navigation action on key + * DOWN. An AccessibilityService can't inject arbitrary key/text events (that + * needs an IME, a later step), but the global navigation actions cover the + * phone's hardware keys — the common remote-control needs. + */ + fun key(code: String, down: Boolean) { + if (!down) return + val action = when (code) { + "Escape", "BrowserBack", "GoBack" -> GLOBAL_ACTION_BACK + "Home", "MetaLeft", "MetaRight", "BrowserHome" -> GLOBAL_ACTION_HOME + "F1", "ContextMenu" -> GLOBAL_ACTION_RECENTS + "AudioVolumeUp", "AudioVolumeDown" -> return // reserved for later + else -> return + } + try { + performGlobalAction(action) + } catch (_: Throwable) { + } + } +} diff --git a/native/ui/android/app/src/main/res/drawable-v21/launch_background.xml b/native/ui/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..1cb7aa2 --- /dev/null +++ b/native/ui/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/native/ui/android/app/src/main/res/drawable/launch_background.xml b/native/ui/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..8403758 --- /dev/null +++ b/native/ui/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/native/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/native/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/native/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/native/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/native/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/native/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/native/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/native/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/native/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/native/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/native/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/native/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/native/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/native/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/native/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/native/ui/android/app/src/main/res/values-night/styles.xml b/native/ui/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..360a160 --- /dev/null +++ b/native/ui/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/native/ui/android/app/src/main/res/values/strings.xml b/native/ui/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..4736450 --- /dev/null +++ b/native/ui/android/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + NetDesk + NetDesk uses this to let a remote viewer tap, swipe and type on this device while you are connected. Developed by Akronlogic. + diff --git a/native/ui/android/app/src/main/res/values/styles.xml b/native/ui/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..5fac679 --- /dev/null +++ b/native/ui/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/native/ui/android/app/src/main/res/xml/accessibility_service_config.xml b/native/ui/android/app/src/main/res/xml/accessibility_service_config.xml new file mode 100644 index 0000000..8310760 --- /dev/null +++ b/native/ui/android/app/src/main/res/xml/accessibility_service_config.xml @@ -0,0 +1,8 @@ + + diff --git a/native/ui/android/app/src/profile/AndroidManifest.xml b/native/ui/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..8ffe024 --- /dev/null +++ b/native/ui/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/native/ui/android/build.gradle.kts b/native/ui/android/build.gradle.kts new file mode 100644 index 0000000..1f88145 --- /dev/null +++ b/native/ui/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/native/ui/android/gradle.properties b/native/ui/android/gradle.properties new file mode 100644 index 0000000..118d7d0 --- /dev/null +++ b/native/ui/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/native/ui/android/gradle/wrapper/gradle-wrapper.properties b/native/ui/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..bcef617 --- /dev/null +++ b/native/ui/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip diff --git a/native/ui/android/key.properties.example b/native/ui/android/key.properties.example new file mode 100644 index 0000000..71802c3 --- /dev/null +++ b/native/ui/android/key.properties.example @@ -0,0 +1,14 @@ +# NetDesk release signing - EXAMPLE / TEMPLATE (safe to commit). +# +# Do NOT put real secrets here. Either: +# (a) let the build script create the keystore + the real key.properties: +# $env:NETDESK_KEYSTORE_PASSWORD = '' +# powershell -ExecutionPolicy Bypass -File packaging\build-android.ps1 -GenerateKeystore +# (b) or copy this file to key.properties (git-ignored) and fill it in. +# +# The value below is a PLACEHOLDER, not a real password. Replace __SET_ME__. +storePassword=__SET_ME__ +keyPassword=__SET_ME__ +keyAlias=netdesk +# Path is resolved relative to the app module dir (ui/android/app). +storeFile=netdesk-release.keystore diff --git a/native/ui/android/settings.gradle.kts b/native/ui/android/settings.gradle.kts new file mode 100644 index 0000000..c0298e3 --- /dev/null +++ b/native/ui/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.1.0" apply false + id("org.jetbrains.kotlin.android") version "2.4.0" apply false +} + +include(":app") diff --git a/native/ui/assets/icon-512.png b/native/ui/assets/icon-512.png new file mode 100644 index 0000000..0b41e19 Binary files /dev/null and b/native/ui/assets/icon-512.png differ diff --git a/native/ui/assets/icon.png b/native/ui/assets/icon.png new file mode 100644 index 0000000..4c3313e Binary files /dev/null and b/native/ui/assets/icon.png differ diff --git a/native/ui/lib/connect_screen.dart b/native/ui/lib/connect_screen.dart new file mode 100644 index 0000000..4df3900 --- /dev/null +++ b/native/ui/lib/connect_screen.dart @@ -0,0 +1,613 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'host_screen.dart'; +import 'rust/api.dart' as nd; +import 'session_screen.dart'; +import 'theme.dart'; + +/// Default NetDesk rendezvous / signaling endpoint (the Frankfurt VPS). +/// A LAN host is reachable by overriding this with `ws://:8901/ws` +/// from the Advanced panel. +const String kDefaultServer = 'wss://netdesk.space/ws'; + +/// The landing / connect screen. +/// +/// Mirrors the mental model of TeamViewer / RustDesk: the brand mark, a big +/// Partner ID field, a session password, and a primary Connect action. Connect +/// calls into the Rust core via flutter_rust_bridge ([nd.connect]) and hands +/// the returned session handle to the [SessionScreen]. +class ConnectScreen extends StatefulWidget { + const ConnectScreen({super.key, this.initError}); + + /// Non-null when the native engine failed to load (shown as a banner). + final String? initError; + + @override + State createState() => _ConnectScreenState(); +} + +class _ConnectScreenState extends State { + final TextEditingController _idController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _serverController = + TextEditingController(text: kDefaultServer); + + bool _obscurePassword = true; + bool _showAdvanced = false; + bool _connecting = false; + + @override + void dispose() { + _idController.dispose(); + _passwordController.dispose(); + _serverController.dispose(); + super.dispose(); + } + + String get _digits => _idController.text.replaceAll(RegExp(r'\s'), ''); + + bool get _canConnect => + !_connecting && _digits.length >= 6 && widget.initError == null; + + Future _connect() async { + if (!_canConnect) return; + FocusScope.of(context).unfocus(); + + final hostId = _digits; + final server = _serverController.text.trim().isEmpty + ? kDefaultServer + : _serverController.text.trim(); + + setState(() => _connecting = true); + try { + final handle = await nd.connect( + wsUrl: server, + hostId: hostId, + password: _passwordController.text, + totp: '', + ); + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SessionScreen( + partnerId: _idController.text.trim(), + handle: handle, + ), + ), + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + backgroundColor: NetDeskColors.surface, + content: Text('Could not start session: $e'), + ), + ); + } + } finally { + if (mounted) setState(() => _connecting = false); + } + } + + /// The form half of the screen: error banner, connect card, device card and + /// the host-mode button. Shared by the narrow and the wide (two-pane) layouts. + List _formChildren(BuildContext context) { + return [ + if (widget.initError != null) ...[ + _EngineErrorBanner(message: widget.initError!), + const SizedBox(height: NetDeskSpacing.md), + ], + _ConnectCard( + idController: _idController, + passwordController: _passwordController, + serverController: _serverController, + obscurePassword: _obscurePassword, + canConnect: _canConnect, + connecting: _connecting, + showAdvanced: _showAdvanced, + onToggleObscure: () => + setState(() => _obscurePassword = !_obscurePassword), + onToggleAdvanced: () => setState(() => _showAdvanced = !_showAdvanced), + onChanged: () => setState(() {}), + onConnect: _connect, + ), + const SizedBox(height: NetDeskSpacing.md), + const _ThisDeviceCard(), + const SizedBox(height: NetDeskSpacing.md), + OutlinedButton.icon( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const HostScreen()), + ), + icon: const Icon(Icons.phonelink_setup, size: 18), + label: const Text('Allow remote control of this device'), + style: OutlinedButton.styleFrom( + foregroundColor: NetDeskColors.textSecondary, + side: const BorderSide(color: NetDeskColors.border), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(NetDeskRadius.md), + ), + ), + ), + ]; + } + + @override + Widget build(BuildContext context) { + // Responsive: the screen must FIT the window on phones, tablets (either + // orientation), split-screen and desktop/DeX windows. + // * wide windows -> brand beside the form (two panes, half the height) + // * short windows -> compact brand + tighter spacing + // * scrolling only remains as a last-resort safety for tiny windows. + return Scaffold( + body: SafeArea( + child: LayoutBuilder( + builder: (context, box) { + final wide = box.maxWidth >= 720; + final compact = box.maxHeight < 700; + + if (wide) { + return Row( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + _Wordmark(), + SizedBox(height: NetDeskSpacing.lg), + _FooterNote(), + ], + ), + ), + Expanded( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(NetDeskSpacing.lg), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _formChildren(context), + ), + ), + ), + ), + ), + ], + ); + } + + return Center( + child: SingleChildScrollView( + padding: EdgeInsets.all( + compact ? NetDeskSpacing.md : NetDeskSpacing.lg), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Wordmark(compact: compact), + SizedBox( + height: compact + ? NetDeskSpacing.md + : NetDeskSpacing.xl), + ..._formChildren(context), + SizedBox( + height: compact + ? NetDeskSpacing.md + : NetDeskSpacing.lg), + const _FooterNote(), + ], + ), + ), + ), + ); + }, + ), + ), + ); + } +} + +/// Brand mark: the NetDesk logo, wordmark and the Akronlogic byline. +/// `compact` shrinks it so short windows (landscape phones, small desktop +/// windows) still fit the whole screen without scrolling. +class _Wordmark extends StatelessWidget { + const _Wordmark({this.compact = false}); + + final bool compact; + + @override + Widget build(BuildContext context) { + final logo = compact ? 54.0 : 76.0; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: logo, + height: logo, + padding: EdgeInsets.all(compact ? 7 : 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(NetDeskRadius.lg), + color: NetDeskColors.surface, + border: Border.all(color: NetDeskColors.border), + boxShadow: const [ + BoxShadow( + color: NetDeskColors.accentGlow, + blurRadius: 36, + spreadRadius: 1, + ), + ], + ), + child: Image.asset( + 'assets/icon.png', + fit: BoxFit.contain, + filterQuality: FilterQuality.medium, + ), + ), + SizedBox(height: compact ? NetDeskSpacing.sm : NetDeskSpacing.md), + Text( + 'NetDesk', + style: TextStyle( + fontSize: compact ? 28 : 40, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + color: NetDeskColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + 'by Akronlogic', + style: TextStyle( + fontSize: compact ? 12 : 13, + fontWeight: FontWeight.w600, + letterSpacing: 2.4, + color: NetDeskColors.textSecondary.withValues(alpha: 0.9), + ), + ), + ], + ); + } +} + +/// The main card: Partner ID + password + Connect (+ optional Advanced server). +class _ConnectCard extends StatelessWidget { + const _ConnectCard({ + required this.idController, + required this.passwordController, + required this.serverController, + required this.obscurePassword, + required this.canConnect, + required this.connecting, + required this.showAdvanced, + required this.onToggleObscure, + required this.onToggleAdvanced, + required this.onChanged, + required this.onConnect, + }); + + final TextEditingController idController; + final TextEditingController passwordController; + final TextEditingController serverController; + final bool obscurePassword; + final bool canConnect; + final bool connecting; + final bool showAdvanced; + final VoidCallback onToggleObscure; + final VoidCallback onToggleAdvanced; + final VoidCallback onChanged; + final VoidCallback onConnect; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(NetDeskSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _FieldLabel('Partner ID'), + const SizedBox(height: NetDeskSpacing.xs), + TextField( + controller: idController, + onChanged: (_) => onChanged(), + enabled: !connecting, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + _PartnerIdFormatter(), + ], + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + letterSpacing: 3, + color: NetDeskColors.textPrimary, + ), + decoration: const InputDecoration( + hintText: '000 000 000', + prefixIcon: + Icon(Icons.badge_outlined, color: NetDeskColors.textMuted), + ), + ), + const SizedBox(height: NetDeskSpacing.md), + const _FieldLabel('Password'), + const SizedBox(height: NetDeskSpacing.xs), + TextField( + controller: passwordController, + onChanged: (_) => onChanged(), + enabled: !connecting, + obscureText: obscurePassword, + onSubmitted: (_) => canConnect ? onConnect() : null, + decoration: InputDecoration( + hintText: 'Session password', + prefixIcon: const Icon(Icons.lock_outline, + color: NetDeskColors.textMuted), + suffixIcon: IconButton( + tooltip: obscurePassword ? 'Show' : 'Hide', + onPressed: onToggleObscure, + icon: Icon( + obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + ), + ), + ), + AnimatedCrossFade( + duration: const Duration(milliseconds: 180), + crossFadeState: showAdvanced + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: const SizedBox(width: double.infinity), + secondChild: Padding( + padding: const EdgeInsets.only(top: NetDeskSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _FieldLabel('Server'), + const SizedBox(height: NetDeskSpacing.xs), + TextField( + controller: serverController, + enabled: !connecting, + keyboardType: TextInputType.url, + autocorrect: false, + style: const TextStyle( + fontSize: 14, + color: NetDeskColors.textPrimary, + ), + decoration: const InputDecoration( + hintText: 'wss://netdesk.space/ws', + prefixIcon: Icon(Icons.dns_outlined, + color: NetDeskColors.textMuted), + ), + ), + ], + ), + ), + ), + const SizedBox(height: NetDeskSpacing.lg), + FilledButton.icon( + onPressed: canConnect ? onConnect : null, + icon: connecting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Color(0xFF04210F), + ), + ) + : const Icon(Icons.bolt_rounded, size: 20), + label: Text(connecting ? 'Connecting…' : 'Connect'), + ), + const SizedBox(height: NetDeskSpacing.xs), + Align( + alignment: Alignment.center, + child: TextButton.icon( + onPressed: onToggleAdvanced, + style: TextButton.styleFrom( + foregroundColor: NetDeskColors.textMuted, + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + icon: Icon( + showAdvanced + ? Icons.keyboard_arrow_up_rounded + : Icons.tune_rounded, + size: 16, + ), + label: Text(showAdvanced ? 'Hide advanced' : 'Advanced'), + ), + ), + ], + ), + ), + ); + } +} + +/// Small card standing in for *this* device's own shareable identity. +class _ThisDeviceCard extends StatelessWidget { + const _ThisDeviceCard(); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: NetDeskSpacing.lg, + vertical: NetDeskSpacing.md, + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: NetDeskColors.accentGlow, + borderRadius: BorderRadius.circular(NetDeskRadius.sm), + ), + child: const Icon(Icons.smartphone_rounded, + color: NetDeskColors.accent, size: 20), + ), + const SizedBox(width: NetDeskSpacing.md), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'This device', + style: TextStyle( + fontSize: 12, + letterSpacing: 0.4, + color: NetDeskColors.textMuted, + ), + ), + SizedBox(height: 2), + Text( + 'Incoming control — tap “Allow” below', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: NetDeskColors.textSecondary, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: NetDeskColors.surfaceAlt, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: NetDeskColors.border), + ), + child: const Text( + 'BETA', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1, + color: NetDeskColors.textMuted, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _EngineErrorBanner extends StatelessWidget { + const _EngineErrorBanner({required this.message}); + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(NetDeskSpacing.md), + decoration: BoxDecoration( + color: NetDeskColors.danger.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(NetDeskRadius.md), + border: Border.all(color: NetDeskColors.danger.withValues(alpha: 0.5)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.error_outline_rounded, + color: NetDeskColors.danger, size: 20), + const SizedBox(width: NetDeskSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Engine not loaded', + style: TextStyle( + fontWeight: FontWeight.w700, + color: NetDeskColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + message, + style: const TextStyle( + fontSize: 12, + color: NetDeskColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _FieldLabel extends StatelessWidget { + const _FieldLabel(this.text); + final String text; + + @override + Widget build(BuildContext context) { + return Text( + text.toUpperCase(), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + color: NetDeskColors.textSecondary, + ), + ); + } +} + +class _FooterNote extends StatelessWidget { + const _FooterNote(); + + @override + Widget build(BuildContext context) { + return const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.shield_outlined, size: 14, color: NetDeskColors.textMuted), + SizedBox(width: 6), + Text( + 'End-to-end encrypted · v0.1.0', + style: TextStyle(fontSize: 12, color: NetDeskColors.textMuted), + ), + ], + ); + } +} + +/// Formats up to 12 digits as "123 456 789" while typing. +class _PartnerIdFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + var digits = newValue.text.replaceAll(RegExp(r'\s'), ''); + if (digits.length > 12) digits = digits.substring(0, 12); + final buffer = StringBuffer(); + for (var i = 0; i < digits.length; i++) { + if (i != 0 && i % 3 == 0) buffer.write(' '); + buffer.write(digits[i]); + } + final formatted = buffer.toString(); + return TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); + } +} diff --git a/native/ui/lib/host_screen.dart b/native/ui/lib/host_screen.dart new file mode 100644 index 0000000..e0cf53b --- /dev/null +++ b/native/ui/lib/host_screen.dart @@ -0,0 +1,539 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'rust/api.dart' as nd; +import 'theme.dart'; + +/// "Allow remote control" — the Android HOST role. +/// +/// Turns this device into a NetDesk host (like RustDesk): once the user grants +/// screen capture and enables the accessibility service, a remote viewer can see +/// and control it. Talks to the native side over the `space.netdesk/host` +/// MethodChannel (see MainActivity.kt / HostService.kt). +/// +/// Preview: screen capture + the controls are live; the remote-connection wiring +/// (register by Partner ID, send the encrypted H.264 to a viewer) lands next. +class HostScreen extends StatefulWidget { + const HostScreen({super.key}); + + @override + State createState() => _HostScreenState(); +} + +class _HostScreenState extends State with WidgetsBindingObserver { + static const _host = MethodChannel('space.netdesk/host'); + static const _frameChannel = EventChannel('space.netdesk/frames'); + static const _wsUrl = 'wss://netdesk.space/ws'; + + bool _running = false; + bool _accessibility = false; + bool _hideIcon = false; + bool _unattended = false; + int _frames = 0; + bool _busy = false; + + // Rust host session. + BigInt? _handle; + String _id = ''; + String _password = ''; + bool _viewerConnected = false; + StreamSubscription? _frameSub; + StreamSubscription? _eventSub; + + // Viewer-mouse -> touch gesture interpretation (coords are in capture space; + // MainActivity scales to the real display before injecting). + int _btns = 0; + double _downX = 0, _downY = 0, _lastX = 0, _lastY = 0; + int _downMs = 0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _refresh(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _frameSub?.cancel(); + _eventSub?.cancel(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) _refresh(); + } + + Future _call(String method, [Map? args]) async { + try { + return await _host.invokeMethod(method, args); + } catch (_) { + return null; + } + } + + Future _refresh() async { + final running = await _call('isHostRunning') ?? false; + final acc = await _call('hasAccessibility') ?? false; + final hide = await _call('isIconHidden') ?? false; + final un = await _call('isUnattended') ?? false; + final frames = await _call('lastFrameCount') ?? 0; + if (mounted) { + setState(() { + _running = running; + _accessibility = acc; + _hideIcon = hide; + _unattended = un; + _frames = frames; + }); + } + } + + Future _toggleHost() async { + setState(() => _busy = true); + if (_running) { + await _stopHostFull(); + } else { + await _startHostFull(); + } + if (mounted) setState(() => _busy = false); + } + + /// Load the persisted host id + unattended password, generating them once. + Future _loadCreds() async { + var id = await _call('getHostId') ?? ''; + var pw = await _call('getHostPassword') ?? ''; + if (id.isEmpty || pw.isEmpty) { + id = nd.hostGenId(); + pw = nd.hostGenPassword(); + await _call('setHostCreds', {'id': id, 'password': pw}); + } + if (mounted) { + setState(() { + _id = id; + _password = pw; + }); + } + } + + Future _startHostFull() async { + await _loadCreds(); + // 1) screen-capture consent -> HostService starts encoding. + final ok = await _call('startHost') ?? false; + if (!ok) { + await _refresh(); + return; + } + // 2) wait for the encoder to report a capture size. + int w = 0, h = 0; + for (var i = 0; i < 30; i++) { + await Future.delayed(const Duration(milliseconds: 200)); + final s = await _call>('hostScreenSize'); + if (s != null && (s['w'] as int? ?? 0) > 0) { + w = s['w'] as int; + h = s['h'] as int; + break; + } + } + if (w == 0) { + await _refresh(); + return; + } + // 3) register the Rust host + wire frames out and input in. + final handle = await nd.hostStart( + wsUrl: _wsUrl, + hostId: _id, + name: 'Android', + password: _password, + width: w, + height: h, + ); + _handle = handle; + _frameSub = _frameChannel.receiveBroadcastStream().listen(_onFrame); + _eventSub = nd.hostEvents(handle: handle).listen(_onHostEvent); + await _refresh(); + } + + Future _stopHostFull() async { + await _frameSub?.cancel(); + _frameSub = null; + await _eventSub?.cancel(); + _eventSub = null; + final h = _handle; + _handle = null; + if (h != null) nd.hostStop(handle: h); + await _call('stopHost'); + if (mounted) setState(() => _viewerConnected = false); + await Future.delayed(const Duration(milliseconds: 300)); + await _refresh(); + } + + void _onFrame(dynamic payload) { + final h = _handle; + if (h == null || payload is! Uint8List || payload.isEmpty) return; + final keyframe = payload[0] == 1; + final data = Uint8List.sublistView(payload, 1); + nd.hostPushFrame(handle: h, data: data, keyframe: keyframe); + } + + void _onHostEvent(nd.NdHostEvent ev) { + switch (ev) { + case nd.NdHostEvent_Mouse(:final x, :final y, :final buttons, :final wheel): + _onMouse(x, y, buttons, wheel); + case nd.NdHostEvent_Key(:final code, :final down): + _call('injectKey', {'code': code, 'down': down}); + case nd.NdHostEvent_KeyframeRequested(): + _call('requestKeyframe'); + case nd.NdHostEvent_ViewerConnected(): + if (mounted) setState(() => _viewerConnected = true); + case nd.NdHostEvent_ViewerLeft(): + if (mounted) setState(() => _viewerConnected = false); + default: + break; + } + } + + /// Translate the viewer's absolute pointer into Android gestures: a quick + /// press+release = tap, a moved drag = swipe, wheel = scroll. + void _onMouse(int x, int y, int buttons, double wheel) { + if (wheel != 0) { + final dy = (wheel.clamp(-3.0, 3.0) * 140).round(); + _call('injectSwipe', { + 'x1': x.toDouble(), 'y1': y.toDouble(), + 'x2': x.toDouble(), 'y2': (y + dy).toDouble(), 'dur': 120, + }); + return; + } + final leftNow = (buttons & 1) != 0; + final leftWas = (_btns & 1) != 0; + _btns = buttons; + if (leftNow && !leftWas) { + _downX = x.toDouble(); + _downY = y.toDouble(); + _lastX = _downX; + _lastY = _downY; + _downMs = DateTime.now().millisecondsSinceEpoch; + } else if (leftNow && leftWas) { + _lastX = x.toDouble(); + _lastY = y.toDouble(); + } else if (!leftNow && leftWas) { + _lastX = x.toDouble(); + _lastY = y.toDouble(); + final dist = (_lastX - _downX).abs() + (_lastY - _downY).abs(); + if (dist < 12) { + _call('injectTap', {'x': _downX, 'y': _downY}); + } else { + final dur = (DateTime.now().millisecondsSinceEpoch - _downMs) + .clamp(40, 800); + _call('injectSwipe', { + 'x1': _downX, 'y1': _downY, 'x2': _lastX, 'y2': _lastY, 'dur': dur, + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: NetDeskColors.background, + appBar: AppBar( + backgroundColor: NetDeskColors.background, + elevation: 0, + title: const Text('Allow remote control'), + ), + body: SafeArea( + child: RefreshIndicator( + color: NetDeskColors.accent, + onRefresh: _refresh, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _statusCard(), + if (_running && _id.isNotEmpty) ...[ + const SizedBox(height: 14), + _credsCard(), + ], + const SizedBox(height: 14), + _startStopButton(), + const SizedBox(height: 18), + _tile( + icon: Icons.touch_app_outlined, + title: 'Remote input (accessibility)', + subtitle: _accessibility + ? 'Enabled — a viewer can tap and swipe this device.' + : 'Required so a viewer can tap and swipe. Tap to enable.', + trailing: _accessibility + ? const Icon(Icons.check_circle, color: NetDeskColors.accent) + : const Icon(Icons.chevron_right, + color: NetDeskColors.textMuted), + onTap: _accessibility + ? null + : () async { + await _call('openAccessibilitySettings'); + }, + ), + _switchTile( + icon: Icons.schedule, + title: 'Unattended access', + subtitle: 'Start the host automatically after the device reboots.', + value: _unattended, + onChanged: (v) async { + await _call('setUnattended', {'on': v}); + await _refresh(); + }, + ), + _switchTile( + icon: Icons.visibility_off_outlined, + title: 'Hide app icon', + subtitle: + 'Remove NetDesk from the app drawer (it keeps running). ' + 'Reopen it from your browser at netdesk.space.', + value: _hideIcon, + onChanged: (v) async { + await _call('setHideIcon', {'hide': v}); + await _refresh(); + }, + ), + const SizedBox(height: 16), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + child: Text( + 'Enter the ID and password above in any NetDesk viewer ' + '(Windows, web or another phone) to see and control this ' + 'device. Keep the screen-capture notification on. ' + 'Developed by Akronlogic.', + style: TextStyle( + color: NetDeskColors.textMuted, fontSize: 12, height: 1.4), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _statusCard() { + final on = _running; + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: NetDeskColors.surface, + borderRadius: BorderRadius.circular(NetDeskRadius.lg), + border: Border.all(color: NetDeskColors.border), + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: on ? NetDeskColors.accentGlow : NetDeskColors.surfaceAlt, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + on ? Icons.cast_connected : Icons.cast, + color: on ? NetDeskColors.accent : NetDeskColors.textSecondary, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + on ? 'Host is active' : 'Host is off', + style: const TextStyle( + color: NetDeskColors.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + on + ? 'Capturing the screen · $_frames frames encoded' + : 'This device cannot be controlled yet', + style: const TextStyle( + color: NetDeskColors.textSecondary, fontSize: 13), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _credsCard() { + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: NetDeskColors.surface, + borderRadius: BorderRadius.circular(NetDeskRadius.lg), + border: Border.all( + color: _viewerConnected ? NetDeskColors.accent : NetDeskColors.border, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _viewerConnected ? Icons.lock_person : Icons.vpn_key_outlined, + size: 18, + color: _viewerConnected + ? NetDeskColors.accent + : NetDeskColors.textSecondary, + ), + const SizedBox(width: 8), + Text( + _viewerConnected ? 'A viewer is connected' : 'Ready for a viewer', + style: TextStyle( + color: _viewerConnected + ? NetDeskColors.accent + : NetDeskColors.textSecondary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 14), + _credRow('Your ID', _id), + const SizedBox(height: 10), + _credRow('Password', _password), + ], + ), + ); + } + + Widget _credRow(String label, String value) { + return Row( + children: [ + SizedBox( + width: 86, + child: Text(label, + style: const TextStyle( + color: NetDeskColors.textMuted, fontSize: 13)), + ), + Expanded( + child: SelectableText( + value, + style: const TextStyle( + color: NetDeskColors.textPrimary, + fontSize: 19, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + ), + ), + ), + IconButton( + icon: const Icon(Icons.copy_rounded, + size: 18, color: NetDeskColors.textSecondary), + onPressed: () { + Clipboard.setData(ClipboardData(text: value)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$label copied'), + duration: const Duration(seconds: 1), + ), + ); + }, + ), + ], + ); + } + + Widget _startStopButton() { + return SizedBox( + height: 52, + child: ElevatedButton( + onPressed: _busy ? null : _toggleHost, + style: ElevatedButton.styleFrom( + backgroundColor: + _running ? NetDeskColors.danger : NetDeskColors.accent, + foregroundColor: _running ? Colors.white : const Color(0xFF04210F), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(NetDeskRadius.md), + ), + ), + child: _busy + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2.4), + ) + : Text( + _running ? 'Stop hosting' : 'Start hosting this device', + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w700), + ), + ), + ); + } + + Widget _tile({ + required IconData icon, + required String title, + required String subtitle, + Widget? trailing, + VoidCallback? onTap, + }) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + color: NetDeskColors.surface, + borderRadius: BorderRadius.circular(NetDeskRadius.md), + border: Border.all(color: NetDeskColors.border), + ), + child: ListTile( + onTap: onTap, + leading: Icon(icon, color: NetDeskColors.textSecondary), + title: Text(title, + style: const TextStyle( + color: NetDeskColors.textPrimary, + fontWeight: FontWeight.w600)), + subtitle: Text(subtitle, + style: const TextStyle( + color: NetDeskColors.textMuted, fontSize: 12.5)), + trailing: trailing, + ), + ); + } + + Widget _switchTile({ + required IconData icon, + required String title, + required String subtitle, + required bool value, + required ValueChanged onChanged, + }) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + color: NetDeskColors.surface, + borderRadius: BorderRadius.circular(NetDeskRadius.md), + border: Border.all(color: NetDeskColors.border), + ), + child: SwitchListTile( + value: value, + onChanged: onChanged, + activeThumbColor: NetDeskColors.accent, + secondary: Icon(icon, color: NetDeskColors.textSecondary), + title: Text(title, + style: const TextStyle( + color: NetDeskColors.textPrimary, + fontWeight: FontWeight.w600)), + subtitle: Text(subtitle, + style: const TextStyle( + color: NetDeskColors.textMuted, fontSize: 12.5)), + ), + ); + } +} diff --git a/native/ui/lib/main.dart b/native/ui/lib/main.dart new file mode 100644 index 0000000..cc53d92 --- /dev/null +++ b/native/ui/lib/main.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'connect_screen.dart'; +import 'rust/frb_generated.dart'; +import 'theme.dart'; + +/// Entry point. +/// +/// Before the first widget builds we initialise flutter_rust_bridge, which +/// loads the native `netdesk_frb` library (built from the Rust `netdesk-frb` +/// crate over `netdesk-core`). All session work — signaling, the transport, +/// input injection and the H.264 decode — runs behind that FFI layer; the Dart +/// side only paints frames and forwards input. +/// +/// If the engine fails to load we still start the app, surfacing the error on +/// the connect screen instead of crashing to a black window. +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Behave like a true Android app: allow every orientation (tablets are used + // in landscape), and draw behind the system bars for an edge-to-edge look. + await SystemChrome.setPreferredOrientations(const [ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + + String? initError; + try { + await RustLib.init(); + } catch (e) { + initError = '$e'; + } + + runApp(NetDeskApp(initError: initError)); +} + +/// Root application widget. +/// +/// NetDesk is Akronlogic's premium cross-platform remote-desktop client. This +/// Flutter layer is the touch-native front-end; it binds to the Rust +/// `netdesk-core` crate via flutter_rust_bridge to drive the whole session. +class NetDeskApp extends StatelessWidget { + const NetDeskApp({super.key, this.initError}); + + /// Non-null when the native engine could not be loaded at startup. + final String? initError; + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'NetDesk', + debugShowCheckedModeBanner: false, + theme: NetDeskTheme.dark, + darkTheme: NetDeskTheme.dark, + themeMode: ThemeMode.dark, + home: ConnectScreen(initError: initError), + ); + } +} diff --git a/native/ui/lib/rust/api.dart b/native/ui/lib/rust/api.dart new file mode 100644 index 0000000..67f3a7b --- /dev/null +++ b/native/ui/lib/rust/api.dart @@ -0,0 +1,258 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.13.0. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import 'frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; +import 'package:freezed_annotation/freezed_annotation.dart' hide protected; +part 'api.freezed.dart'; + +// These functions are ignored because they are not marked as `pub`: `cancels`, `host_cancels`, `host_frames`, `host_pending`, `map_host_event`, `pending`, `rgb_to_rgba`, `runtime`, `sessions`, `with_session` +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt` + +/// 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. +Future connect( + {required String wsUrl, + required String hostId, + required String password, + required String totp}) => + RustLib.instance.api.crateApiConnect( + wsUrl: wsUrl, hostId: hostId, password: password, totp: totp); + +/// Subscribe to the [`NdEvent`] stream for a `handle` returned by [`connect`]. +/// In Dart this is `Stream sessionEvents({required BigInt handle})`. +/// Call it once per handle, right after `connect`. +Stream sessionEvents({required BigInt handle}) => + RustLib.instance.api.crateApiSessionEvents(handle: handle); + +/// Send a pointer event (coordinates in the remote screen's pixel space). +/// `buttons` is the button bitmask, `wheel`/`hwheel` are scroll deltas. +void sendMouse( + {required BigInt handle, + required int x, + required int y, + required int buttons, + required double wheel, + required double hwheel}) => + RustLib.instance.api.crateApiSendMouse( + handle: handle, + x: x, + y: y, + buttons: buttons, + wheel: wheel, + hwheel: hwheel); + +/// Send a key event. `code` is a DOM-style key code (e.g. "KeyA", "Enter"). +void sendKey( + {required BigInt handle, required String code, required bool down}) => + RustLib.instance.api + .crateApiSendKey(handle: handle, code: code, down: down); + +/// Reconfigure the host encoder: target `fps`, `bitrate` (kbps), resolution `scale`. +void setQuality( + {required BigInt handle, + required int fps, + required int bitrate, + required double scale}) => + RustLib.instance.api.crateApiSetQuality( + handle: handle, fps: fps, bitrate: bitrate, scale: 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. +void monitorSwitch({required BigInt handle, required int i}) => + RustLib.instance.api.crateApiMonitorSwitch(handle: handle, i: i); + +/// 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. +List sessionMonitors({required BigInt handle}) => + RustLib.instance.api.crateApiSessionMonitors(handle: handle); + +/// The monitor index the host is currently capturing (as reported at accept). +int sessionCurrentMonitor({required BigInt handle}) => + RustLib.instance.api.crateApiSessionCurrentMonitor(handle: handle); + +/// Send Ctrl+Alt+Del on the host. +void ctrlAltDel({required BigInt handle}) => + RustLib.instance.api.crateApiCtrlAltDel(handle: handle); + +/// Send a chat message to the host. +void sendChat({required BigInt handle, required String text}) => + RustLib.instance.api.crateApiSendChat(handle: handle, text: text); + +/// Push text to the remote clipboard (and announce our local clipboard). +void setClipboard({required BigInt handle, required String text}) => + RustLib.instance.api.crateApiSetClipboard(handle: handle, text: text); + +/// End the session: tell the host, then drop the handle. The event stream +/// receives a final [`NdEvent::Ended`] when the network task unwinds. +void disconnect({required BigInt handle}) => + RustLib.instance.api.crateApiDisconnect(handle: handle); + +/// A fresh 9-digit host id to persist on the device (shown to viewers). +String hostGenId() => RustLib.instance.api.crateApiHostGenId(); + +/// A fresh friendly unattended password (no look-alike chars). +String hostGenPassword() => RustLib.instance.api.crateApiHostGenPassword(); + +/// 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). +Future hostStart( + {required String wsUrl, + required String hostId, + required String name, + required String password, + required int width, + required int height}) => + RustLib.instance.api.crateApiHostStart( + wsUrl: wsUrl, + hostId: hostId, + name: name, + password: password, + width: width, + height: height); + +/// Subscribe to the [`NdHostEvent`] stream for a `handle` from [`host_start`]. +Stream hostEvents({required BigInt handle}) => + RustLib.instance.api.crateApiHostEvents(handle: handle); + +/// Feed one encoded H.264 access unit (Annex-B) from the platform encoder. Cheap +/// + non-blocking; dropped when no viewer is connected. +void hostPushFrame( + {required BigInt handle, + required List data, + required bool keyframe}) => + RustLib.instance.api + .crateApiHostPushFrame(handle: handle, data: data, keyframe: keyframe); + +/// Stop hosting: end the reconnect loop and drop the handle. +void hostStop({required BigInt handle}) => + RustLib.instance.api.crateApiHostStop(handle: handle); + +@freezed +sealed class NdEvent with _$NdEvent { + const NdEvent._(); + + /// The WebSocket is dialing / the handshake is in flight. + const factory NdEvent.connecting() = NdEvent_Connecting; + + /// Session accepted; the remote screen is about to stream. + const factory NdEvent.connected({ + required int w, + required int h, + required bool e2E, + required String hostName, + }) = NdEvent_Connected; + + /// A decoded, paint-ready frame: tightly-packed RGBA8 (`width*height*4`). + const factory NdEvent.frame({ + required int width, + required int height, + required Uint8List rgba, + }) = NdEvent_Frame; + + /// The session ended (socket closed, host ended it, connect failed, …). + const factory NdEvent.ended({ + required String reason, + }) = NdEvent_Ended; + + /// The host refused the session (bad password/TOTP, busy, blocked, …). + const factory NdEvent.denied({ + required String reason, + }) = NdEvent_Denied; + + /// A chat message from the host. + const factory NdEvent.chatMessage({ + required String from, + required String text, + }) = NdEvent_ChatMessage; + + /// Remote clipboard text pushed by the host (RustDesk-style sync). + const factory NdEvent.clipboard({ + required String text, + }) = NdEvent_Clipboard; +} + +@freezed +sealed class NdHostEvent with _$NdHostEvent { + const NdHostEvent._(); + + /// Registered with the server; the device is reachable at its host id. + const factory NdHostEvent.registered() = NdHostEvent_Registered; + + /// A viewer authenticated; the session is live (inject input, feed frames). + const factory NdHostEvent.viewerConnected({ + required bool e2E, + }) = NdHostEvent_ViewerConnected; + + /// The viewer left; the host stays registered for the next one. + const factory NdHostEvent.viewerLeft() = NdHostEvent_ViewerLeft; + + /// Pointer in the captured image's pixel space (buttons = bitmask). + const factory NdHostEvent.mouse({ + required int x, + required int y, + required int buttons, + required double wheel, + }) = NdHostEvent_Mouse; + + /// Key: DOM-style code + down/up. + const factory NdHostEvent.key({ + required String code, + required bool down, + }) = NdHostEvent_Key; + + /// The viewer asked for a fresh keyframe — request one from the encoder. + const factory NdHostEvent.keyframeRequested() = NdHostEvent_KeyframeRequested; + + /// The viewer changed quality targets. + const factory NdHostEvent.quality({ + required int fps, + required int bitrate, + required double scale, + }) = NdHostEvent_Quality; + + /// The server link dropped; auto-reconnecting. + const factory NdHostEvent.reconnecting() = NdHostEvent_Reconnecting; + + /// The host loop stopped for good (host_stop / fatal). + const factory NdHostEvent.ended({ + required String reason, + }) = NdHostEvent_Ended; +} + +/// A display the host advertised at accept time (index + pixel size). +class NdMonitor { + final int i; + final int w; + final int h; + + const NdMonitor({ + required this.i, + required this.w, + required this.h, + }); + + @override + int get hashCode => i.hashCode ^ w.hashCode ^ h.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is NdMonitor && + runtimeType == other.runtimeType && + i == other.i && + w == other.w && + h == other.h; +} diff --git a/native/ui/lib/rust/api.freezed.dart b/native/ui/lib/rust/api.freezed.dart new file mode 100644 index 0000000..c0a3a09 --- /dev/null +++ b/native/ui/lib/rust/api.freezed.dart @@ -0,0 +1,3083 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'api.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$NdEvent { + @optionalTypeArgs + TResult when({ + required TResult Function() connecting, + required TResult Function(int w, int h, bool e2E, String hostName) + connected, + required TResult Function(int width, int height, Uint8List rgba) frame, + required TResult Function(String reason) ended, + required TResult Function(String reason) denied, + required TResult Function(String from, String text) chatMessage, + required TResult Function(String text) clipboard, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? connecting, + TResult? Function(int w, int h, bool e2E, String hostName)? connected, + TResult? Function(int width, int height, Uint8List rgba)? frame, + TResult? Function(String reason)? ended, + TResult? Function(String reason)? denied, + TResult? Function(String from, String text)? chatMessage, + TResult? Function(String text)? clipboard, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? connecting, + TResult Function(int w, int h, bool e2E, String hostName)? connected, + TResult Function(int width, int height, Uint8List rgba)? frame, + TResult Function(String reason)? ended, + TResult Function(String reason)? denied, + TResult Function(String from, String text)? chatMessage, + TResult Function(String text)? clipboard, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(NdEvent_Connecting value) connecting, + required TResult Function(NdEvent_Connected value) connected, + required TResult Function(NdEvent_Frame value) frame, + required TResult Function(NdEvent_Ended value) ended, + required TResult Function(NdEvent_Denied value) denied, + required TResult Function(NdEvent_ChatMessage value) chatMessage, + required TResult Function(NdEvent_Clipboard value) clipboard, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdEvent_Connecting value)? connecting, + TResult? Function(NdEvent_Connected value)? connected, + TResult? Function(NdEvent_Frame value)? frame, + TResult? Function(NdEvent_Ended value)? ended, + TResult? Function(NdEvent_Denied value)? denied, + TResult? Function(NdEvent_ChatMessage value)? chatMessage, + TResult? Function(NdEvent_Clipboard value)? clipboard, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdEvent_Connecting value)? connecting, + TResult Function(NdEvent_Connected value)? connected, + TResult Function(NdEvent_Frame value)? frame, + TResult Function(NdEvent_Ended value)? ended, + TResult Function(NdEvent_Denied value)? denied, + TResult Function(NdEvent_ChatMessage value)? chatMessage, + TResult Function(NdEvent_Clipboard value)? clipboard, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $NdEventCopyWith<$Res> { + factory $NdEventCopyWith(NdEvent value, $Res Function(NdEvent) then) = + _$NdEventCopyWithImpl<$Res, NdEvent>; +} + +/// @nodoc +class _$NdEventCopyWithImpl<$Res, $Val extends NdEvent> + implements $NdEventCopyWith<$Res> { + _$NdEventCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc +abstract class _$$NdEvent_ConnectingImplCopyWith<$Res> { + factory _$$NdEvent_ConnectingImplCopyWith(_$NdEvent_ConnectingImpl value, + $Res Function(_$NdEvent_ConnectingImpl) then) = + __$$NdEvent_ConnectingImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$NdEvent_ConnectingImplCopyWithImpl<$Res> + extends _$NdEventCopyWithImpl<$Res, _$NdEvent_ConnectingImpl> + implements _$$NdEvent_ConnectingImplCopyWith<$Res> { + __$$NdEvent_ConnectingImplCopyWithImpl(_$NdEvent_ConnectingImpl _value, + $Res Function(_$NdEvent_ConnectingImpl) _then) + : super(_value, _then); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$NdEvent_ConnectingImpl extends NdEvent_Connecting { + const _$NdEvent_ConnectingImpl() : super._(); + + @override + String toString() { + return 'NdEvent.connecting()'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is _$NdEvent_ConnectingImpl); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() connecting, + required TResult Function(int w, int h, bool e2E, String hostName) + connected, + required TResult Function(int width, int height, Uint8List rgba) frame, + required TResult Function(String reason) ended, + required TResult Function(String reason) denied, + required TResult Function(String from, String text) chatMessage, + required TResult Function(String text) clipboard, + }) { + return connecting(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? connecting, + TResult? Function(int w, int h, bool e2E, String hostName)? connected, + TResult? Function(int width, int height, Uint8List rgba)? frame, + TResult? Function(String reason)? ended, + TResult? Function(String reason)? denied, + TResult? Function(String from, String text)? chatMessage, + TResult? Function(String text)? clipboard, + }) { + return connecting?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? connecting, + TResult Function(int w, int h, bool e2E, String hostName)? connected, + TResult Function(int width, int height, Uint8List rgba)? frame, + TResult Function(String reason)? ended, + TResult Function(String reason)? denied, + TResult Function(String from, String text)? chatMessage, + TResult Function(String text)? clipboard, + required TResult orElse(), + }) { + if (connecting != null) { + return connecting(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdEvent_Connecting value) connecting, + required TResult Function(NdEvent_Connected value) connected, + required TResult Function(NdEvent_Frame value) frame, + required TResult Function(NdEvent_Ended value) ended, + required TResult Function(NdEvent_Denied value) denied, + required TResult Function(NdEvent_ChatMessage value) chatMessage, + required TResult Function(NdEvent_Clipboard value) clipboard, + }) { + return connecting(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdEvent_Connecting value)? connecting, + TResult? Function(NdEvent_Connected value)? connected, + TResult? Function(NdEvent_Frame value)? frame, + TResult? Function(NdEvent_Ended value)? ended, + TResult? Function(NdEvent_Denied value)? denied, + TResult? Function(NdEvent_ChatMessage value)? chatMessage, + TResult? Function(NdEvent_Clipboard value)? clipboard, + }) { + return connecting?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdEvent_Connecting value)? connecting, + TResult Function(NdEvent_Connected value)? connected, + TResult Function(NdEvent_Frame value)? frame, + TResult Function(NdEvent_Ended value)? ended, + TResult Function(NdEvent_Denied value)? denied, + TResult Function(NdEvent_ChatMessage value)? chatMessage, + TResult Function(NdEvent_Clipboard value)? clipboard, + required TResult orElse(), + }) { + if (connecting != null) { + return connecting(this); + } + return orElse(); + } +} + +abstract class NdEvent_Connecting extends NdEvent { + const factory NdEvent_Connecting() = _$NdEvent_ConnectingImpl; + const NdEvent_Connecting._() : super._(); +} + +/// @nodoc +abstract class _$$NdEvent_ConnectedImplCopyWith<$Res> { + factory _$$NdEvent_ConnectedImplCopyWith(_$NdEvent_ConnectedImpl value, + $Res Function(_$NdEvent_ConnectedImpl) then) = + __$$NdEvent_ConnectedImplCopyWithImpl<$Res>; + @useResult + $Res call({int w, int h, bool e2E, String hostName}); +} + +/// @nodoc +class __$$NdEvent_ConnectedImplCopyWithImpl<$Res> + extends _$NdEventCopyWithImpl<$Res, _$NdEvent_ConnectedImpl> + implements _$$NdEvent_ConnectedImplCopyWith<$Res> { + __$$NdEvent_ConnectedImplCopyWithImpl(_$NdEvent_ConnectedImpl _value, + $Res Function(_$NdEvent_ConnectedImpl) _then) + : super(_value, _then); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? w = null, + Object? h = null, + Object? e2E = null, + Object? hostName = null, + }) { + return _then(_$NdEvent_ConnectedImpl( + w: null == w + ? _value.w + : w // ignore: cast_nullable_to_non_nullable + as int, + h: null == h + ? _value.h + : h // ignore: cast_nullable_to_non_nullable + as int, + e2E: null == e2E + ? _value.e2E + : e2E // ignore: cast_nullable_to_non_nullable + as bool, + hostName: null == hostName + ? _value.hostName + : hostName // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc + +class _$NdEvent_ConnectedImpl extends NdEvent_Connected { + const _$NdEvent_ConnectedImpl( + {required this.w, + required this.h, + required this.e2E, + required this.hostName}) + : super._(); + + @override + final int w; + @override + final int h; + @override + final bool e2E; + @override + final String hostName; + + @override + String toString() { + return 'NdEvent.connected(w: $w, h: $h, e2E: $e2E, hostName: $hostName)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdEvent_ConnectedImpl && + (identical(other.w, w) || other.w == w) && + (identical(other.h, h) || other.h == h) && + (identical(other.e2E, e2E) || other.e2E == e2E) && + (identical(other.hostName, hostName) || + other.hostName == hostName)); + } + + @override + int get hashCode => Object.hash(runtimeType, w, h, e2E, hostName); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$NdEvent_ConnectedImplCopyWith<_$NdEvent_ConnectedImpl> get copyWith => + __$$NdEvent_ConnectedImplCopyWithImpl<_$NdEvent_ConnectedImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() connecting, + required TResult Function(int w, int h, bool e2E, String hostName) + connected, + required TResult Function(int width, int height, Uint8List rgba) frame, + required TResult Function(String reason) ended, + required TResult Function(String reason) denied, + required TResult Function(String from, String text) chatMessage, + required TResult Function(String text) clipboard, + }) { + return connected(w, h, e2E, hostName); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? connecting, + TResult? Function(int w, int h, bool e2E, String hostName)? connected, + TResult? Function(int width, int height, Uint8List rgba)? frame, + TResult? Function(String reason)? ended, + TResult? Function(String reason)? denied, + TResult? Function(String from, String text)? chatMessage, + TResult? Function(String text)? clipboard, + }) { + return connected?.call(w, h, e2E, hostName); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? connecting, + TResult Function(int w, int h, bool e2E, String hostName)? connected, + TResult Function(int width, int height, Uint8List rgba)? frame, + TResult Function(String reason)? ended, + TResult Function(String reason)? denied, + TResult Function(String from, String text)? chatMessage, + TResult Function(String text)? clipboard, + required TResult orElse(), + }) { + if (connected != null) { + return connected(w, h, e2E, hostName); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdEvent_Connecting value) connecting, + required TResult Function(NdEvent_Connected value) connected, + required TResult Function(NdEvent_Frame value) frame, + required TResult Function(NdEvent_Ended value) ended, + required TResult Function(NdEvent_Denied value) denied, + required TResult Function(NdEvent_ChatMessage value) chatMessage, + required TResult Function(NdEvent_Clipboard value) clipboard, + }) { + return connected(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdEvent_Connecting value)? connecting, + TResult? Function(NdEvent_Connected value)? connected, + TResult? Function(NdEvent_Frame value)? frame, + TResult? Function(NdEvent_Ended value)? ended, + TResult? Function(NdEvent_Denied value)? denied, + TResult? Function(NdEvent_ChatMessage value)? chatMessage, + TResult? Function(NdEvent_Clipboard value)? clipboard, + }) { + return connected?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdEvent_Connecting value)? connecting, + TResult Function(NdEvent_Connected value)? connected, + TResult Function(NdEvent_Frame value)? frame, + TResult Function(NdEvent_Ended value)? ended, + TResult Function(NdEvent_Denied value)? denied, + TResult Function(NdEvent_ChatMessage value)? chatMessage, + TResult Function(NdEvent_Clipboard value)? clipboard, + required TResult orElse(), + }) { + if (connected != null) { + return connected(this); + } + return orElse(); + } +} + +abstract class NdEvent_Connected extends NdEvent { + const factory NdEvent_Connected( + {required final int w, + required final int h, + required final bool e2E, + required final String hostName}) = _$NdEvent_ConnectedImpl; + const NdEvent_Connected._() : super._(); + + int get w; + int get h; + bool get e2E; + String get hostName; + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$NdEvent_ConnectedImplCopyWith<_$NdEvent_ConnectedImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$NdEvent_FrameImplCopyWith<$Res> { + factory _$$NdEvent_FrameImplCopyWith( + _$NdEvent_FrameImpl value, $Res Function(_$NdEvent_FrameImpl) then) = + __$$NdEvent_FrameImplCopyWithImpl<$Res>; + @useResult + $Res call({int width, int height, Uint8List rgba}); +} + +/// @nodoc +class __$$NdEvent_FrameImplCopyWithImpl<$Res> + extends _$NdEventCopyWithImpl<$Res, _$NdEvent_FrameImpl> + implements _$$NdEvent_FrameImplCopyWith<$Res> { + __$$NdEvent_FrameImplCopyWithImpl( + _$NdEvent_FrameImpl _value, $Res Function(_$NdEvent_FrameImpl) _then) + : super(_value, _then); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? width = null, + Object? height = null, + Object? rgba = null, + }) { + return _then(_$NdEvent_FrameImpl( + width: null == width + ? _value.width + : width // ignore: cast_nullable_to_non_nullable + as int, + height: null == height + ? _value.height + : height // ignore: cast_nullable_to_non_nullable + as int, + rgba: null == rgba + ? _value.rgba + : rgba // ignore: cast_nullable_to_non_nullable + as Uint8List, + )); + } +} + +/// @nodoc + +class _$NdEvent_FrameImpl extends NdEvent_Frame { + const _$NdEvent_FrameImpl( + {required this.width, required this.height, required this.rgba}) + : super._(); + + @override + final int width; + @override + final int height; + @override + final Uint8List rgba; + + @override + String toString() { + return 'NdEvent.frame(width: $width, height: $height, rgba: $rgba)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdEvent_FrameImpl && + (identical(other.width, width) || other.width == width) && + (identical(other.height, height) || other.height == height) && + const DeepCollectionEquality().equals(other.rgba, rgba)); + } + + @override + int get hashCode => Object.hash( + runtimeType, width, height, const DeepCollectionEquality().hash(rgba)); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$NdEvent_FrameImplCopyWith<_$NdEvent_FrameImpl> get copyWith => + __$$NdEvent_FrameImplCopyWithImpl<_$NdEvent_FrameImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() connecting, + required TResult Function(int w, int h, bool e2E, String hostName) + connected, + required TResult Function(int width, int height, Uint8List rgba) frame, + required TResult Function(String reason) ended, + required TResult Function(String reason) denied, + required TResult Function(String from, String text) chatMessage, + required TResult Function(String text) clipboard, + }) { + return frame(width, height, rgba); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? connecting, + TResult? Function(int w, int h, bool e2E, String hostName)? connected, + TResult? Function(int width, int height, Uint8List rgba)? frame, + TResult? Function(String reason)? ended, + TResult? Function(String reason)? denied, + TResult? Function(String from, String text)? chatMessage, + TResult? Function(String text)? clipboard, + }) { + return frame?.call(width, height, rgba); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? connecting, + TResult Function(int w, int h, bool e2E, String hostName)? connected, + TResult Function(int width, int height, Uint8List rgba)? frame, + TResult Function(String reason)? ended, + TResult Function(String reason)? denied, + TResult Function(String from, String text)? chatMessage, + TResult Function(String text)? clipboard, + required TResult orElse(), + }) { + if (frame != null) { + return frame(width, height, rgba); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdEvent_Connecting value) connecting, + required TResult Function(NdEvent_Connected value) connected, + required TResult Function(NdEvent_Frame value) frame, + required TResult Function(NdEvent_Ended value) ended, + required TResult Function(NdEvent_Denied value) denied, + required TResult Function(NdEvent_ChatMessage value) chatMessage, + required TResult Function(NdEvent_Clipboard value) clipboard, + }) { + return frame(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdEvent_Connecting value)? connecting, + TResult? Function(NdEvent_Connected value)? connected, + TResult? Function(NdEvent_Frame value)? frame, + TResult? Function(NdEvent_Ended value)? ended, + TResult? Function(NdEvent_Denied value)? denied, + TResult? Function(NdEvent_ChatMessage value)? chatMessage, + TResult? Function(NdEvent_Clipboard value)? clipboard, + }) { + return frame?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdEvent_Connecting value)? connecting, + TResult Function(NdEvent_Connected value)? connected, + TResult Function(NdEvent_Frame value)? frame, + TResult Function(NdEvent_Ended value)? ended, + TResult Function(NdEvent_Denied value)? denied, + TResult Function(NdEvent_ChatMessage value)? chatMessage, + TResult Function(NdEvent_Clipboard value)? clipboard, + required TResult orElse(), + }) { + if (frame != null) { + return frame(this); + } + return orElse(); + } +} + +abstract class NdEvent_Frame extends NdEvent { + const factory NdEvent_Frame( + {required final int width, + required final int height, + required final Uint8List rgba}) = _$NdEvent_FrameImpl; + const NdEvent_Frame._() : super._(); + + int get width; + int get height; + Uint8List get rgba; + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$NdEvent_FrameImplCopyWith<_$NdEvent_FrameImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$NdEvent_EndedImplCopyWith<$Res> { + factory _$$NdEvent_EndedImplCopyWith( + _$NdEvent_EndedImpl value, $Res Function(_$NdEvent_EndedImpl) then) = + __$$NdEvent_EndedImplCopyWithImpl<$Res>; + @useResult + $Res call({String reason}); +} + +/// @nodoc +class __$$NdEvent_EndedImplCopyWithImpl<$Res> + extends _$NdEventCopyWithImpl<$Res, _$NdEvent_EndedImpl> + implements _$$NdEvent_EndedImplCopyWith<$Res> { + __$$NdEvent_EndedImplCopyWithImpl( + _$NdEvent_EndedImpl _value, $Res Function(_$NdEvent_EndedImpl) _then) + : super(_value, _then); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? reason = null, + }) { + return _then(_$NdEvent_EndedImpl( + reason: null == reason + ? _value.reason + : reason // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc + +class _$NdEvent_EndedImpl extends NdEvent_Ended { + const _$NdEvent_EndedImpl({required this.reason}) : super._(); + + @override + final String reason; + + @override + String toString() { + return 'NdEvent.ended(reason: $reason)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdEvent_EndedImpl && + (identical(other.reason, reason) || other.reason == reason)); + } + + @override + int get hashCode => Object.hash(runtimeType, reason); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$NdEvent_EndedImplCopyWith<_$NdEvent_EndedImpl> get copyWith => + __$$NdEvent_EndedImplCopyWithImpl<_$NdEvent_EndedImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() connecting, + required TResult Function(int w, int h, bool e2E, String hostName) + connected, + required TResult Function(int width, int height, Uint8List rgba) frame, + required TResult Function(String reason) ended, + required TResult Function(String reason) denied, + required TResult Function(String from, String text) chatMessage, + required TResult Function(String text) clipboard, + }) { + return ended(reason); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? connecting, + TResult? Function(int w, int h, bool e2E, String hostName)? connected, + TResult? Function(int width, int height, Uint8List rgba)? frame, + TResult? Function(String reason)? ended, + TResult? Function(String reason)? denied, + TResult? Function(String from, String text)? chatMessage, + TResult? Function(String text)? clipboard, + }) { + return ended?.call(reason); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? connecting, + TResult Function(int w, int h, bool e2E, String hostName)? connected, + TResult Function(int width, int height, Uint8List rgba)? frame, + TResult Function(String reason)? ended, + TResult Function(String reason)? denied, + TResult Function(String from, String text)? chatMessage, + TResult Function(String text)? clipboard, + required TResult orElse(), + }) { + if (ended != null) { + return ended(reason); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdEvent_Connecting value) connecting, + required TResult Function(NdEvent_Connected value) connected, + required TResult Function(NdEvent_Frame value) frame, + required TResult Function(NdEvent_Ended value) ended, + required TResult Function(NdEvent_Denied value) denied, + required TResult Function(NdEvent_ChatMessage value) chatMessage, + required TResult Function(NdEvent_Clipboard value) clipboard, + }) { + return ended(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdEvent_Connecting value)? connecting, + TResult? Function(NdEvent_Connected value)? connected, + TResult? Function(NdEvent_Frame value)? frame, + TResult? Function(NdEvent_Ended value)? ended, + TResult? Function(NdEvent_Denied value)? denied, + TResult? Function(NdEvent_ChatMessage value)? chatMessage, + TResult? Function(NdEvent_Clipboard value)? clipboard, + }) { + return ended?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdEvent_Connecting value)? connecting, + TResult Function(NdEvent_Connected value)? connected, + TResult Function(NdEvent_Frame value)? frame, + TResult Function(NdEvent_Ended value)? ended, + TResult Function(NdEvent_Denied value)? denied, + TResult Function(NdEvent_ChatMessage value)? chatMessage, + TResult Function(NdEvent_Clipboard value)? clipboard, + required TResult orElse(), + }) { + if (ended != null) { + return ended(this); + } + return orElse(); + } +} + +abstract class NdEvent_Ended extends NdEvent { + const factory NdEvent_Ended({required final String reason}) = + _$NdEvent_EndedImpl; + const NdEvent_Ended._() : super._(); + + String get reason; + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$NdEvent_EndedImplCopyWith<_$NdEvent_EndedImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$NdEvent_DeniedImplCopyWith<$Res> { + factory _$$NdEvent_DeniedImplCopyWith(_$NdEvent_DeniedImpl value, + $Res Function(_$NdEvent_DeniedImpl) then) = + __$$NdEvent_DeniedImplCopyWithImpl<$Res>; + @useResult + $Res call({String reason}); +} + +/// @nodoc +class __$$NdEvent_DeniedImplCopyWithImpl<$Res> + extends _$NdEventCopyWithImpl<$Res, _$NdEvent_DeniedImpl> + implements _$$NdEvent_DeniedImplCopyWith<$Res> { + __$$NdEvent_DeniedImplCopyWithImpl( + _$NdEvent_DeniedImpl _value, $Res Function(_$NdEvent_DeniedImpl) _then) + : super(_value, _then); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? reason = null, + }) { + return _then(_$NdEvent_DeniedImpl( + reason: null == reason + ? _value.reason + : reason // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc + +class _$NdEvent_DeniedImpl extends NdEvent_Denied { + const _$NdEvent_DeniedImpl({required this.reason}) : super._(); + + @override + final String reason; + + @override + String toString() { + return 'NdEvent.denied(reason: $reason)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdEvent_DeniedImpl && + (identical(other.reason, reason) || other.reason == reason)); + } + + @override + int get hashCode => Object.hash(runtimeType, reason); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$NdEvent_DeniedImplCopyWith<_$NdEvent_DeniedImpl> get copyWith => + __$$NdEvent_DeniedImplCopyWithImpl<_$NdEvent_DeniedImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() connecting, + required TResult Function(int w, int h, bool e2E, String hostName) + connected, + required TResult Function(int width, int height, Uint8List rgba) frame, + required TResult Function(String reason) ended, + required TResult Function(String reason) denied, + required TResult Function(String from, String text) chatMessage, + required TResult Function(String text) clipboard, + }) { + return denied(reason); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? connecting, + TResult? Function(int w, int h, bool e2E, String hostName)? connected, + TResult? Function(int width, int height, Uint8List rgba)? frame, + TResult? Function(String reason)? ended, + TResult? Function(String reason)? denied, + TResult? Function(String from, String text)? chatMessage, + TResult? Function(String text)? clipboard, + }) { + return denied?.call(reason); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? connecting, + TResult Function(int w, int h, bool e2E, String hostName)? connected, + TResult Function(int width, int height, Uint8List rgba)? frame, + TResult Function(String reason)? ended, + TResult Function(String reason)? denied, + TResult Function(String from, String text)? chatMessage, + TResult Function(String text)? clipboard, + required TResult orElse(), + }) { + if (denied != null) { + return denied(reason); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdEvent_Connecting value) connecting, + required TResult Function(NdEvent_Connected value) connected, + required TResult Function(NdEvent_Frame value) frame, + required TResult Function(NdEvent_Ended value) ended, + required TResult Function(NdEvent_Denied value) denied, + required TResult Function(NdEvent_ChatMessage value) chatMessage, + required TResult Function(NdEvent_Clipboard value) clipboard, + }) { + return denied(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdEvent_Connecting value)? connecting, + TResult? Function(NdEvent_Connected value)? connected, + TResult? Function(NdEvent_Frame value)? frame, + TResult? Function(NdEvent_Ended value)? ended, + TResult? Function(NdEvent_Denied value)? denied, + TResult? Function(NdEvent_ChatMessage value)? chatMessage, + TResult? Function(NdEvent_Clipboard value)? clipboard, + }) { + return denied?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdEvent_Connecting value)? connecting, + TResult Function(NdEvent_Connected value)? connected, + TResult Function(NdEvent_Frame value)? frame, + TResult Function(NdEvent_Ended value)? ended, + TResult Function(NdEvent_Denied value)? denied, + TResult Function(NdEvent_ChatMessage value)? chatMessage, + TResult Function(NdEvent_Clipboard value)? clipboard, + required TResult orElse(), + }) { + if (denied != null) { + return denied(this); + } + return orElse(); + } +} + +abstract class NdEvent_Denied extends NdEvent { + const factory NdEvent_Denied({required final String reason}) = + _$NdEvent_DeniedImpl; + const NdEvent_Denied._() : super._(); + + String get reason; + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$NdEvent_DeniedImplCopyWith<_$NdEvent_DeniedImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$NdEvent_ChatMessageImplCopyWith<$Res> { + factory _$$NdEvent_ChatMessageImplCopyWith(_$NdEvent_ChatMessageImpl value, + $Res Function(_$NdEvent_ChatMessageImpl) then) = + __$$NdEvent_ChatMessageImplCopyWithImpl<$Res>; + @useResult + $Res call({String from, String text}); +} + +/// @nodoc +class __$$NdEvent_ChatMessageImplCopyWithImpl<$Res> + extends _$NdEventCopyWithImpl<$Res, _$NdEvent_ChatMessageImpl> + implements _$$NdEvent_ChatMessageImplCopyWith<$Res> { + __$$NdEvent_ChatMessageImplCopyWithImpl(_$NdEvent_ChatMessageImpl _value, + $Res Function(_$NdEvent_ChatMessageImpl) _then) + : super(_value, _then); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? from = null, + Object? text = null, + }) { + return _then(_$NdEvent_ChatMessageImpl( + from: null == from + ? _value.from + : from // ignore: cast_nullable_to_non_nullable + as String, + text: null == text + ? _value.text + : text // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc + +class _$NdEvent_ChatMessageImpl extends NdEvent_ChatMessage { + const _$NdEvent_ChatMessageImpl({required this.from, required this.text}) + : super._(); + + @override + final String from; + @override + final String text; + + @override + String toString() { + return 'NdEvent.chatMessage(from: $from, text: $text)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdEvent_ChatMessageImpl && + (identical(other.from, from) || other.from == from) && + (identical(other.text, text) || other.text == text)); + } + + @override + int get hashCode => Object.hash(runtimeType, from, text); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$NdEvent_ChatMessageImplCopyWith<_$NdEvent_ChatMessageImpl> get copyWith => + __$$NdEvent_ChatMessageImplCopyWithImpl<_$NdEvent_ChatMessageImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() connecting, + required TResult Function(int w, int h, bool e2E, String hostName) + connected, + required TResult Function(int width, int height, Uint8List rgba) frame, + required TResult Function(String reason) ended, + required TResult Function(String reason) denied, + required TResult Function(String from, String text) chatMessage, + required TResult Function(String text) clipboard, + }) { + return chatMessage(from, text); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? connecting, + TResult? Function(int w, int h, bool e2E, String hostName)? connected, + TResult? Function(int width, int height, Uint8List rgba)? frame, + TResult? Function(String reason)? ended, + TResult? Function(String reason)? denied, + TResult? Function(String from, String text)? chatMessage, + TResult? Function(String text)? clipboard, + }) { + return chatMessage?.call(from, text); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? connecting, + TResult Function(int w, int h, bool e2E, String hostName)? connected, + TResult Function(int width, int height, Uint8List rgba)? frame, + TResult Function(String reason)? ended, + TResult Function(String reason)? denied, + TResult Function(String from, String text)? chatMessage, + TResult Function(String text)? clipboard, + required TResult orElse(), + }) { + if (chatMessage != null) { + return chatMessage(from, text); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdEvent_Connecting value) connecting, + required TResult Function(NdEvent_Connected value) connected, + required TResult Function(NdEvent_Frame value) frame, + required TResult Function(NdEvent_Ended value) ended, + required TResult Function(NdEvent_Denied value) denied, + required TResult Function(NdEvent_ChatMessage value) chatMessage, + required TResult Function(NdEvent_Clipboard value) clipboard, + }) { + return chatMessage(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdEvent_Connecting value)? connecting, + TResult? Function(NdEvent_Connected value)? connected, + TResult? Function(NdEvent_Frame value)? frame, + TResult? Function(NdEvent_Ended value)? ended, + TResult? Function(NdEvent_Denied value)? denied, + TResult? Function(NdEvent_ChatMessage value)? chatMessage, + TResult? Function(NdEvent_Clipboard value)? clipboard, + }) { + return chatMessage?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdEvent_Connecting value)? connecting, + TResult Function(NdEvent_Connected value)? connected, + TResult Function(NdEvent_Frame value)? frame, + TResult Function(NdEvent_Ended value)? ended, + TResult Function(NdEvent_Denied value)? denied, + TResult Function(NdEvent_ChatMessage value)? chatMessage, + TResult Function(NdEvent_Clipboard value)? clipboard, + required TResult orElse(), + }) { + if (chatMessage != null) { + return chatMessage(this); + } + return orElse(); + } +} + +abstract class NdEvent_ChatMessage extends NdEvent { + const factory NdEvent_ChatMessage( + {required final String from, + required final String text}) = _$NdEvent_ChatMessageImpl; + const NdEvent_ChatMessage._() : super._(); + + String get from; + String get text; + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$NdEvent_ChatMessageImplCopyWith<_$NdEvent_ChatMessageImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$NdEvent_ClipboardImplCopyWith<$Res> { + factory _$$NdEvent_ClipboardImplCopyWith(_$NdEvent_ClipboardImpl value, + $Res Function(_$NdEvent_ClipboardImpl) then) = + __$$NdEvent_ClipboardImplCopyWithImpl<$Res>; + @useResult + $Res call({String text}); +} + +/// @nodoc +class __$$NdEvent_ClipboardImplCopyWithImpl<$Res> + extends _$NdEventCopyWithImpl<$Res, _$NdEvent_ClipboardImpl> + implements _$$NdEvent_ClipboardImplCopyWith<$Res> { + __$$NdEvent_ClipboardImplCopyWithImpl(_$NdEvent_ClipboardImpl _value, + $Res Function(_$NdEvent_ClipboardImpl) _then) + : super(_value, _then); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? text = null, + }) { + return _then(_$NdEvent_ClipboardImpl( + text: null == text + ? _value.text + : text // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc + +class _$NdEvent_ClipboardImpl extends NdEvent_Clipboard { + const _$NdEvent_ClipboardImpl({required this.text}) : super._(); + + @override + final String text; + + @override + String toString() { + return 'NdEvent.clipboard(text: $text)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdEvent_ClipboardImpl && + (identical(other.text, text) || other.text == text)); + } + + @override + int get hashCode => Object.hash(runtimeType, text); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$NdEvent_ClipboardImplCopyWith<_$NdEvent_ClipboardImpl> get copyWith => + __$$NdEvent_ClipboardImplCopyWithImpl<_$NdEvent_ClipboardImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() connecting, + required TResult Function(int w, int h, bool e2E, String hostName) + connected, + required TResult Function(int width, int height, Uint8List rgba) frame, + required TResult Function(String reason) ended, + required TResult Function(String reason) denied, + required TResult Function(String from, String text) chatMessage, + required TResult Function(String text) clipboard, + }) { + return clipboard(text); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? connecting, + TResult? Function(int w, int h, bool e2E, String hostName)? connected, + TResult? Function(int width, int height, Uint8List rgba)? frame, + TResult? Function(String reason)? ended, + TResult? Function(String reason)? denied, + TResult? Function(String from, String text)? chatMessage, + TResult? Function(String text)? clipboard, + }) { + return clipboard?.call(text); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? connecting, + TResult Function(int w, int h, bool e2E, String hostName)? connected, + TResult Function(int width, int height, Uint8List rgba)? frame, + TResult Function(String reason)? ended, + TResult Function(String reason)? denied, + TResult Function(String from, String text)? chatMessage, + TResult Function(String text)? clipboard, + required TResult orElse(), + }) { + if (clipboard != null) { + return clipboard(text); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdEvent_Connecting value) connecting, + required TResult Function(NdEvent_Connected value) connected, + required TResult Function(NdEvent_Frame value) frame, + required TResult Function(NdEvent_Ended value) ended, + required TResult Function(NdEvent_Denied value) denied, + required TResult Function(NdEvent_ChatMessage value) chatMessage, + required TResult Function(NdEvent_Clipboard value) clipboard, + }) { + return clipboard(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdEvent_Connecting value)? connecting, + TResult? Function(NdEvent_Connected value)? connected, + TResult? Function(NdEvent_Frame value)? frame, + TResult? Function(NdEvent_Ended value)? ended, + TResult? Function(NdEvent_Denied value)? denied, + TResult? Function(NdEvent_ChatMessage value)? chatMessage, + TResult? Function(NdEvent_Clipboard value)? clipboard, + }) { + return clipboard?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdEvent_Connecting value)? connecting, + TResult Function(NdEvent_Connected value)? connected, + TResult Function(NdEvent_Frame value)? frame, + TResult Function(NdEvent_Ended value)? ended, + TResult Function(NdEvent_Denied value)? denied, + TResult Function(NdEvent_ChatMessage value)? chatMessage, + TResult Function(NdEvent_Clipboard value)? clipboard, + required TResult orElse(), + }) { + if (clipboard != null) { + return clipboard(this); + } + return orElse(); + } +} + +abstract class NdEvent_Clipboard extends NdEvent { + const factory NdEvent_Clipboard({required final String text}) = + _$NdEvent_ClipboardImpl; + const NdEvent_Clipboard._() : super._(); + + String get text; + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$NdEvent_ClipboardImplCopyWith<_$NdEvent_ClipboardImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +mixin _$NdHostEvent { + @optionalTypeArgs + TResult when({ + required TResult Function() registered, + required TResult Function(bool e2E) viewerConnected, + required TResult Function() viewerLeft, + required TResult Function(int x, int y, int buttons, double wheel) mouse, + required TResult Function(String code, bool down) key, + required TResult Function() keyframeRequested, + required TResult Function(int fps, int bitrate, double scale) quality, + required TResult Function() reconnecting, + required TResult Function(String reason) ended, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? registered, + TResult? Function(bool e2E)? viewerConnected, + TResult? Function()? viewerLeft, + TResult? Function(int x, int y, int buttons, double wheel)? mouse, + TResult? Function(String code, bool down)? key, + TResult? Function()? keyframeRequested, + TResult? Function(int fps, int bitrate, double scale)? quality, + TResult? Function()? reconnecting, + TResult? Function(String reason)? ended, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? registered, + TResult Function(bool e2E)? viewerConnected, + TResult Function()? viewerLeft, + TResult Function(int x, int y, int buttons, double wheel)? mouse, + TResult Function(String code, bool down)? key, + TResult Function()? keyframeRequested, + TResult Function(int fps, int bitrate, double scale)? quality, + TResult Function()? reconnecting, + TResult Function(String reason)? ended, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(NdHostEvent_Registered value) registered, + required TResult Function(NdHostEvent_ViewerConnected value) + viewerConnected, + required TResult Function(NdHostEvent_ViewerLeft value) viewerLeft, + required TResult Function(NdHostEvent_Mouse value) mouse, + required TResult Function(NdHostEvent_Key value) key, + required TResult Function(NdHostEvent_KeyframeRequested value) + keyframeRequested, + required TResult Function(NdHostEvent_Quality value) quality, + required TResult Function(NdHostEvent_Reconnecting value) reconnecting, + required TResult Function(NdHostEvent_Ended value) ended, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdHostEvent_Registered value)? registered, + TResult? Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult? Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult? Function(NdHostEvent_Mouse value)? mouse, + TResult? Function(NdHostEvent_Key value)? key, + TResult? Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult? Function(NdHostEvent_Quality value)? quality, + TResult? Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult? Function(NdHostEvent_Ended value)? ended, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdHostEvent_Registered value)? registered, + TResult Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult Function(NdHostEvent_Mouse value)? mouse, + TResult Function(NdHostEvent_Key value)? key, + TResult Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult Function(NdHostEvent_Quality value)? quality, + TResult Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult Function(NdHostEvent_Ended value)? ended, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $NdHostEventCopyWith<$Res> { + factory $NdHostEventCopyWith( + NdHostEvent value, $Res Function(NdHostEvent) then) = + _$NdHostEventCopyWithImpl<$Res, NdHostEvent>; +} + +/// @nodoc +class _$NdHostEventCopyWithImpl<$Res, $Val extends NdHostEvent> + implements $NdHostEventCopyWith<$Res> { + _$NdHostEventCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc +abstract class _$$NdHostEvent_RegisteredImplCopyWith<$Res> { + factory _$$NdHostEvent_RegisteredImplCopyWith( + _$NdHostEvent_RegisteredImpl value, + $Res Function(_$NdHostEvent_RegisteredImpl) then) = + __$$NdHostEvent_RegisteredImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$NdHostEvent_RegisteredImplCopyWithImpl<$Res> + extends _$NdHostEventCopyWithImpl<$Res, _$NdHostEvent_RegisteredImpl> + implements _$$NdHostEvent_RegisteredImplCopyWith<$Res> { + __$$NdHostEvent_RegisteredImplCopyWithImpl( + _$NdHostEvent_RegisteredImpl _value, + $Res Function(_$NdHostEvent_RegisteredImpl) _then) + : super(_value, _then); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$NdHostEvent_RegisteredImpl extends NdHostEvent_Registered { + const _$NdHostEvent_RegisteredImpl() : super._(); + + @override + String toString() { + return 'NdHostEvent.registered()'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdHostEvent_RegisteredImpl); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() registered, + required TResult Function(bool e2E) viewerConnected, + required TResult Function() viewerLeft, + required TResult Function(int x, int y, int buttons, double wheel) mouse, + required TResult Function(String code, bool down) key, + required TResult Function() keyframeRequested, + required TResult Function(int fps, int bitrate, double scale) quality, + required TResult Function() reconnecting, + required TResult Function(String reason) ended, + }) { + return registered(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? registered, + TResult? Function(bool e2E)? viewerConnected, + TResult? Function()? viewerLeft, + TResult? Function(int x, int y, int buttons, double wheel)? mouse, + TResult? Function(String code, bool down)? key, + TResult? Function()? keyframeRequested, + TResult? Function(int fps, int bitrate, double scale)? quality, + TResult? Function()? reconnecting, + TResult? Function(String reason)? ended, + }) { + return registered?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? registered, + TResult Function(bool e2E)? viewerConnected, + TResult Function()? viewerLeft, + TResult Function(int x, int y, int buttons, double wheel)? mouse, + TResult Function(String code, bool down)? key, + TResult Function()? keyframeRequested, + TResult Function(int fps, int bitrate, double scale)? quality, + TResult Function()? reconnecting, + TResult Function(String reason)? ended, + required TResult orElse(), + }) { + if (registered != null) { + return registered(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdHostEvent_Registered value) registered, + required TResult Function(NdHostEvent_ViewerConnected value) + viewerConnected, + required TResult Function(NdHostEvent_ViewerLeft value) viewerLeft, + required TResult Function(NdHostEvent_Mouse value) mouse, + required TResult Function(NdHostEvent_Key value) key, + required TResult Function(NdHostEvent_KeyframeRequested value) + keyframeRequested, + required TResult Function(NdHostEvent_Quality value) quality, + required TResult Function(NdHostEvent_Reconnecting value) reconnecting, + required TResult Function(NdHostEvent_Ended value) ended, + }) { + return registered(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdHostEvent_Registered value)? registered, + TResult? Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult? Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult? Function(NdHostEvent_Mouse value)? mouse, + TResult? Function(NdHostEvent_Key value)? key, + TResult? Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult? Function(NdHostEvent_Quality value)? quality, + TResult? Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult? Function(NdHostEvent_Ended value)? ended, + }) { + return registered?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdHostEvent_Registered value)? registered, + TResult Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult Function(NdHostEvent_Mouse value)? mouse, + TResult Function(NdHostEvent_Key value)? key, + TResult Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult Function(NdHostEvent_Quality value)? quality, + TResult Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult Function(NdHostEvent_Ended value)? ended, + required TResult orElse(), + }) { + if (registered != null) { + return registered(this); + } + return orElse(); + } +} + +abstract class NdHostEvent_Registered extends NdHostEvent { + const factory NdHostEvent_Registered() = _$NdHostEvent_RegisteredImpl; + const NdHostEvent_Registered._() : super._(); +} + +/// @nodoc +abstract class _$$NdHostEvent_ViewerConnectedImplCopyWith<$Res> { + factory _$$NdHostEvent_ViewerConnectedImplCopyWith( + _$NdHostEvent_ViewerConnectedImpl value, + $Res Function(_$NdHostEvent_ViewerConnectedImpl) then) = + __$$NdHostEvent_ViewerConnectedImplCopyWithImpl<$Res>; + @useResult + $Res call({bool e2E}); +} + +/// @nodoc +class __$$NdHostEvent_ViewerConnectedImplCopyWithImpl<$Res> + extends _$NdHostEventCopyWithImpl<$Res, _$NdHostEvent_ViewerConnectedImpl> + implements _$$NdHostEvent_ViewerConnectedImplCopyWith<$Res> { + __$$NdHostEvent_ViewerConnectedImplCopyWithImpl( + _$NdHostEvent_ViewerConnectedImpl _value, + $Res Function(_$NdHostEvent_ViewerConnectedImpl) _then) + : super(_value, _then); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? e2E = null, + }) { + return _then(_$NdHostEvent_ViewerConnectedImpl( + e2E: null == e2E + ? _value.e2E + : e2E // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc + +class _$NdHostEvent_ViewerConnectedImpl extends NdHostEvent_ViewerConnected { + const _$NdHostEvent_ViewerConnectedImpl({required this.e2E}) : super._(); + + @override + final bool e2E; + + @override + String toString() { + return 'NdHostEvent.viewerConnected(e2E: $e2E)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdHostEvent_ViewerConnectedImpl && + (identical(other.e2E, e2E) || other.e2E == e2E)); + } + + @override + int get hashCode => Object.hash(runtimeType, e2E); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$NdHostEvent_ViewerConnectedImplCopyWith<_$NdHostEvent_ViewerConnectedImpl> + get copyWith => __$$NdHostEvent_ViewerConnectedImplCopyWithImpl< + _$NdHostEvent_ViewerConnectedImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() registered, + required TResult Function(bool e2E) viewerConnected, + required TResult Function() viewerLeft, + required TResult Function(int x, int y, int buttons, double wheel) mouse, + required TResult Function(String code, bool down) key, + required TResult Function() keyframeRequested, + required TResult Function(int fps, int bitrate, double scale) quality, + required TResult Function() reconnecting, + required TResult Function(String reason) ended, + }) { + return viewerConnected(e2E); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? registered, + TResult? Function(bool e2E)? viewerConnected, + TResult? Function()? viewerLeft, + TResult? Function(int x, int y, int buttons, double wheel)? mouse, + TResult? Function(String code, bool down)? key, + TResult? Function()? keyframeRequested, + TResult? Function(int fps, int bitrate, double scale)? quality, + TResult? Function()? reconnecting, + TResult? Function(String reason)? ended, + }) { + return viewerConnected?.call(e2E); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? registered, + TResult Function(bool e2E)? viewerConnected, + TResult Function()? viewerLeft, + TResult Function(int x, int y, int buttons, double wheel)? mouse, + TResult Function(String code, bool down)? key, + TResult Function()? keyframeRequested, + TResult Function(int fps, int bitrate, double scale)? quality, + TResult Function()? reconnecting, + TResult Function(String reason)? ended, + required TResult orElse(), + }) { + if (viewerConnected != null) { + return viewerConnected(e2E); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdHostEvent_Registered value) registered, + required TResult Function(NdHostEvent_ViewerConnected value) + viewerConnected, + required TResult Function(NdHostEvent_ViewerLeft value) viewerLeft, + required TResult Function(NdHostEvent_Mouse value) mouse, + required TResult Function(NdHostEvent_Key value) key, + required TResult Function(NdHostEvent_KeyframeRequested value) + keyframeRequested, + required TResult Function(NdHostEvent_Quality value) quality, + required TResult Function(NdHostEvent_Reconnecting value) reconnecting, + required TResult Function(NdHostEvent_Ended value) ended, + }) { + return viewerConnected(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdHostEvent_Registered value)? registered, + TResult? Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult? Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult? Function(NdHostEvent_Mouse value)? mouse, + TResult? Function(NdHostEvent_Key value)? key, + TResult? Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult? Function(NdHostEvent_Quality value)? quality, + TResult? Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult? Function(NdHostEvent_Ended value)? ended, + }) { + return viewerConnected?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdHostEvent_Registered value)? registered, + TResult Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult Function(NdHostEvent_Mouse value)? mouse, + TResult Function(NdHostEvent_Key value)? key, + TResult Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult Function(NdHostEvent_Quality value)? quality, + TResult Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult Function(NdHostEvent_Ended value)? ended, + required TResult orElse(), + }) { + if (viewerConnected != null) { + return viewerConnected(this); + } + return orElse(); + } +} + +abstract class NdHostEvent_ViewerConnected extends NdHostEvent { + const factory NdHostEvent_ViewerConnected({required final bool e2E}) = + _$NdHostEvent_ViewerConnectedImpl; + const NdHostEvent_ViewerConnected._() : super._(); + + bool get e2E; + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$NdHostEvent_ViewerConnectedImplCopyWith<_$NdHostEvent_ViewerConnectedImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$NdHostEvent_ViewerLeftImplCopyWith<$Res> { + factory _$$NdHostEvent_ViewerLeftImplCopyWith( + _$NdHostEvent_ViewerLeftImpl value, + $Res Function(_$NdHostEvent_ViewerLeftImpl) then) = + __$$NdHostEvent_ViewerLeftImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$NdHostEvent_ViewerLeftImplCopyWithImpl<$Res> + extends _$NdHostEventCopyWithImpl<$Res, _$NdHostEvent_ViewerLeftImpl> + implements _$$NdHostEvent_ViewerLeftImplCopyWith<$Res> { + __$$NdHostEvent_ViewerLeftImplCopyWithImpl( + _$NdHostEvent_ViewerLeftImpl _value, + $Res Function(_$NdHostEvent_ViewerLeftImpl) _then) + : super(_value, _then); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$NdHostEvent_ViewerLeftImpl extends NdHostEvent_ViewerLeft { + const _$NdHostEvent_ViewerLeftImpl() : super._(); + + @override + String toString() { + return 'NdHostEvent.viewerLeft()'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdHostEvent_ViewerLeftImpl); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() registered, + required TResult Function(bool e2E) viewerConnected, + required TResult Function() viewerLeft, + required TResult Function(int x, int y, int buttons, double wheel) mouse, + required TResult Function(String code, bool down) key, + required TResult Function() keyframeRequested, + required TResult Function(int fps, int bitrate, double scale) quality, + required TResult Function() reconnecting, + required TResult Function(String reason) ended, + }) { + return viewerLeft(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? registered, + TResult? Function(bool e2E)? viewerConnected, + TResult? Function()? viewerLeft, + TResult? Function(int x, int y, int buttons, double wheel)? mouse, + TResult? Function(String code, bool down)? key, + TResult? Function()? keyframeRequested, + TResult? Function(int fps, int bitrate, double scale)? quality, + TResult? Function()? reconnecting, + TResult? Function(String reason)? ended, + }) { + return viewerLeft?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? registered, + TResult Function(bool e2E)? viewerConnected, + TResult Function()? viewerLeft, + TResult Function(int x, int y, int buttons, double wheel)? mouse, + TResult Function(String code, bool down)? key, + TResult Function()? keyframeRequested, + TResult Function(int fps, int bitrate, double scale)? quality, + TResult Function()? reconnecting, + TResult Function(String reason)? ended, + required TResult orElse(), + }) { + if (viewerLeft != null) { + return viewerLeft(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdHostEvent_Registered value) registered, + required TResult Function(NdHostEvent_ViewerConnected value) + viewerConnected, + required TResult Function(NdHostEvent_ViewerLeft value) viewerLeft, + required TResult Function(NdHostEvent_Mouse value) mouse, + required TResult Function(NdHostEvent_Key value) key, + required TResult Function(NdHostEvent_KeyframeRequested value) + keyframeRequested, + required TResult Function(NdHostEvent_Quality value) quality, + required TResult Function(NdHostEvent_Reconnecting value) reconnecting, + required TResult Function(NdHostEvent_Ended value) ended, + }) { + return viewerLeft(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdHostEvent_Registered value)? registered, + TResult? Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult? Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult? Function(NdHostEvent_Mouse value)? mouse, + TResult? Function(NdHostEvent_Key value)? key, + TResult? Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult? Function(NdHostEvent_Quality value)? quality, + TResult? Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult? Function(NdHostEvent_Ended value)? ended, + }) { + return viewerLeft?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdHostEvent_Registered value)? registered, + TResult Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult Function(NdHostEvent_Mouse value)? mouse, + TResult Function(NdHostEvent_Key value)? key, + TResult Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult Function(NdHostEvent_Quality value)? quality, + TResult Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult Function(NdHostEvent_Ended value)? ended, + required TResult orElse(), + }) { + if (viewerLeft != null) { + return viewerLeft(this); + } + return orElse(); + } +} + +abstract class NdHostEvent_ViewerLeft extends NdHostEvent { + const factory NdHostEvent_ViewerLeft() = _$NdHostEvent_ViewerLeftImpl; + const NdHostEvent_ViewerLeft._() : super._(); +} + +/// @nodoc +abstract class _$$NdHostEvent_MouseImplCopyWith<$Res> { + factory _$$NdHostEvent_MouseImplCopyWith(_$NdHostEvent_MouseImpl value, + $Res Function(_$NdHostEvent_MouseImpl) then) = + __$$NdHostEvent_MouseImplCopyWithImpl<$Res>; + @useResult + $Res call({int x, int y, int buttons, double wheel}); +} + +/// @nodoc +class __$$NdHostEvent_MouseImplCopyWithImpl<$Res> + extends _$NdHostEventCopyWithImpl<$Res, _$NdHostEvent_MouseImpl> + implements _$$NdHostEvent_MouseImplCopyWith<$Res> { + __$$NdHostEvent_MouseImplCopyWithImpl(_$NdHostEvent_MouseImpl _value, + $Res Function(_$NdHostEvent_MouseImpl) _then) + : super(_value, _then); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? x = null, + Object? y = null, + Object? buttons = null, + Object? wheel = null, + }) { + return _then(_$NdHostEvent_MouseImpl( + x: null == x + ? _value.x + : x // ignore: cast_nullable_to_non_nullable + as int, + y: null == y + ? _value.y + : y // ignore: cast_nullable_to_non_nullable + as int, + buttons: null == buttons + ? _value.buttons + : buttons // ignore: cast_nullable_to_non_nullable + as int, + wheel: null == wheel + ? _value.wheel + : wheel // ignore: cast_nullable_to_non_nullable + as double, + )); + } +} + +/// @nodoc + +class _$NdHostEvent_MouseImpl extends NdHostEvent_Mouse { + const _$NdHostEvent_MouseImpl( + {required this.x, + required this.y, + required this.buttons, + required this.wheel}) + : super._(); + + @override + final int x; + @override + final int y; + @override + final int buttons; + @override + final double wheel; + + @override + String toString() { + return 'NdHostEvent.mouse(x: $x, y: $y, buttons: $buttons, wheel: $wheel)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdHostEvent_MouseImpl && + (identical(other.x, x) || other.x == x) && + (identical(other.y, y) || other.y == y) && + (identical(other.buttons, buttons) || other.buttons == buttons) && + (identical(other.wheel, wheel) || other.wheel == wheel)); + } + + @override + int get hashCode => Object.hash(runtimeType, x, y, buttons, wheel); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$NdHostEvent_MouseImplCopyWith<_$NdHostEvent_MouseImpl> get copyWith => + __$$NdHostEvent_MouseImplCopyWithImpl<_$NdHostEvent_MouseImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() registered, + required TResult Function(bool e2E) viewerConnected, + required TResult Function() viewerLeft, + required TResult Function(int x, int y, int buttons, double wheel) mouse, + required TResult Function(String code, bool down) key, + required TResult Function() keyframeRequested, + required TResult Function(int fps, int bitrate, double scale) quality, + required TResult Function() reconnecting, + required TResult Function(String reason) ended, + }) { + return mouse(x, y, buttons, wheel); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? registered, + TResult? Function(bool e2E)? viewerConnected, + TResult? Function()? viewerLeft, + TResult? Function(int x, int y, int buttons, double wheel)? mouse, + TResult? Function(String code, bool down)? key, + TResult? Function()? keyframeRequested, + TResult? Function(int fps, int bitrate, double scale)? quality, + TResult? Function()? reconnecting, + TResult? Function(String reason)? ended, + }) { + return mouse?.call(x, y, buttons, wheel); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? registered, + TResult Function(bool e2E)? viewerConnected, + TResult Function()? viewerLeft, + TResult Function(int x, int y, int buttons, double wheel)? mouse, + TResult Function(String code, bool down)? key, + TResult Function()? keyframeRequested, + TResult Function(int fps, int bitrate, double scale)? quality, + TResult Function()? reconnecting, + TResult Function(String reason)? ended, + required TResult orElse(), + }) { + if (mouse != null) { + return mouse(x, y, buttons, wheel); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdHostEvent_Registered value) registered, + required TResult Function(NdHostEvent_ViewerConnected value) + viewerConnected, + required TResult Function(NdHostEvent_ViewerLeft value) viewerLeft, + required TResult Function(NdHostEvent_Mouse value) mouse, + required TResult Function(NdHostEvent_Key value) key, + required TResult Function(NdHostEvent_KeyframeRequested value) + keyframeRequested, + required TResult Function(NdHostEvent_Quality value) quality, + required TResult Function(NdHostEvent_Reconnecting value) reconnecting, + required TResult Function(NdHostEvent_Ended value) ended, + }) { + return mouse(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdHostEvent_Registered value)? registered, + TResult? Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult? Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult? Function(NdHostEvent_Mouse value)? mouse, + TResult? Function(NdHostEvent_Key value)? key, + TResult? Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult? Function(NdHostEvent_Quality value)? quality, + TResult? Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult? Function(NdHostEvent_Ended value)? ended, + }) { + return mouse?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdHostEvent_Registered value)? registered, + TResult Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult Function(NdHostEvent_Mouse value)? mouse, + TResult Function(NdHostEvent_Key value)? key, + TResult Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult Function(NdHostEvent_Quality value)? quality, + TResult Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult Function(NdHostEvent_Ended value)? ended, + required TResult orElse(), + }) { + if (mouse != null) { + return mouse(this); + } + return orElse(); + } +} + +abstract class NdHostEvent_Mouse extends NdHostEvent { + const factory NdHostEvent_Mouse( + {required final int x, + required final int y, + required final int buttons, + required final double wheel}) = _$NdHostEvent_MouseImpl; + const NdHostEvent_Mouse._() : super._(); + + int get x; + int get y; + int get buttons; + double get wheel; + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$NdHostEvent_MouseImplCopyWith<_$NdHostEvent_MouseImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$NdHostEvent_KeyImplCopyWith<$Res> { + factory _$$NdHostEvent_KeyImplCopyWith(_$NdHostEvent_KeyImpl value, + $Res Function(_$NdHostEvent_KeyImpl) then) = + __$$NdHostEvent_KeyImplCopyWithImpl<$Res>; + @useResult + $Res call({String code, bool down}); +} + +/// @nodoc +class __$$NdHostEvent_KeyImplCopyWithImpl<$Res> + extends _$NdHostEventCopyWithImpl<$Res, _$NdHostEvent_KeyImpl> + implements _$$NdHostEvent_KeyImplCopyWith<$Res> { + __$$NdHostEvent_KeyImplCopyWithImpl( + _$NdHostEvent_KeyImpl _value, $Res Function(_$NdHostEvent_KeyImpl) _then) + : super(_value, _then); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? code = null, + Object? down = null, + }) { + return _then(_$NdHostEvent_KeyImpl( + code: null == code + ? _value.code + : code // ignore: cast_nullable_to_non_nullable + as String, + down: null == down + ? _value.down + : down // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc + +class _$NdHostEvent_KeyImpl extends NdHostEvent_Key { + const _$NdHostEvent_KeyImpl({required this.code, required this.down}) + : super._(); + + @override + final String code; + @override + final bool down; + + @override + String toString() { + return 'NdHostEvent.key(code: $code, down: $down)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdHostEvent_KeyImpl && + (identical(other.code, code) || other.code == code) && + (identical(other.down, down) || other.down == down)); + } + + @override + int get hashCode => Object.hash(runtimeType, code, down); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$NdHostEvent_KeyImplCopyWith<_$NdHostEvent_KeyImpl> get copyWith => + __$$NdHostEvent_KeyImplCopyWithImpl<_$NdHostEvent_KeyImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() registered, + required TResult Function(bool e2E) viewerConnected, + required TResult Function() viewerLeft, + required TResult Function(int x, int y, int buttons, double wheel) mouse, + required TResult Function(String code, bool down) key, + required TResult Function() keyframeRequested, + required TResult Function(int fps, int bitrate, double scale) quality, + required TResult Function() reconnecting, + required TResult Function(String reason) ended, + }) { + return key(code, down); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? registered, + TResult? Function(bool e2E)? viewerConnected, + TResult? Function()? viewerLeft, + TResult? Function(int x, int y, int buttons, double wheel)? mouse, + TResult? Function(String code, bool down)? key, + TResult? Function()? keyframeRequested, + TResult? Function(int fps, int bitrate, double scale)? quality, + TResult? Function()? reconnecting, + TResult? Function(String reason)? ended, + }) { + return key?.call(code, down); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? registered, + TResult Function(bool e2E)? viewerConnected, + TResult Function()? viewerLeft, + TResult Function(int x, int y, int buttons, double wheel)? mouse, + TResult Function(String code, bool down)? key, + TResult Function()? keyframeRequested, + TResult Function(int fps, int bitrate, double scale)? quality, + TResult Function()? reconnecting, + TResult Function(String reason)? ended, + required TResult orElse(), + }) { + if (key != null) { + return key(code, down); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdHostEvent_Registered value) registered, + required TResult Function(NdHostEvent_ViewerConnected value) + viewerConnected, + required TResult Function(NdHostEvent_ViewerLeft value) viewerLeft, + required TResult Function(NdHostEvent_Mouse value) mouse, + required TResult Function(NdHostEvent_Key value) key, + required TResult Function(NdHostEvent_KeyframeRequested value) + keyframeRequested, + required TResult Function(NdHostEvent_Quality value) quality, + required TResult Function(NdHostEvent_Reconnecting value) reconnecting, + required TResult Function(NdHostEvent_Ended value) ended, + }) { + return key(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdHostEvent_Registered value)? registered, + TResult? Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult? Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult? Function(NdHostEvent_Mouse value)? mouse, + TResult? Function(NdHostEvent_Key value)? key, + TResult? Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult? Function(NdHostEvent_Quality value)? quality, + TResult? Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult? Function(NdHostEvent_Ended value)? ended, + }) { + return key?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdHostEvent_Registered value)? registered, + TResult Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult Function(NdHostEvent_Mouse value)? mouse, + TResult Function(NdHostEvent_Key value)? key, + TResult Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult Function(NdHostEvent_Quality value)? quality, + TResult Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult Function(NdHostEvent_Ended value)? ended, + required TResult orElse(), + }) { + if (key != null) { + return key(this); + } + return orElse(); + } +} + +abstract class NdHostEvent_Key extends NdHostEvent { + const factory NdHostEvent_Key( + {required final String code, + required final bool down}) = _$NdHostEvent_KeyImpl; + const NdHostEvent_Key._() : super._(); + + String get code; + bool get down; + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$NdHostEvent_KeyImplCopyWith<_$NdHostEvent_KeyImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$NdHostEvent_KeyframeRequestedImplCopyWith<$Res> { + factory _$$NdHostEvent_KeyframeRequestedImplCopyWith( + _$NdHostEvent_KeyframeRequestedImpl value, + $Res Function(_$NdHostEvent_KeyframeRequestedImpl) then) = + __$$NdHostEvent_KeyframeRequestedImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$NdHostEvent_KeyframeRequestedImplCopyWithImpl<$Res> + extends _$NdHostEventCopyWithImpl<$Res, _$NdHostEvent_KeyframeRequestedImpl> + implements _$$NdHostEvent_KeyframeRequestedImplCopyWith<$Res> { + __$$NdHostEvent_KeyframeRequestedImplCopyWithImpl( + _$NdHostEvent_KeyframeRequestedImpl _value, + $Res Function(_$NdHostEvent_KeyframeRequestedImpl) _then) + : super(_value, _then); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$NdHostEvent_KeyframeRequestedImpl + extends NdHostEvent_KeyframeRequested { + const _$NdHostEvent_KeyframeRequestedImpl() : super._(); + + @override + String toString() { + return 'NdHostEvent.keyframeRequested()'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdHostEvent_KeyframeRequestedImpl); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() registered, + required TResult Function(bool e2E) viewerConnected, + required TResult Function() viewerLeft, + required TResult Function(int x, int y, int buttons, double wheel) mouse, + required TResult Function(String code, bool down) key, + required TResult Function() keyframeRequested, + required TResult Function(int fps, int bitrate, double scale) quality, + required TResult Function() reconnecting, + required TResult Function(String reason) ended, + }) { + return keyframeRequested(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? registered, + TResult? Function(bool e2E)? viewerConnected, + TResult? Function()? viewerLeft, + TResult? Function(int x, int y, int buttons, double wheel)? mouse, + TResult? Function(String code, bool down)? key, + TResult? Function()? keyframeRequested, + TResult? Function(int fps, int bitrate, double scale)? quality, + TResult? Function()? reconnecting, + TResult? Function(String reason)? ended, + }) { + return keyframeRequested?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? registered, + TResult Function(bool e2E)? viewerConnected, + TResult Function()? viewerLeft, + TResult Function(int x, int y, int buttons, double wheel)? mouse, + TResult Function(String code, bool down)? key, + TResult Function()? keyframeRequested, + TResult Function(int fps, int bitrate, double scale)? quality, + TResult Function()? reconnecting, + TResult Function(String reason)? ended, + required TResult orElse(), + }) { + if (keyframeRequested != null) { + return keyframeRequested(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdHostEvent_Registered value) registered, + required TResult Function(NdHostEvent_ViewerConnected value) + viewerConnected, + required TResult Function(NdHostEvent_ViewerLeft value) viewerLeft, + required TResult Function(NdHostEvent_Mouse value) mouse, + required TResult Function(NdHostEvent_Key value) key, + required TResult Function(NdHostEvent_KeyframeRequested value) + keyframeRequested, + required TResult Function(NdHostEvent_Quality value) quality, + required TResult Function(NdHostEvent_Reconnecting value) reconnecting, + required TResult Function(NdHostEvent_Ended value) ended, + }) { + return keyframeRequested(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdHostEvent_Registered value)? registered, + TResult? Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult? Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult? Function(NdHostEvent_Mouse value)? mouse, + TResult? Function(NdHostEvent_Key value)? key, + TResult? Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult? Function(NdHostEvent_Quality value)? quality, + TResult? Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult? Function(NdHostEvent_Ended value)? ended, + }) { + return keyframeRequested?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdHostEvent_Registered value)? registered, + TResult Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult Function(NdHostEvent_Mouse value)? mouse, + TResult Function(NdHostEvent_Key value)? key, + TResult Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult Function(NdHostEvent_Quality value)? quality, + TResult Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult Function(NdHostEvent_Ended value)? ended, + required TResult orElse(), + }) { + if (keyframeRequested != null) { + return keyframeRequested(this); + } + return orElse(); + } +} + +abstract class NdHostEvent_KeyframeRequested extends NdHostEvent { + const factory NdHostEvent_KeyframeRequested() = + _$NdHostEvent_KeyframeRequestedImpl; + const NdHostEvent_KeyframeRequested._() : super._(); +} + +/// @nodoc +abstract class _$$NdHostEvent_QualityImplCopyWith<$Res> { + factory _$$NdHostEvent_QualityImplCopyWith(_$NdHostEvent_QualityImpl value, + $Res Function(_$NdHostEvent_QualityImpl) then) = + __$$NdHostEvent_QualityImplCopyWithImpl<$Res>; + @useResult + $Res call({int fps, int bitrate, double scale}); +} + +/// @nodoc +class __$$NdHostEvent_QualityImplCopyWithImpl<$Res> + extends _$NdHostEventCopyWithImpl<$Res, _$NdHostEvent_QualityImpl> + implements _$$NdHostEvent_QualityImplCopyWith<$Res> { + __$$NdHostEvent_QualityImplCopyWithImpl(_$NdHostEvent_QualityImpl _value, + $Res Function(_$NdHostEvent_QualityImpl) _then) + : super(_value, _then); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? fps = null, + Object? bitrate = null, + Object? scale = null, + }) { + return _then(_$NdHostEvent_QualityImpl( + fps: null == fps + ? _value.fps + : fps // ignore: cast_nullable_to_non_nullable + as int, + bitrate: null == bitrate + ? _value.bitrate + : bitrate // ignore: cast_nullable_to_non_nullable + as int, + scale: null == scale + ? _value.scale + : scale // ignore: cast_nullable_to_non_nullable + as double, + )); + } +} + +/// @nodoc + +class _$NdHostEvent_QualityImpl extends NdHostEvent_Quality { + const _$NdHostEvent_QualityImpl( + {required this.fps, required this.bitrate, required this.scale}) + : super._(); + + @override + final int fps; + @override + final int bitrate; + @override + final double scale; + + @override + String toString() { + return 'NdHostEvent.quality(fps: $fps, bitrate: $bitrate, scale: $scale)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdHostEvent_QualityImpl && + (identical(other.fps, fps) || other.fps == fps) && + (identical(other.bitrate, bitrate) || other.bitrate == bitrate) && + (identical(other.scale, scale) || other.scale == scale)); + } + + @override + int get hashCode => Object.hash(runtimeType, fps, bitrate, scale); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$NdHostEvent_QualityImplCopyWith<_$NdHostEvent_QualityImpl> get copyWith => + __$$NdHostEvent_QualityImplCopyWithImpl<_$NdHostEvent_QualityImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() registered, + required TResult Function(bool e2E) viewerConnected, + required TResult Function() viewerLeft, + required TResult Function(int x, int y, int buttons, double wheel) mouse, + required TResult Function(String code, bool down) key, + required TResult Function() keyframeRequested, + required TResult Function(int fps, int bitrate, double scale) quality, + required TResult Function() reconnecting, + required TResult Function(String reason) ended, + }) { + return quality(fps, bitrate, scale); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? registered, + TResult? Function(bool e2E)? viewerConnected, + TResult? Function()? viewerLeft, + TResult? Function(int x, int y, int buttons, double wheel)? mouse, + TResult? Function(String code, bool down)? key, + TResult? Function()? keyframeRequested, + TResult? Function(int fps, int bitrate, double scale)? quality, + TResult? Function()? reconnecting, + TResult? Function(String reason)? ended, + }) { + return quality?.call(fps, bitrate, scale); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? registered, + TResult Function(bool e2E)? viewerConnected, + TResult Function()? viewerLeft, + TResult Function(int x, int y, int buttons, double wheel)? mouse, + TResult Function(String code, bool down)? key, + TResult Function()? keyframeRequested, + TResult Function(int fps, int bitrate, double scale)? quality, + TResult Function()? reconnecting, + TResult Function(String reason)? ended, + required TResult orElse(), + }) { + if (quality != null) { + return quality(fps, bitrate, scale); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdHostEvent_Registered value) registered, + required TResult Function(NdHostEvent_ViewerConnected value) + viewerConnected, + required TResult Function(NdHostEvent_ViewerLeft value) viewerLeft, + required TResult Function(NdHostEvent_Mouse value) mouse, + required TResult Function(NdHostEvent_Key value) key, + required TResult Function(NdHostEvent_KeyframeRequested value) + keyframeRequested, + required TResult Function(NdHostEvent_Quality value) quality, + required TResult Function(NdHostEvent_Reconnecting value) reconnecting, + required TResult Function(NdHostEvent_Ended value) ended, + }) { + return quality(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdHostEvent_Registered value)? registered, + TResult? Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult? Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult? Function(NdHostEvent_Mouse value)? mouse, + TResult? Function(NdHostEvent_Key value)? key, + TResult? Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult? Function(NdHostEvent_Quality value)? quality, + TResult? Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult? Function(NdHostEvent_Ended value)? ended, + }) { + return quality?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdHostEvent_Registered value)? registered, + TResult Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult Function(NdHostEvent_Mouse value)? mouse, + TResult Function(NdHostEvent_Key value)? key, + TResult Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult Function(NdHostEvent_Quality value)? quality, + TResult Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult Function(NdHostEvent_Ended value)? ended, + required TResult orElse(), + }) { + if (quality != null) { + return quality(this); + } + return orElse(); + } +} + +abstract class NdHostEvent_Quality extends NdHostEvent { + const factory NdHostEvent_Quality( + {required final int fps, + required final int bitrate, + required final double scale}) = _$NdHostEvent_QualityImpl; + const NdHostEvent_Quality._() : super._(); + + int get fps; + int get bitrate; + double get scale; + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$NdHostEvent_QualityImplCopyWith<_$NdHostEvent_QualityImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$NdHostEvent_ReconnectingImplCopyWith<$Res> { + factory _$$NdHostEvent_ReconnectingImplCopyWith( + _$NdHostEvent_ReconnectingImpl value, + $Res Function(_$NdHostEvent_ReconnectingImpl) then) = + __$$NdHostEvent_ReconnectingImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$NdHostEvent_ReconnectingImplCopyWithImpl<$Res> + extends _$NdHostEventCopyWithImpl<$Res, _$NdHostEvent_ReconnectingImpl> + implements _$$NdHostEvent_ReconnectingImplCopyWith<$Res> { + __$$NdHostEvent_ReconnectingImplCopyWithImpl( + _$NdHostEvent_ReconnectingImpl _value, + $Res Function(_$NdHostEvent_ReconnectingImpl) _then) + : super(_value, _then); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$NdHostEvent_ReconnectingImpl extends NdHostEvent_Reconnecting { + const _$NdHostEvent_ReconnectingImpl() : super._(); + + @override + String toString() { + return 'NdHostEvent.reconnecting()'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdHostEvent_ReconnectingImpl); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() registered, + required TResult Function(bool e2E) viewerConnected, + required TResult Function() viewerLeft, + required TResult Function(int x, int y, int buttons, double wheel) mouse, + required TResult Function(String code, bool down) key, + required TResult Function() keyframeRequested, + required TResult Function(int fps, int bitrate, double scale) quality, + required TResult Function() reconnecting, + required TResult Function(String reason) ended, + }) { + return reconnecting(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? registered, + TResult? Function(bool e2E)? viewerConnected, + TResult? Function()? viewerLeft, + TResult? Function(int x, int y, int buttons, double wheel)? mouse, + TResult? Function(String code, bool down)? key, + TResult? Function()? keyframeRequested, + TResult? Function(int fps, int bitrate, double scale)? quality, + TResult? Function()? reconnecting, + TResult? Function(String reason)? ended, + }) { + return reconnecting?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? registered, + TResult Function(bool e2E)? viewerConnected, + TResult Function()? viewerLeft, + TResult Function(int x, int y, int buttons, double wheel)? mouse, + TResult Function(String code, bool down)? key, + TResult Function()? keyframeRequested, + TResult Function(int fps, int bitrate, double scale)? quality, + TResult Function()? reconnecting, + TResult Function(String reason)? ended, + required TResult orElse(), + }) { + if (reconnecting != null) { + return reconnecting(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdHostEvent_Registered value) registered, + required TResult Function(NdHostEvent_ViewerConnected value) + viewerConnected, + required TResult Function(NdHostEvent_ViewerLeft value) viewerLeft, + required TResult Function(NdHostEvent_Mouse value) mouse, + required TResult Function(NdHostEvent_Key value) key, + required TResult Function(NdHostEvent_KeyframeRequested value) + keyframeRequested, + required TResult Function(NdHostEvent_Quality value) quality, + required TResult Function(NdHostEvent_Reconnecting value) reconnecting, + required TResult Function(NdHostEvent_Ended value) ended, + }) { + return reconnecting(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdHostEvent_Registered value)? registered, + TResult? Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult? Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult? Function(NdHostEvent_Mouse value)? mouse, + TResult? Function(NdHostEvent_Key value)? key, + TResult? Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult? Function(NdHostEvent_Quality value)? quality, + TResult? Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult? Function(NdHostEvent_Ended value)? ended, + }) { + return reconnecting?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdHostEvent_Registered value)? registered, + TResult Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult Function(NdHostEvent_Mouse value)? mouse, + TResult Function(NdHostEvent_Key value)? key, + TResult Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult Function(NdHostEvent_Quality value)? quality, + TResult Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult Function(NdHostEvent_Ended value)? ended, + required TResult orElse(), + }) { + if (reconnecting != null) { + return reconnecting(this); + } + return orElse(); + } +} + +abstract class NdHostEvent_Reconnecting extends NdHostEvent { + const factory NdHostEvent_Reconnecting() = _$NdHostEvent_ReconnectingImpl; + const NdHostEvent_Reconnecting._() : super._(); +} + +/// @nodoc +abstract class _$$NdHostEvent_EndedImplCopyWith<$Res> { + factory _$$NdHostEvent_EndedImplCopyWith(_$NdHostEvent_EndedImpl value, + $Res Function(_$NdHostEvent_EndedImpl) then) = + __$$NdHostEvent_EndedImplCopyWithImpl<$Res>; + @useResult + $Res call({String reason}); +} + +/// @nodoc +class __$$NdHostEvent_EndedImplCopyWithImpl<$Res> + extends _$NdHostEventCopyWithImpl<$Res, _$NdHostEvent_EndedImpl> + implements _$$NdHostEvent_EndedImplCopyWith<$Res> { + __$$NdHostEvent_EndedImplCopyWithImpl(_$NdHostEvent_EndedImpl _value, + $Res Function(_$NdHostEvent_EndedImpl) _then) + : super(_value, _then); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? reason = null, + }) { + return _then(_$NdHostEvent_EndedImpl( + reason: null == reason + ? _value.reason + : reason // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc + +class _$NdHostEvent_EndedImpl extends NdHostEvent_Ended { + const _$NdHostEvent_EndedImpl({required this.reason}) : super._(); + + @override + final String reason; + + @override + String toString() { + return 'NdHostEvent.ended(reason: $reason)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdHostEvent_EndedImpl && + (identical(other.reason, reason) || other.reason == reason)); + } + + @override + int get hashCode => Object.hash(runtimeType, reason); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$NdHostEvent_EndedImplCopyWith<_$NdHostEvent_EndedImpl> get copyWith => + __$$NdHostEvent_EndedImplCopyWithImpl<_$NdHostEvent_EndedImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() registered, + required TResult Function(bool e2E) viewerConnected, + required TResult Function() viewerLeft, + required TResult Function(int x, int y, int buttons, double wheel) mouse, + required TResult Function(String code, bool down) key, + required TResult Function() keyframeRequested, + required TResult Function(int fps, int bitrate, double scale) quality, + required TResult Function() reconnecting, + required TResult Function(String reason) ended, + }) { + return ended(reason); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? registered, + TResult? Function(bool e2E)? viewerConnected, + TResult? Function()? viewerLeft, + TResult? Function(int x, int y, int buttons, double wheel)? mouse, + TResult? Function(String code, bool down)? key, + TResult? Function()? keyframeRequested, + TResult? Function(int fps, int bitrate, double scale)? quality, + TResult? Function()? reconnecting, + TResult? Function(String reason)? ended, + }) { + return ended?.call(reason); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? registered, + TResult Function(bool e2E)? viewerConnected, + TResult Function()? viewerLeft, + TResult Function(int x, int y, int buttons, double wheel)? mouse, + TResult Function(String code, bool down)? key, + TResult Function()? keyframeRequested, + TResult Function(int fps, int bitrate, double scale)? quality, + TResult Function()? reconnecting, + TResult Function(String reason)? ended, + required TResult orElse(), + }) { + if (ended != null) { + return ended(reason); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdHostEvent_Registered value) registered, + required TResult Function(NdHostEvent_ViewerConnected value) + viewerConnected, + required TResult Function(NdHostEvent_ViewerLeft value) viewerLeft, + required TResult Function(NdHostEvent_Mouse value) mouse, + required TResult Function(NdHostEvent_Key value) key, + required TResult Function(NdHostEvent_KeyframeRequested value) + keyframeRequested, + required TResult Function(NdHostEvent_Quality value) quality, + required TResult Function(NdHostEvent_Reconnecting value) reconnecting, + required TResult Function(NdHostEvent_Ended value) ended, + }) { + return ended(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdHostEvent_Registered value)? registered, + TResult? Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult? Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult? Function(NdHostEvent_Mouse value)? mouse, + TResult? Function(NdHostEvent_Key value)? key, + TResult? Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult? Function(NdHostEvent_Quality value)? quality, + TResult? Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult? Function(NdHostEvent_Ended value)? ended, + }) { + return ended?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdHostEvent_Registered value)? registered, + TResult Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult Function(NdHostEvent_Mouse value)? mouse, + TResult Function(NdHostEvent_Key value)? key, + TResult Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult Function(NdHostEvent_Quality value)? quality, + TResult Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult Function(NdHostEvent_Ended value)? ended, + required TResult orElse(), + }) { + if (ended != null) { + return ended(this); + } + return orElse(); + } +} + +abstract class NdHostEvent_Ended extends NdHostEvent { + const factory NdHostEvent_Ended({required final String reason}) = + _$NdHostEvent_EndedImpl; + const NdHostEvent_Ended._() : super._(); + + String get reason; + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$NdHostEvent_EndedImplCopyWith<_$NdHostEvent_EndedImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/native/ui/lib/rust/frb_generated.dart b/native/ui/lib/rust/frb_generated.dart new file mode 100644 index 0000000..8034f3b --- /dev/null +++ b/native/ui/lib/rust/frb_generated.dart @@ -0,0 +1,1190 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.13.0. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +import 'api.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'frb_generated.dart'; +import 'frb_generated.io.dart' + if (dart.library.js_interop) 'frb_generated.web.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +/// Main entrypoint of the Rust API +class RustLib extends BaseEntrypoint { + @internal + static final instance = RustLib._(); + + RustLib._(); + + /// Initialize flutter_rust_bridge + static Future init({ + RustLibApi? api, + BaseHandler? handler, + ExternalLibrary? externalLibrary, + bool forceSameCodegenVersion = true, + }) async { + await instance.initImpl( + api: api, + handler: handler, + externalLibrary: externalLibrary, + forceSameCodegenVersion: forceSameCodegenVersion, + ); + } + + /// Initialize flutter_rust_bridge in mock mode. + /// No libraries for FFI are loaded. + static void initMock({ + required RustLibApi api, + }) { + instance.initMockImpl( + api: api, + ); + } + + /// Dispose flutter_rust_bridge + /// + /// The call to this function is optional, since flutter_rust_bridge (and everything else) + /// is automatically disposed when the app stops. + static void dispose() => instance.disposeImpl(); + + @override + ApiImplConstructor get apiImplConstructor => + RustLibApiImpl.new; + + @override + WireConstructor get wireConstructor => + RustLibWire.fromExternalLibrary; + + @override + Future executeRustInitializers() async {} + + @override + ExternalLibraryLoaderConfig get defaultExternalLibraryLoaderConfig => + kDefaultExternalLibraryLoaderConfig; + + @override + String get codegenVersion => '2.13.0'; + + @override + int get rustContentHash => 389194750; + + static const kDefaultExternalLibraryLoaderConfig = + ExternalLibraryLoaderConfig( + stem: 'netdesk_frb', + ioDirectory: '../frb/target/release/', + webPrefix: 'pkg/', + wasmBindgenName: 'wasm_bindgen', + ); +} + +abstract class RustLibApi extends BaseApi { + Future crateApiConnect( + {required String wsUrl, + required String hostId, + required String password, + required String totp}); + + void crateApiCtrlAltDel({required BigInt handle}); + + void crateApiDisconnect({required BigInt handle}); + + Stream crateApiHostEvents({required BigInt handle}); + + String crateApiHostGenId(); + + String crateApiHostGenPassword(); + + void crateApiHostPushFrame( + {required BigInt handle, + required List data, + required bool keyframe}); + + Future crateApiHostStart( + {required String wsUrl, + required String hostId, + required String name, + required String password, + required int width, + required int height}); + + void crateApiHostStop({required BigInt handle}); + + void crateApiMonitorSwitch({required BigInt handle, required int i}); + + void crateApiSendChat({required BigInt handle, required String text}); + + void crateApiSendKey( + {required BigInt handle, required String code, required bool down}); + + void crateApiSendMouse( + {required BigInt handle, + required int x, + required int y, + required int buttons, + required double wheel, + required double hwheel}); + + int crateApiSessionCurrentMonitor({required BigInt handle}); + + Stream crateApiSessionEvents({required BigInt handle}); + + List crateApiSessionMonitors({required BigInt handle}); + + void crateApiSetClipboard({required BigInt handle, required String text}); + + void crateApiSetQuality( + {required BigInt handle, + required int fps, + required int bitrate, + required double scale}); +} + +class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { + RustLibApiImpl({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @override + Future crateApiConnect( + {required String wsUrl, + required String hostId, + required String password, + required String totp}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(wsUrl, serializer); + sse_encode_String(hostId, serializer); + sse_encode_String(password, serializer); + sse_encode_String(totp, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 1, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_u_64, + decodeErrorData: null, + ), + constMeta: kCrateApiConnectConstMeta, + argValues: [wsUrl, hostId, password, totp], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiConnectConstMeta => const TaskConstMeta( + debugName: "connect", + argNames: ["wsUrl", "hostId", "password", "totp"], + ); + + @override + void crateApiCtrlAltDel({required BigInt handle}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 2)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiCtrlAltDelConstMeta, + argValues: [handle], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiCtrlAltDelConstMeta => const TaskConstMeta( + debugName: "ctrl_alt_del", + argNames: ["handle"], + ); + + @override + void crateApiDisconnect({required BigInt handle}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 3)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiDisconnectConstMeta, + argValues: [handle], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiDisconnectConstMeta => const TaskConstMeta( + debugName: "disconnect", + argNames: ["handle"], + ); + + @override + Stream crateApiHostEvents({required BigInt handle}) { + final sink = RustStreamSink(); + unawaited(handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + sse_encode_StreamSink_nd_host_event_Sse(sink, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 4, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiHostEventsConstMeta, + argValues: [handle, sink], + apiImpl: this, + ))); + return sink.stream; + } + + TaskConstMeta get kCrateApiHostEventsConstMeta => const TaskConstMeta( + debugName: "host_events", + argNames: ["handle", "sink"], + ); + + @override + String crateApiHostGenId() { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 5)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: null, + ), + constMeta: kCrateApiHostGenIdConstMeta, + argValues: [], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiHostGenIdConstMeta => const TaskConstMeta( + debugName: "host_gen_id", + argNames: [], + ); + + @override + String crateApiHostGenPassword() { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 6)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: null, + ), + constMeta: kCrateApiHostGenPasswordConstMeta, + argValues: [], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiHostGenPasswordConstMeta => const TaskConstMeta( + debugName: "host_gen_password", + argNames: [], + ); + + @override + void crateApiHostPushFrame( + {required BigInt handle, + required List data, + required bool keyframe}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + sse_encode_list_prim_u_8_loose(data, serializer); + sse_encode_bool(keyframe, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 7)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiHostPushFrameConstMeta, + argValues: [handle, data, keyframe], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiHostPushFrameConstMeta => const TaskConstMeta( + debugName: "host_push_frame", + argNames: ["handle", "data", "keyframe"], + ); + + @override + Future crateApiHostStart( + {required String wsUrl, + required String hostId, + required String name, + required String password, + required int width, + required int height}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(wsUrl, serializer); + sse_encode_String(hostId, serializer); + sse_encode_String(name, serializer); + sse_encode_String(password, serializer); + sse_encode_u_32(width, serializer); + sse_encode_u_32(height, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 8, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_u_64, + decodeErrorData: null, + ), + constMeta: kCrateApiHostStartConstMeta, + argValues: [wsUrl, hostId, name, password, width, height], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiHostStartConstMeta => const TaskConstMeta( + debugName: "host_start", + argNames: ["wsUrl", "hostId", "name", "password", "width", "height"], + ); + + @override + void crateApiHostStop({required BigInt handle}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 9)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiHostStopConstMeta, + argValues: [handle], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiHostStopConstMeta => const TaskConstMeta( + debugName: "host_stop", + argNames: ["handle"], + ); + + @override + void crateApiMonitorSwitch({required BigInt handle, required int i}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + sse_encode_i_32(i, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 10)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiMonitorSwitchConstMeta, + argValues: [handle, i], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiMonitorSwitchConstMeta => const TaskConstMeta( + debugName: "monitor_switch", + argNames: ["handle", "i"], + ); + + @override + void crateApiSendChat({required BigInt handle, required String text}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + sse_encode_String(text, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 11)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiSendChatConstMeta, + argValues: [handle, text], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiSendChatConstMeta => const TaskConstMeta( + debugName: "send_chat", + argNames: ["handle", "text"], + ); + + @override + void crateApiSendKey( + {required BigInt handle, required String code, required bool down}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + sse_encode_String(code, serializer); + sse_encode_bool(down, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 12)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiSendKeyConstMeta, + argValues: [handle, code, down], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiSendKeyConstMeta => const TaskConstMeta( + debugName: "send_key", + argNames: ["handle", "code", "down"], + ); + + @override + void crateApiSendMouse( + {required BigInt handle, + required int x, + required int y, + required int buttons, + required double wheel, + required double hwheel}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + sse_encode_i_32(x, serializer); + sse_encode_i_32(y, serializer); + sse_encode_u_32(buttons, serializer); + sse_encode_f_32(wheel, serializer); + sse_encode_f_32(hwheel, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 13)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiSendMouseConstMeta, + argValues: [handle, x, y, buttons, wheel, hwheel], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiSendMouseConstMeta => const TaskConstMeta( + debugName: "send_mouse", + argNames: ["handle", "x", "y", "buttons", "wheel", "hwheel"], + ); + + @override + int crateApiSessionCurrentMonitor({required BigInt handle}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_i_32, + decodeErrorData: null, + ), + constMeta: kCrateApiSessionCurrentMonitorConstMeta, + argValues: [handle], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiSessionCurrentMonitorConstMeta => + const TaskConstMeta( + debugName: "session_current_monitor", + argNames: ["handle"], + ); + + @override + Stream crateApiSessionEvents({required BigInt handle}) { + final sink = RustStreamSink(); + unawaited(handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + sse_encode_StreamSink_nd_event_Sse(sink, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 15, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiSessionEventsConstMeta, + argValues: [handle, sink], + apiImpl: this, + ))); + return sink.stream; + } + + TaskConstMeta get kCrateApiSessionEventsConstMeta => const TaskConstMeta( + debugName: "session_events", + argNames: ["handle", "sink"], + ); + + @override + List crateApiSessionMonitors({required BigInt handle}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_list_nd_monitor, + decodeErrorData: null, + ), + constMeta: kCrateApiSessionMonitorsConstMeta, + argValues: [handle], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiSessionMonitorsConstMeta => const TaskConstMeta( + debugName: "session_monitors", + argNames: ["handle"], + ); + + @override + void crateApiSetClipboard({required BigInt handle, required String text}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + sse_encode_String(text, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiSetClipboardConstMeta, + argValues: [handle, text], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiSetClipboardConstMeta => const TaskConstMeta( + debugName: "set_clipboard", + argNames: ["handle", "text"], + ); + + @override + void crateApiSetQuality( + {required BigInt handle, + required int fps, + required int bitrate, + required double scale}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + sse_encode_u_32(fps, serializer); + sse_encode_u_32(bitrate, serializer); + sse_encode_f_32(scale, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiSetQualityConstMeta, + argValues: [handle, fps, bitrate, scale], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiSetQualityConstMeta => const TaskConstMeta( + debugName: "set_quality", + argNames: ["handle", "fps", "bitrate", "scale"], + ); + + @protected + AnyhowException dco_decode_AnyhowException(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return AnyhowException(raw as String); + } + + @protected + RustStreamSink dco_decode_StreamSink_nd_event_Sse(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + throw UnimplementedError(); + } + + @protected + RustStreamSink dco_decode_StreamSink_nd_host_event_Sse( + dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + throw UnimplementedError(); + } + + @protected + String dco_decode_String(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as String; + } + + @protected + bool dco_decode_bool(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as bool; + } + + @protected + double dco_decode_f_32(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as double; + } + + @protected + int dco_decode_i_32(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + List dco_decode_list_nd_monitor(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_nd_monitor).toList(); + } + + @protected + List dco_decode_list_prim_u_8_loose(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as List; + } + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as Uint8List; + } + + @protected + NdEvent dco_decode_nd_event(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + switch (raw[0]) { + case 0: + return const NdEvent_Connecting(); + case 1: + return NdEvent_Connected( + w: dco_decode_u_32(raw[1]), + h: dco_decode_u_32(raw[2]), + e2E: dco_decode_bool(raw[3]), + hostName: dco_decode_String(raw[4]), + ); + case 2: + return NdEvent_Frame( + width: dco_decode_u_32(raw[1]), + height: dco_decode_u_32(raw[2]), + rgba: dco_decode_list_prim_u_8_strict(raw[3]), + ); + case 3: + return NdEvent_Ended( + reason: dco_decode_String(raw[1]), + ); + case 4: + return NdEvent_Denied( + reason: dco_decode_String(raw[1]), + ); + case 5: + return NdEvent_ChatMessage( + from: dco_decode_String(raw[1]), + text: dco_decode_String(raw[2]), + ); + case 6: + return NdEvent_Clipboard( + text: dco_decode_String(raw[1]), + ); + default: + throw Exception("unreachable"); + } + } + + @protected + NdHostEvent dco_decode_nd_host_event(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + switch (raw[0]) { + case 0: + return const NdHostEvent_Registered(); + case 1: + return NdHostEvent_ViewerConnected( + e2E: dco_decode_bool(raw[1]), + ); + case 2: + return const NdHostEvent_ViewerLeft(); + case 3: + return NdHostEvent_Mouse( + x: dco_decode_i_32(raw[1]), + y: dco_decode_i_32(raw[2]), + buttons: dco_decode_u_32(raw[3]), + wheel: dco_decode_f_32(raw[4]), + ); + case 4: + return NdHostEvent_Key( + code: dco_decode_String(raw[1]), + down: dco_decode_bool(raw[2]), + ); + case 5: + return const NdHostEvent_KeyframeRequested(); + case 6: + return NdHostEvent_Quality( + fps: dco_decode_u_32(raw[1]), + bitrate: dco_decode_u_32(raw[2]), + scale: dco_decode_f_32(raw[3]), + ); + case 7: + return const NdHostEvent_Reconnecting(); + case 8: + return NdHostEvent_Ended( + reason: dco_decode_String(raw[1]), + ); + default: + throw Exception("unreachable"); + } + } + + @protected + NdMonitor dco_decode_nd_monitor(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 3) + throw Exception('unexpected arr length: expect 3 but see ${arr.length}'); + return NdMonitor( + i: dco_decode_i_32(arr[0]), + w: dco_decode_u_32(arr[1]), + h: dco_decode_u_32(arr[2]), + ); + } + + @protected + int dco_decode_u_32(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + BigInt dco_decode_u_64(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dcoDecodeU64(raw); + } + + @protected + int dco_decode_u_8(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + void dco_decode_unit(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return; + } + + @protected + AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_String(deserializer); + return AnyhowException(inner); + } + + @protected + RustStreamSink sse_decode_StreamSink_nd_event_Sse( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + throw UnimplementedError('Unreachable ()'); + } + + @protected + RustStreamSink sse_decode_StreamSink_nd_host_event_Sse( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + throw UnimplementedError('Unreachable ()'); + } + + @protected + String sse_decode_String(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_list_prim_u_8_strict(deserializer); + return utf8.decoder.convert(inner); + } + + @protected + bool sse_decode_bool(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint8() != 0; + } + + @protected + double sse_decode_f_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getFloat32(); + } + + @protected + int sse_decode_i_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getInt32(); + } + + @protected + List sse_decode_list_nd_monitor(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var len_ = sse_decode_i_32(deserializer); + var ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_nd_monitor(deserializer)); + } + return ans_; + } + + @protected + List sse_decode_list_prim_u_8_loose(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var len_ = sse_decode_i_32(deserializer); + return deserializer.buffer.getUint8List(len_); + } + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var len_ = sse_decode_i_32(deserializer); + return deserializer.buffer.getUint8List(len_); + } + + @protected + NdEvent sse_decode_nd_event(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var tag_ = sse_decode_i_32(deserializer); + switch (tag_) { + case 0: + return const NdEvent_Connecting(); + case 1: + var var_w = sse_decode_u_32(deserializer); + var var_h = sse_decode_u_32(deserializer); + var var_e2E = sse_decode_bool(deserializer); + var var_hostName = sse_decode_String(deserializer); + return NdEvent_Connected( + w: var_w, h: var_h, e2E: var_e2E, hostName: var_hostName); + case 2: + var var_width = sse_decode_u_32(deserializer); + var var_height = sse_decode_u_32(deserializer); + var var_rgba = sse_decode_list_prim_u_8_strict(deserializer); + return NdEvent_Frame( + width: var_width, height: var_height, rgba: var_rgba); + case 3: + var var_reason = sse_decode_String(deserializer); + return NdEvent_Ended(reason: var_reason); + case 4: + var var_reason = sse_decode_String(deserializer); + return NdEvent_Denied(reason: var_reason); + case 5: + var var_from = sse_decode_String(deserializer); + var var_text = sse_decode_String(deserializer); + return NdEvent_ChatMessage(from: var_from, text: var_text); + case 6: + var var_text = sse_decode_String(deserializer); + return NdEvent_Clipboard(text: var_text); + default: + throw UnimplementedError(''); + } + } + + @protected + NdHostEvent sse_decode_nd_host_event(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var tag_ = sse_decode_i_32(deserializer); + switch (tag_) { + case 0: + return const NdHostEvent_Registered(); + case 1: + var var_e2E = sse_decode_bool(deserializer); + return NdHostEvent_ViewerConnected(e2E: var_e2E); + case 2: + return const NdHostEvent_ViewerLeft(); + case 3: + var var_x = sse_decode_i_32(deserializer); + var var_y = sse_decode_i_32(deserializer); + var var_buttons = sse_decode_u_32(deserializer); + var var_wheel = sse_decode_f_32(deserializer); + return NdHostEvent_Mouse( + x: var_x, y: var_y, buttons: var_buttons, wheel: var_wheel); + case 4: + var var_code = sse_decode_String(deserializer); + var var_down = sse_decode_bool(deserializer); + return NdHostEvent_Key(code: var_code, down: var_down); + case 5: + return const NdHostEvent_KeyframeRequested(); + case 6: + var var_fps = sse_decode_u_32(deserializer); + var var_bitrate = sse_decode_u_32(deserializer); + var var_scale = sse_decode_f_32(deserializer); + return NdHostEvent_Quality( + fps: var_fps, bitrate: var_bitrate, scale: var_scale); + case 7: + return const NdHostEvent_Reconnecting(); + case 8: + var var_reason = sse_decode_String(deserializer); + return NdHostEvent_Ended(reason: var_reason); + default: + throw UnimplementedError(''); + } + } + + @protected + NdMonitor sse_decode_nd_monitor(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_i = sse_decode_i_32(deserializer); + var var_w = sse_decode_u_32(deserializer); + var var_h = sse_decode_u_32(deserializer); + return NdMonitor(i: var_i, w: var_w, h: var_h); + } + + @protected + int sse_decode_u_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint32(); + } + + @protected + BigInt sse_decode_u_64(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getBigUint64(); + } + + @protected + int sse_decode_u_8(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint8(); + } + + @protected + void sse_decode_unit(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + } + + @protected + void sse_encode_AnyhowException( + AnyhowException self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.message, serializer); + } + + @protected + void sse_encode_StreamSink_nd_event_Sse( + RustStreamSink self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String( + self.setupAndSerialize( + codec: SseCodec( + decodeSuccessData: sse_decode_nd_event, + decodeErrorData: sse_decode_AnyhowException, + )), + serializer); + } + + @protected + void sse_encode_StreamSink_nd_host_event_Sse( + RustStreamSink self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String( + self.setupAndSerialize( + codec: SseCodec( + decodeSuccessData: sse_decode_nd_host_event, + decodeErrorData: sse_decode_AnyhowException, + )), + serializer); + } + + @protected + void sse_encode_String(String self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_list_prim_u_8_strict(utf8.encoder.convert(self), serializer); + } + + @protected + void sse_encode_bool(bool self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint8(self ? 1 : 0); + } + + @protected + void sse_encode_f_32(double self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putFloat32(self); + } + + @protected + void sse_encode_i_32(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putInt32(self); + } + + @protected + void sse_encode_list_nd_monitor( + List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_nd_monitor(item, serializer); + } + } + + @protected + void sse_encode_list_prim_u_8_loose( + List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + serializer.buffer + .putUint8List(self is Uint8List ? self : Uint8List.fromList(self)); + } + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + serializer.buffer.putUint8List(self); + } + + @protected + void sse_encode_nd_event(NdEvent self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + switch (self) { + case NdEvent_Connecting(): + sse_encode_i_32(0, serializer); + case NdEvent_Connected( + w: final w, + h: final h, + e2E: final e2E, + hostName: final hostName + ): + sse_encode_i_32(1, serializer); + sse_encode_u_32(w, serializer); + sse_encode_u_32(h, serializer); + sse_encode_bool(e2E, serializer); + sse_encode_String(hostName, serializer); + case NdEvent_Frame( + width: final width, + height: final height, + rgba: final rgba + ): + sse_encode_i_32(2, serializer); + sse_encode_u_32(width, serializer); + sse_encode_u_32(height, serializer); + sse_encode_list_prim_u_8_strict(rgba, serializer); + case NdEvent_Ended(reason: final reason): + sse_encode_i_32(3, serializer); + sse_encode_String(reason, serializer); + case NdEvent_Denied(reason: final reason): + sse_encode_i_32(4, serializer); + sse_encode_String(reason, serializer); + case NdEvent_ChatMessage(from: final from, text: final text): + sse_encode_i_32(5, serializer); + sse_encode_String(from, serializer); + sse_encode_String(text, serializer); + case NdEvent_Clipboard(text: final text): + sse_encode_i_32(6, serializer); + sse_encode_String(text, serializer); + } + } + + @protected + void sse_encode_nd_host_event(NdHostEvent self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + switch (self) { + case NdHostEvent_Registered(): + sse_encode_i_32(0, serializer); + case NdHostEvent_ViewerConnected(e2E: final e2E): + sse_encode_i_32(1, serializer); + sse_encode_bool(e2E, serializer); + case NdHostEvent_ViewerLeft(): + sse_encode_i_32(2, serializer); + case NdHostEvent_Mouse( + x: final x, + y: final y, + buttons: final buttons, + wheel: final wheel + ): + sse_encode_i_32(3, serializer); + sse_encode_i_32(x, serializer); + sse_encode_i_32(y, serializer); + sse_encode_u_32(buttons, serializer); + sse_encode_f_32(wheel, serializer); + case NdHostEvent_Key(code: final code, down: final down): + sse_encode_i_32(4, serializer); + sse_encode_String(code, serializer); + sse_encode_bool(down, serializer); + case NdHostEvent_KeyframeRequested(): + sse_encode_i_32(5, serializer); + case NdHostEvent_Quality( + fps: final fps, + bitrate: final bitrate, + scale: final scale + ): + sse_encode_i_32(6, serializer); + sse_encode_u_32(fps, serializer); + sse_encode_u_32(bitrate, serializer); + sse_encode_f_32(scale, serializer); + case NdHostEvent_Reconnecting(): + sse_encode_i_32(7, serializer); + case NdHostEvent_Ended(reason: final reason): + sse_encode_i_32(8, serializer); + sse_encode_String(reason, serializer); + } + } + + @protected + void sse_encode_nd_monitor(NdMonitor self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.i, serializer); + sse_encode_u_32(self.w, serializer); + sse_encode_u_32(self.h, serializer); + } + + @protected + void sse_encode_u_32(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint32(self); + } + + @protected + void sse_encode_u_64(BigInt self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putBigUint64(self); + } + + @protected + void sse_encode_u_8(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint8(self); + } + + @protected + void sse_encode_unit(void self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + } +} diff --git a/native/ui/lib/rust/frb_generated.io.dart b/native/ui/lib/rust/frb_generated.io.dart new file mode 100644 index 0000000..626da37 --- /dev/null +++ b/native/ui/lib/rust/frb_generated.io.dart @@ -0,0 +1,196 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.13.0. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +import 'api.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi' as ffi; +import 'frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart'; + +abstract class RustLibApiImplPlatform extends BaseApiImpl { + RustLibApiImplPlatform({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @protected + AnyhowException dco_decode_AnyhowException(dynamic raw); + + @protected + RustStreamSink dco_decode_StreamSink_nd_event_Sse(dynamic raw); + + @protected + RustStreamSink dco_decode_StreamSink_nd_host_event_Sse( + dynamic raw); + + @protected + String dco_decode_String(dynamic raw); + + @protected + bool dco_decode_bool(dynamic raw); + + @protected + double dco_decode_f_32(dynamic raw); + + @protected + int dco_decode_i_32(dynamic raw); + + @protected + List dco_decode_list_nd_monitor(dynamic raw); + + @protected + List dco_decode_list_prim_u_8_loose(dynamic raw); + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + + @protected + NdEvent dco_decode_nd_event(dynamic raw); + + @protected + NdHostEvent dco_decode_nd_host_event(dynamic raw); + + @protected + NdMonitor dco_decode_nd_monitor(dynamic raw); + + @protected + int dco_decode_u_32(dynamic raw); + + @protected + BigInt dco_decode_u_64(dynamic raw); + + @protected + int dco_decode_u_8(dynamic raw); + + @protected + void dco_decode_unit(dynamic raw); + + @protected + AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer); + + @protected + RustStreamSink sse_decode_StreamSink_nd_event_Sse( + SseDeserializer deserializer); + + @protected + RustStreamSink sse_decode_StreamSink_nd_host_event_Sse( + SseDeserializer deserializer); + + @protected + String sse_decode_String(SseDeserializer deserializer); + + @protected + bool sse_decode_bool(SseDeserializer deserializer); + + @protected + double sse_decode_f_32(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + + @protected + List sse_decode_list_nd_monitor(SseDeserializer deserializer); + + @protected + List sse_decode_list_prim_u_8_loose(SseDeserializer deserializer); + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + + @protected + NdEvent sse_decode_nd_event(SseDeserializer deserializer); + + @protected + NdHostEvent sse_decode_nd_host_event(SseDeserializer deserializer); + + @protected + NdMonitor sse_decode_nd_monitor(SseDeserializer deserializer); + + @protected + int sse_decode_u_32(SseDeserializer deserializer); + + @protected + BigInt sse_decode_u_64(SseDeserializer deserializer); + + @protected + int sse_decode_u_8(SseDeserializer deserializer); + + @protected + void sse_decode_unit(SseDeserializer deserializer); + + @protected + void sse_encode_AnyhowException( + AnyhowException self, SseSerializer serializer); + + @protected + void sse_encode_StreamSink_nd_event_Sse( + RustStreamSink self, SseSerializer serializer); + + @protected + void sse_encode_StreamSink_nd_host_event_Sse( + RustStreamSink self, SseSerializer serializer); + + @protected + void sse_encode_String(String self, SseSerializer serializer); + + @protected + void sse_encode_bool(bool self, SseSerializer serializer); + + @protected + void sse_encode_f_32(double self, SseSerializer serializer); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); + + @protected + void sse_encode_list_nd_monitor( + List self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_loose(List self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, SseSerializer serializer); + + @protected + void sse_encode_nd_event(NdEvent self, SseSerializer serializer); + + @protected + void sse_encode_nd_host_event(NdHostEvent self, SseSerializer serializer); + + @protected + void sse_encode_nd_monitor(NdMonitor self, SseSerializer serializer); + + @protected + void sse_encode_u_32(int self, SseSerializer serializer); + + @protected + void sse_encode_u_64(BigInt self, SseSerializer serializer); + + @protected + void sse_encode_u_8(int self, SseSerializer serializer); + + @protected + void sse_encode_unit(void self, SseSerializer serializer); +} + +// Section: wire_class + +class RustLibWire implements BaseWire { + factory RustLibWire.fromExternalLibrary(ExternalLibrary lib) => + RustLibWire(lib.ffiDynamicLibrary); + + /// Holds the symbol lookup function. + final ffi.Pointer Function(String symbolName) + _lookup; + + /// The symbols are looked up in [dynamicLibrary]. + RustLibWire(ffi.DynamicLibrary dynamicLibrary) + : _lookup = dynamicLibrary.lookup; +} diff --git a/native/ui/lib/rust/frb_generated.web.dart b/native/ui/lib/rust/frb_generated.web.dart new file mode 100644 index 0000000..01a6d3b --- /dev/null +++ b/native/ui/lib/rust/frb_generated.web.dart @@ -0,0 +1,196 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.13.0. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +// Static analysis wrongly picks the IO variant, thus ignore this +// ignore_for_file: argument_type_not_assignable + +import 'api.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart'; + +abstract class RustLibApiImplPlatform extends BaseApiImpl { + RustLibApiImplPlatform({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @protected + AnyhowException dco_decode_AnyhowException(dynamic raw); + + @protected + RustStreamSink dco_decode_StreamSink_nd_event_Sse(dynamic raw); + + @protected + RustStreamSink dco_decode_StreamSink_nd_host_event_Sse( + dynamic raw); + + @protected + String dco_decode_String(dynamic raw); + + @protected + bool dco_decode_bool(dynamic raw); + + @protected + double dco_decode_f_32(dynamic raw); + + @protected + int dco_decode_i_32(dynamic raw); + + @protected + List dco_decode_list_nd_monitor(dynamic raw); + + @protected + List dco_decode_list_prim_u_8_loose(dynamic raw); + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + + @protected + NdEvent dco_decode_nd_event(dynamic raw); + + @protected + NdHostEvent dco_decode_nd_host_event(dynamic raw); + + @protected + NdMonitor dco_decode_nd_monitor(dynamic raw); + + @protected + int dco_decode_u_32(dynamic raw); + + @protected + BigInt dco_decode_u_64(dynamic raw); + + @protected + int dco_decode_u_8(dynamic raw); + + @protected + void dco_decode_unit(dynamic raw); + + @protected + AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer); + + @protected + RustStreamSink sse_decode_StreamSink_nd_event_Sse( + SseDeserializer deserializer); + + @protected + RustStreamSink sse_decode_StreamSink_nd_host_event_Sse( + SseDeserializer deserializer); + + @protected + String sse_decode_String(SseDeserializer deserializer); + + @protected + bool sse_decode_bool(SseDeserializer deserializer); + + @protected + double sse_decode_f_32(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + + @protected + List sse_decode_list_nd_monitor(SseDeserializer deserializer); + + @protected + List sse_decode_list_prim_u_8_loose(SseDeserializer deserializer); + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + + @protected + NdEvent sse_decode_nd_event(SseDeserializer deserializer); + + @protected + NdHostEvent sse_decode_nd_host_event(SseDeserializer deserializer); + + @protected + NdMonitor sse_decode_nd_monitor(SseDeserializer deserializer); + + @protected + int sse_decode_u_32(SseDeserializer deserializer); + + @protected + BigInt sse_decode_u_64(SseDeserializer deserializer); + + @protected + int sse_decode_u_8(SseDeserializer deserializer); + + @protected + void sse_decode_unit(SseDeserializer deserializer); + + @protected + void sse_encode_AnyhowException( + AnyhowException self, SseSerializer serializer); + + @protected + void sse_encode_StreamSink_nd_event_Sse( + RustStreamSink self, SseSerializer serializer); + + @protected + void sse_encode_StreamSink_nd_host_event_Sse( + RustStreamSink self, SseSerializer serializer); + + @protected + void sse_encode_String(String self, SseSerializer serializer); + + @protected + void sse_encode_bool(bool self, SseSerializer serializer); + + @protected + void sse_encode_f_32(double self, SseSerializer serializer); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); + + @protected + void sse_encode_list_nd_monitor( + List self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_loose(List self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, SseSerializer serializer); + + @protected + void sse_encode_nd_event(NdEvent self, SseSerializer serializer); + + @protected + void sse_encode_nd_host_event(NdHostEvent self, SseSerializer serializer); + + @protected + void sse_encode_nd_monitor(NdMonitor self, SseSerializer serializer); + + @protected + void sse_encode_u_32(int self, SseSerializer serializer); + + @protected + void sse_encode_u_64(BigInt self, SseSerializer serializer); + + @protected + void sse_encode_u_8(int self, SseSerializer serializer); + + @protected + void sse_encode_unit(void self, SseSerializer serializer); +} + +// Section: wire_class + +class RustLibWire implements BaseWire { + RustLibWire.fromExternalLibrary(ExternalLibrary lib); +} + +@JS('wasm_bindgen') +external RustLibWasmModule get wasmModule; + +@JS() +@anonymous +extension type RustLibWasmModule._(JSObject _) implements JSObject {} diff --git a/native/ui/lib/session_screen.dart b/native/ui/lib/session_screen.dart new file mode 100644 index 0000000..4334fda --- /dev/null +++ b/native/ui/lib/session_screen.dart @@ -0,0 +1,1447 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'rust/api.dart' as nd; +import 'theme.dart'; + +/// Mouse-button bitmask values used by the Rust input layer. +const int _btnLeft = 1; +const int _btnRight = 2; + +enum _Phase { connecting, live, ended, denied } + +/// How finger touch maps to the remote cursor. +/// * [trackpad] — the screen is a relative trackpad: drag to nudge the cursor, +/// tap to click (precise, works at any zoom, like a laptop pad). +/// * [direct] — the screen is the remote surface: the cursor jumps to wherever +/// you touch, tap-to-click there (fast, like touching the PC). +/// A stylus/pen is ALWAYS treated as a precise direct pointer (with hover), +/// regardless of this mode. +enum _InputMode { trackpad, direct } + +/// The active remote-session screen. +/// +/// It subscribes to [nd.sessionEvents] for the session [handle] opened by the +/// connect screen, paints each decoded RGBA [nd.NdEvent_Frame] to fill the +/// display (fit + pinch-zoom + pan), and forwards touch and keyboard input back +/// through the FRB methods: +/// +/// * one-finger drag → move cursor ([nd.sendMouse]) +/// * tap → left click ([nd.sendMouse]) +/// * long-press → right click ([nd.sendMouse]) +/// * two-finger drag → scroll wheel ([nd.sendMouse]) +/// * pinch → zoom the view (local) +/// * hidden TextField → keystrokes ([nd.sendKey]) +/// +/// A floating toolbar exposes disconnect, keyboard, monitors, quality and +/// Ctrl+Alt+Del; an overflow sheet adds chat, clipboard push and reset-zoom. +class SessionScreen extends StatefulWidget { + const SessionScreen({ + super.key, + required this.partnerId, + required this.handle, + }); + + /// The Partner ID being controlled (display only). + final String partnerId; + + /// The session handle returned by [nd.connect]. + final BigInt handle; + + @override + State createState() => _SessionScreenState(); +} + +class _SessionScreenState extends State { + StreamSubscription? _sub; + + // ---- session state ------------------------------------------------------- + _Phase _phase = _Phase.connecting; + String? _error; + String _hostName = ''; + bool _e2e = false; + ui.Image? _image; + int _remoteW = 0; + int _remoteH = 0; + int _currentMon = 0; + bool _closed = false; + bool _showHint = false; + /// When false, the top bar + toolbar are hidden for a pure full-screen view. + bool _controlsVisible = true; + + /// Virtual cursor position, in remote pixel space (touchpad model). + Offset _cursor = Offset.zero; + + /// Touch input mode (trackpad vs direct). Persisted for the session. + _InputMode _mode = _InputMode.trackpad; + + /// Mouse buttons currently held (so pen/touch drags keep the button down + /// across move events). Bitmask of [_btnLeft]/[_btnRight]. + int _heldButtons = 0; + + /// Kind of the most recent pointer down — lets the touch gestures ignore a + /// stylus (which the Listener handles as a precise absolute pointer instead). + PointerDeviceKind _activeKind = PointerDeviceKind.touch; + bool get _penActive => + _activeKind == PointerDeviceKind.stylus || + _activeKind == PointerDeviceKind.invertedStylus; + + // ---- view transform (pinch-zoom + pan) ----------------------------------- + double _viewScale = 1.0; + Offset _viewOffset = Offset.zero; + double _gestureStartScale = 1.0; + Offset _gestureStartOffset = Offset.zero; + Offset _gestureStartFocal = Offset.zero; + Size _viewport = Size.zero; + static const double _minScale = 1.0; + static const double _maxScale = 6.0; + static const double _touchSensitivity = 2.2; + + // ---- keyboard ------------------------------------------------------------ + final FocusNode _bodyFocus = FocusNode(debugLabel: 'netdesk-body'); + final FocusNode _kbFocus = FocusNode(debugLabel: 'netdesk-keyboard'); + final TextEditingController _kbController = + TextEditingController(text: _kbBase); + static const String _kbBase = '​'; // zero-width placeholder + bool _keyboardVisible = false; + + static final Map _logicalMap = { + LogicalKeyboardKey.enter: 'Enter', + LogicalKeyboardKey.numpadEnter: 'Enter', + LogicalKeyboardKey.backspace: 'Backspace', + LogicalKeyboardKey.tab: 'Tab', + LogicalKeyboardKey.escape: 'Escape', + LogicalKeyboardKey.space: 'Space', + LogicalKeyboardKey.delete: 'Delete', + LogicalKeyboardKey.insert: 'Insert', + LogicalKeyboardKey.arrowLeft: 'ArrowLeft', + LogicalKeyboardKey.arrowRight: 'ArrowRight', + LogicalKeyboardKey.arrowUp: 'ArrowUp', + LogicalKeyboardKey.arrowDown: 'ArrowDown', + LogicalKeyboardKey.home: 'Home', + LogicalKeyboardKey.end: 'End', + LogicalKeyboardKey.pageUp: 'PageUp', + LogicalKeyboardKey.pageDown: 'PageDown', + LogicalKeyboardKey.capsLock: 'CapsLock', + LogicalKeyboardKey.controlLeft: 'ControlLeft', + LogicalKeyboardKey.controlRight: 'ControlRight', + LogicalKeyboardKey.shiftLeft: 'ShiftLeft', + LogicalKeyboardKey.shiftRight: 'ShiftRight', + LogicalKeyboardKey.altLeft: 'AltLeft', + LogicalKeyboardKey.altRight: 'AltRight', + LogicalKeyboardKey.metaLeft: 'MetaLeft', + LogicalKeyboardKey.metaRight: 'MetaRight', + LogicalKeyboardKey.f1: 'F1', + LogicalKeyboardKey.f2: 'F2', + LogicalKeyboardKey.f3: 'F3', + LogicalKeyboardKey.f4: 'F4', + LogicalKeyboardKey.f5: 'F5', + LogicalKeyboardKey.f6: 'F6', + LogicalKeyboardKey.f7: 'F7', + LogicalKeyboardKey.f8: 'F8', + LogicalKeyboardKey.f9: 'F9', + LogicalKeyboardKey.f10: 'F10', + LogicalKeyboardKey.f11: 'F11', + LogicalKeyboardKey.f12: 'F12', + }; + + // ========================================================================== + // lifecycle + // ========================================================================== + @override + void initState() { + super.initState(); + // Full-screen immersive session: hide the Android status + nav bars so the + // remote screen uses the whole tablet. They slide back in on an edge swipe. + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + _sub = nd.sessionEvents(handle: widget.handle).listen( + _onEvent, + onError: (Object e) { + if (!mounted) return; + setState(() { + _phase = _Phase.ended; + _error = '$e'; + }); + }, + ); + } + + @override + void dispose() { + // Restore the normal (edge-to-edge) system bars for the rest of the app. + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + _sub?.cancel(); + if (!_closed) { + _closed = true; + nd.disconnect(handle: widget.handle); + } + _image?.dispose(); + _kbController.dispose(); + _kbFocus.dispose(); + _bodyFocus.dispose(); + super.dispose(); + } + + // ========================================================================== + // events + // ========================================================================== + void _onEvent(nd.NdEvent event) { + if (!mounted) return; + switch (event) { + case nd.NdEvent_Connecting(): + setState(() => _phase = _Phase.connecting); + case nd.NdEvent_Connected( + :final w, + :final h, + :final e2E, + :final hostName + ): + setState(() { + _remoteW = w; + _remoteH = h; + _e2e = e2E; + _hostName = hostName; + _cursor = Offset(w / 2, h / 2); + _phase = _Phase.live; + _showHint = true; + _currentMon = nd.sessionCurrentMonitor(handle: widget.handle); + }); + _scheduleHintHide(); + case nd.NdEvent_Frame(:final width, :final height, :final rgba): + _ingestFrame(width, height, rgba); + case nd.NdEvent_Ended(:final reason): + _closed = true; + setState(() { + _phase = _Phase.ended; + _error = reason; + }); + case nd.NdEvent_Denied(:final reason): + _closed = true; + setState(() { + _phase = _Phase.denied; + _error = reason; + }); + case nd.NdEvent_ChatMessage(:final from, :final text): + _toast('$from: $text', icon: Icons.chat_bubble_outline_rounded); + case nd.NdEvent_Clipboard(:final text): + Clipboard.setData(ClipboardData(text: text)); + _toast('Clipboard received from host', + icon: Icons.content_paste_rounded); + } + } + + void _ingestFrame(int w, int h, Uint8List rgba) { + ui.decodeImageFromPixels(rgba, w, h, ui.PixelFormat.rgba8888, (ui.Image img) { + if (!mounted) { + img.dispose(); + return; + } + final old = _image; + setState(() { + _image = img; + if (_remoteW == 0 || _remoteH == 0) { + _remoteW = w; + _remoteH = h; + _cursor = Offset(w / 2, h / 2); + } + if (_phase == _Phase.connecting) { + _phase = _Phase.live; + _showHint = true; + _scheduleHintHide(); + } + }); + old?.dispose(); + }); + } + + void _scheduleHintHide() { + Future.delayed(const Duration(seconds: 4), () { + if (mounted && _showHint) setState(() => _showHint = false); + }); + } + + // ========================================================================== + // input → FRB + // ========================================================================== + bool get _inputReady => _phase == _Phase.live && _remoteW > 0 && _remoteH > 0; + + void _moveCursorBy(Offset delta) { + if (!_inputReady) return; + final speed = _touchSensitivity / _viewScale; + final nx = (_cursor.dx + delta.dx * speed).clamp(0.0, _remoteW.toDouble()); + final ny = (_cursor.dy + delta.dy * speed).clamp(0.0, _remoteH.toDouble()); + setState(() => _cursor = Offset(nx, ny)); + nd.sendMouse( + handle: widget.handle, + x: nx.round(), + y: ny.round(), + buttons: 0, + wheel: 0, + hwheel: 0, + ); + } + + void _click(int button) { + if (!_inputReady) return; + final x = _cursor.dx.round(); + final y = _cursor.dy.round(); + nd.sendMouse( + handle: widget.handle, + x: x, + y: y, + buttons: button, + wheel: 0, + hwheel: 0); + nd.sendMouse( + handle: widget.handle, x: x, y: y, buttons: 0, wheel: 0, hwheel: 0); + HapticFeedback.selectionClick(); + } + + void _scroll(double dy) { + if (!_inputReady) return; + nd.sendMouse( + handle: widget.handle, + x: _cursor.dx.round(), + y: _cursor.dy.round(), + buttons: 0, + wheel: dy / 6.0, + hwheel: 0, + ); + } + + /// Map a local canvas position (viewport space) to remote pixel coords, + /// undoing the pinch-zoom/pan transform AND the BoxFit.contain letterbox. + Offset? _toRemote(Offset local) { + if (_viewport == Size.zero || _remoteW <= 0 || _remoteH <= 0) return null; + final child = (local - _viewOffset) / _viewScale; + final imgSize = Size(_remoteW.toDouble(), _remoteH.toDouble()); + final fitted = applyBoxFit(BoxFit.contain, imgSize, _viewport); + final dst = + Alignment.center.inscribe(fitted.destination, Offset.zero & _viewport); + if (dst.width <= 0 || dst.height <= 0) return null; + final rx = ((child.dx - dst.left) / dst.width * _remoteW) + .clamp(0.0, _remoteW.toDouble()); + final ry = ((child.dy - dst.top) / dst.height * _remoteH) + .clamp(0.0, _remoteH.toDouble()); + return Offset(rx, ry); + } + + /// Send an absolute cursor position (remote space) with the given buttons held. + void _sendAbsolute(Offset remote, int buttons) { + setState(() => _cursor = remote); + nd.sendMouse( + handle: widget.handle, + x: remote.dx.round(), + y: remote.dy.round(), + buttons: buttons, + wheel: 0, + hwheel: 0, + ); + } + + /// Direct-touch / stylus: move the cursor to where the finger/pen points. + void _moveTo(Offset local) { + if (!_inputReady) return; + final r = _toRemote(local); + if (r != null) _sendAbsolute(r, _heldButtons); + } + + // ---- stylus / pen (precise, absolute, hover-aware) ----------------------- + // Touch and mouse fall through to the GestureDetector (trackpad/direct); only + // a stylus is handled here, so the two paths never fight over one pointer. + bool _isPen(PointerDeviceKind k) => + k == PointerDeviceKind.stylus || k == PointerDeviceKind.invertedStylus; + + void _onPointerDown(PointerDownEvent e) { + _activeKind = e.kind; // record for the gesture handlers' pen guard + if (!_inputReady || !_isPen(e.kind)) return; + final r = _toRemote(e.localPosition); + if (r == null) return; + _heldButtons = _btnLeft; // pen touching = left button down + _sendAbsolute(r, _btnLeft); + } + + void _onPointerMove(PointerMoveEvent e) { + if (_isPen(e.kind)) _moveTo(e.localPosition); + } + + void _onPointerHover(PointerHoverEvent e) { + if (_isPen(e.kind)) { + _heldButtons = 0; + _moveTo(e.localPosition); + } + } + + void _onPointerUp(PointerUpEvent e) { + if (!_isPen(e.kind)) return; + _heldButtons = 0; + final r = _toRemote(e.localPosition); + if (r != null) _sendAbsolute(r, 0); + } + + void _onPointerSignal(PointerSignalEvent e) { + if (e is PointerScrollEvent) _scroll(-e.scrollDelta.dy); + } + + void _onScaleStart(ScaleStartDetails d) { + _gestureStartScale = _viewScale; + _gestureStartOffset = _viewOffset; + _gestureStartFocal = d.localFocalPoint; + } + + void _onScaleUpdate(ScaleUpdateDetails d) { + if (_penActive) return; // pen drags are handled by the Listener + if (d.pointerCount >= 2) { + // Pinch → zoom about the focal point; two-finger translation → pan. + final desired = + (_gestureStartScale * d.scale).clamp(_minScale, _maxScale); + final childPoint = + (_gestureStartFocal - _gestureStartOffset) / _gestureStartScale; + var newOffset = d.localFocalPoint - childPoint * desired; + setState(() { + _viewScale = desired; + _viewOffset = newOffset; + _clampOffset(); + }); + // At base scale there is nothing to pan, so a two-finger drag scrolls. + if (_viewScale <= 1.001 && (d.scale - 1).abs() < 0.02) { + final dy = d.focalPointDelta.dy; + if (dy.abs() > 0.3) _scroll(-dy); + } + } else { + // Single finger: trackpad = relative nudge; direct = jump to the finger. + if (_mode == _InputMode.direct) { + _moveTo(d.localFocalPoint); + } else { + _moveCursorBy(d.focalPointDelta); + } + } + } + + void _clampOffset() { + if (_viewport == Size.zero) return; + final minX = _viewport.width * (1 - _viewScale); + final minY = _viewport.height * (1 - _viewScale); + _viewOffset = Offset( + _viewOffset.dx.clamp(minX, 0.0), + _viewOffset.dy.clamp(minY, 0.0), + ); + } + + void _resetZoom() { + setState(() { + _viewScale = 1.0; + _viewOffset = Offset.zero; + }); + } + + // ========================================================================== + // keyboard + // ========================================================================== + void _sendCode(String code, {bool shift = false}) { + final h = widget.handle; + if (shift) nd.sendKey(handle: h, code: 'ShiftLeft', down: true); + nd.sendKey(handle: h, code: code, down: true); + nd.sendKey(handle: h, code: code, down: false); + if (shift) nd.sendKey(handle: h, code: 'ShiftLeft', down: false); + } + + void _typeChar(String ch) { + final spec = _codeForChar(ch); + if (spec == null) return; + _sendCode(spec.$1, shift: spec.$2); + } + + void _onKbChanged(String value) { + if (value == _kbBase) return; + if (!value.startsWith(_kbBase) || value.length < _kbBase.length) { + // The placeholder was eaten → a backspace. + _sendCode('Backspace'); + } else { + final inserted = value.substring(_kbBase.length); + for (final ch in inserted.characters) { + if (ch == '\n') { + _sendCode('Enter'); + } else { + _typeChar(ch); + } + } + } + _resetKb(); + } + + void _resetKb() { + _kbController.value = const TextEditingValue( + text: _kbBase, + selection: TextSelection.collapsed(offset: _kbBase.length), + ); + } + + /// Hardware / IME key events for non-character keys (arrows, F-keys, …). + KeyEventResult _onKey(FocusNode node, KeyEvent event) { + final code = _logicalMap[event.logicalKey]; + if (code == null) return KeyEventResult.ignored; + if (event is KeyDownEvent || event is KeyRepeatEvent) { + nd.sendKey(handle: widget.handle, code: code, down: true); + return KeyEventResult.handled; + } + if (event is KeyUpEvent) { + nd.sendKey(handle: widget.handle, code: code, down: false); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + void _toggleKeyboard() { + setState(() => _keyboardVisible = !_keyboardVisible); + if (_keyboardVisible) { + _resetKb(); + _kbFocus.requestFocus(); + } else { + _kbFocus.unfocus(); + _bodyFocus.requestFocus(); + } + } + + void _toggleMode() { + setState(() { + _mode = + _mode == _InputMode.trackpad ? _InputMode.direct : _InputMode.trackpad; + _showHint = true; + }); + _toast( + _mode == _InputMode.trackpad + ? 'Trackpad — drag to move, tap to click' + : 'Direct touch — tap where you want to click', + icon: _mode == _InputMode.trackpad + ? Icons.touch_app_rounded + : Icons.ads_click_rounded, + ); + _scheduleHintHide(); + } + + /// Maps one character to its DOM `code` + whether Shift is required. + (String, bool)? _codeForChar(String ch) { + if (ch.length != 1) return null; + final c = ch.codeUnitAt(0); + if (c >= 0x61 && c <= 0x7A) return ('Key${ch.toUpperCase()}', false); // a-z + if (c >= 0x41 && c <= 0x5A) return ('Key$ch', true); // A-Z + if (c >= 0x30 && c <= 0x39) return ('Digit$ch', false); // 0-9 + switch (ch) { + case ' ': + return ('Space', false); + case '!': + return ('Digit1', true); + case '@': + return ('Digit2', true); + case '#': + return ('Digit3', true); + case r'$': + return ('Digit4', true); + case '%': + return ('Digit5', true); + case '^': + return ('Digit6', true); + case '&': + return ('Digit7', true); + case '*': + return ('Digit8', true); + case '(': + return ('Digit9', true); + case ')': + return ('Digit0', true); + case '-': + return ('Minus', false); + case '_': + return ('Minus', true); + case '=': + return ('Equal', false); + case '+': + return ('Equal', true); + case '[': + return ('BracketLeft', false); + case '{': + return ('BracketLeft', true); + case ']': + return ('BracketRight', false); + case '}': + return ('BracketRight', true); + case '\\': + return ('Backslash', false); + case '|': + return ('Backslash', true); + case ';': + return ('Semicolon', false); + case ':': + return ('Semicolon', true); + case "'": + return ('Quote', false); + case '"': + return ('Quote', true); + case '`': + return ('Backquote', false); + case '~': + return ('Backquote', true); + case ',': + return ('Comma', false); + case '<': + return ('Comma', true); + case '.': + return ('Period', false); + case '>': + return ('Period', true); + case '/': + return ('Slash', false); + case '?': + return ('Slash', true); + } + return null; + } + + // ========================================================================== + // toolbar actions → FRB + // ========================================================================== + Future _disconnectAndPop() async { + if (!_closed) { + _closed = true; + nd.disconnect(handle: widget.handle); + } + if (mounted) Navigator.of(context).maybePop(); + } + + void _ctrlAltDel() { + nd.ctrlAltDel(handle: widget.handle); + _toast('Sent Ctrl + Alt + Del', icon: Icons.keyboard_command_key_rounded); + } + + Future _sendClipboard() async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + final text = data?.text ?? ''; + if (text.isEmpty) { + _toast('Local clipboard is empty'); + return; + } + nd.setClipboard(handle: widget.handle, text: text); + _toast('Clipboard sent to host', icon: Icons.content_paste_go_rounded); + } + + Future _showQuality() async { + const presets = <(String, String, IconData, int, int, double)>[ + ('Auto', 'Balanced 30 fps · 5 Mbps', Icons.auto_awesome_rounded, 30, 5000, + 1.0), + ('Smooth', 'High frame rate · 60 fps', Icons.speed_rounded, 60, 3500, + 0.8), + ('Sharp', 'High detail · 12 Mbps', Icons.hd_rounded, 24, 12000, 1.0), + ('Data saver', 'Low bandwidth · 720p', Icons.data_saver_on_rounded, 20, + 1500, 0.6), + ]; + await _sheet( + title: 'Quality', + children: [ + for (final p in presets) + _SheetTile( + icon: p.$3, + title: p.$1, + subtitle: p.$2, + onTap: () { + Navigator.of(context).pop(); + nd.setQuality( + handle: widget.handle, + fps: p.$4, + bitrate: p.$5, + scale: p.$6); + _toast('Quality: ${p.$1}'); + }, + ), + ], + ); + } + + Future _showMonitors() async { + // Ask the core for the displays the host actually reported — never a + // hardcoded count. The switch index is each monitor's own `i`. + final mons = nd.sessionMonitors(handle: widget.handle); + if (mons.length <= 1) { + _toast( + mons.isEmpty + ? 'The host reported no displays' + : 'The host has a single display', + icon: Icons.monitor_rounded, + ); + return; + } + await _sheet( + title: 'Displays', + children: [ + for (var idx = 0; idx < mons.length; idx++) + _SheetTile( + icon: Icons.monitor_rounded, + title: 'Monitor ${idx + 1}', + subtitle: _currentMon == mons[idx].i + ? '${mons[idx].w} × ${mons[idx].h} · Now viewing' + : '${mons[idx].w} × ${mons[idx].h}', + selected: _currentMon == mons[idx].i, + onTap: () { + Navigator.of(context).pop(); + if (_currentMon == mons[idx].i) return; // already on this one + nd.monitorSwitch(handle: widget.handle, i: mons[idx].i); + setState(() => _currentMon = mons[idx].i); + _toast('Switched to Monitor ${idx + 1}', + icon: Icons.monitor_rounded); + }, + ), + ], + ); + } + + Future _showMore() async { + await _sheet( + title: 'More', + children: [ + _SheetTile( + icon: Icons.chat_bubble_outline_rounded, + title: 'Chat', + subtitle: 'Send a message to the host', + onTap: () { + Navigator.of(context).pop(); + _openChat(); + }, + ), + _SheetTile( + icon: Icons.content_paste_go_rounded, + title: 'Send clipboard', + subtitle: 'Push this device\'s clipboard to the host', + onTap: () { + Navigator.of(context).pop(); + _sendClipboard(); + }, + ), + _SheetTile( + icon: Icons.center_focus_strong_rounded, + title: 'Reset zoom', + subtitle: 'Fit the remote screen again', + onTap: () { + Navigator.of(context).pop(); + _resetZoom(); + }, + ), + ], + ); + } + + Future _openChat() async { + final controller = TextEditingController(); + final text = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: NetDeskColors.surface, + title: const Text('Send a message'), + content: TextField( + controller: controller, + autofocus: true, + minLines: 1, + maxLines: 4, + decoration: const InputDecoration(hintText: 'Type a message…'), + onSubmitted: (v) => Navigator.of(ctx).pop(v), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(controller.text), + child: const Text('Send'), + ), + ], + ), + ); + controller.dispose(); + final msg = text?.trim() ?? ''; + if (msg.isNotEmpty) { + nd.sendChat(handle: widget.handle, text: msg); + _toast('Message sent', icon: Icons.send_rounded); + } + } + + // ========================================================================== + // helpers + // ========================================================================== + void _toast(String message, {IconData? icon}) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + backgroundColor: NetDeskColors.surface, + content: Row( + children: [ + if (icon != null) ...[ + Icon(icon, size: 18, color: NetDeskColors.accent), + const SizedBox(width: NetDeskSpacing.sm), + ], + Expanded(child: Text(message)), + ], + ), + ), + ); + } + + Future _sheet({ + required String title, + required List children, + }) { + return showModalBottomSheet( + context: context, + backgroundColor: NetDeskColors.surface, + showDragHandle: true, + shape: const RoundedRectangleBorder( + borderRadius: + BorderRadius.vertical(top: Radius.circular(NetDeskRadius.xl)), + ), + builder: (ctx) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB( + NetDeskSpacing.md, + 0, + NetDeskSpacing.md, + NetDeskSpacing.md, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: NetDeskSpacing.sm, vertical: NetDeskSpacing.xs), + child: Text( + title.toUpperCase(), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.4, + color: NetDeskColors.textMuted, + ), + ), + ), + ...children, + ], + ), + ), + ), + ); + } + + // ========================================================================== + // build + // ========================================================================== + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (didPop) return; + _disconnectAndPop(); + }, + child: Scaffold( + backgroundColor: Colors.black, + resizeToAvoidBottomInset: false, + body: Focus( + focusNode: _bodyFocus, + autofocus: true, + onKeyEvent: _onKey, + child: Stack( + children: [ + Positioned.fill(child: _buildCanvas()), + _buildHiddenKeyboard(), + if (_phase == _Phase.live) ...[ + if (_controlsVisible) _buildTopBar(), + if (_showHint) _buildHint(), + if (_controlsVisible) _buildToolbar(), + if (!_controlsVisible) _buildShowControlsButton(), + ], + if (_phase != _Phase.live) _buildStatusOverlay(), + ], + ), + ), + ), + ); + } + + Widget _buildCanvas() { + return LayoutBuilder( + builder: (context, constraints) { + _viewport = constraints.biggest; + return Listener( + onPointerDown: _onPointerDown, + onPointerMove: _onPointerMove, + onPointerUp: _onPointerUp, + onPointerHover: _onPointerHover, + onPointerSignal: _onPointerSignal, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapUp: (d) { + if (_penActive) return; // the pen's tap is a Listener click + if (_mode == _InputMode.direct) _moveTo(d.localPosition); + _click(_btnLeft); + }, + onLongPressStart: (d) { + if (_penActive) return; + if (_mode == _InputMode.direct) _moveTo(d.localPosition); + _click(_btnRight); + }, + onScaleStart: _onScaleStart, + onScaleUpdate: _onScaleUpdate, + child: ClipRect( + child: Transform.translate( + offset: _viewOffset, + child: Transform.scale( + scale: _viewScale, + alignment: Alignment.topLeft, + child: _image == null + ? const SizedBox.expand() + : CustomPaint( + size: _viewport, + painter: _RemotePainter( + image: _image!, + cursor: _cursor, + remoteW: _remoteW, + remoteH: _remoteH, + ), + ), + ), + ), + ), + ), + ); + }, + ); + } + + Widget _buildTopBar() { + return Positioned( + top: 0, + left: 0, + right: 0, + child: IgnorePointer( + child: Container( + padding: EdgeInsets.fromLTRB( + NetDeskSpacing.md, + MediaQuery.of(context).padding.top + NetDeskSpacing.sm, + NetDeskSpacing.md, + NetDeskSpacing.md, + ), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xCC000000), Color(0x00000000)], + ), + ), + child: Row( + children: [ + const _ConnectedDot(), + const SizedBox(width: NetDeskSpacing.sm), + Expanded( + child: Text( + _hostName.isEmpty ? widget.partnerId : _hostName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: NetDeskColors.textPrimary, + ), + ), + ), + if (_e2e) + Row( + children: [ + Icon(Icons.lock_rounded, + size: 13, + color: NetDeskColors.accent.withValues(alpha: 0.9)), + const SizedBox(width: 4), + const Text( + 'E2E', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + color: NetDeskColors.accent, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildHint() { + return Positioned( + left: 0, + right: 0, + top: MediaQuery.of(context).padding.top + 48, + child: IgnorePointer( + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: NetDeskSpacing.md, vertical: NetDeskSpacing.sm), + decoration: BoxDecoration( + color: NetDeskColors.surface.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: NetDeskColors.border), + ), + child: Text( + _mode == _InputMode.trackpad + ? 'Trackpad · drag to move · tap to click · hold = right-click' + : 'Direct touch · tap the screen where you want to click', + style: const TextStyle( + fontSize: 12, color: NetDeskColors.textSecondary), + ), + ), + ), + ), + ); + } + + Widget _buildToolbar() { + return Positioned( + left: 0, + right: 0, + bottom: MediaQuery.of(context).padding.bottom + NetDeskSpacing.md, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), + decoration: BoxDecoration( + color: NetDeskColors.surface.withValues(alpha: 0.96), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: NetDeskColors.border), + boxShadow: const [ + BoxShadow(color: Color(0x66000000), blurRadius: 24, spreadRadius: 2), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ToolButton( + icon: Icons.call_end_rounded, + tooltip: 'Disconnect', + color: NetDeskColors.danger, + onPressed: _disconnectAndPop, + ), + _ToolButton( + icon: _keyboardVisible + ? Icons.keyboard_hide_rounded + : Icons.keyboard_rounded, + tooltip: 'Keyboard', + active: _keyboardVisible, + onPressed: _toggleKeyboard, + ), + _ToolButton( + icon: _mode == _InputMode.trackpad + ? Icons.touch_app_rounded + : Icons.ads_click_rounded, + tooltip: _mode == _InputMode.trackpad + ? 'Trackpad mode' + : 'Direct touch', + active: _mode == _InputMode.direct, + onPressed: _toggleMode, + ), + _ToolButton( + icon: Icons.monitor_rounded, + tooltip: 'Monitors', + onPressed: _showMonitors, + ), + _ToolButton( + icon: Icons.tune_rounded, + tooltip: 'Quality', + onPressed: _showQuality, + ), + _ToolButton( + icon: Icons.keyboard_command_key_rounded, + tooltip: 'Ctrl + Alt + Del', + onPressed: _ctrlAltDel, + ), + _ToolButton( + icon: Icons.more_horiz_rounded, + tooltip: 'More', + onPressed: _showMore, + ), + _ToolButton( + icon: Icons.fullscreen_rounded, + tooltip: 'Full screen (hide controls)', + onPressed: _toggleControls, + ), + ], + ), + ), + ), + ); + } + + /// Hide/show the top bar + toolbar for a pure full-screen remote view. + void _toggleControls() { + setState(() => _controlsVisible = !_controlsVisible); + } + + /// A small, unobtrusive handle shown while controls are hidden, to bring the + /// toolbar back without interfering with the trackpad/touch area. + Widget _buildShowControlsButton() { + return Positioned( + top: MediaQuery.of(context).padding.top + 6, + right: 8, + child: SafeArea( + child: Material( + color: Colors.black.withValues(alpha: 0.35), + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: IconButton( + iconSize: 20, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.fullscreen_exit_rounded, color: Colors.white70), + tooltip: 'Show controls', + onPressed: _toggleControls, + ), + ), + ), + ); + } + + Widget _buildHiddenKeyboard() { + return Positioned( + left: 0, + bottom: 0, + width: 1, + height: 1, + child: Opacity( + opacity: 0, + child: TextField( + focusNode: _kbFocus, + controller: _kbController, + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.text, + textCapitalization: TextCapitalization.none, + textInputAction: TextInputAction.done, + cursorColor: Colors.transparent, + style: const TextStyle(color: Colors.transparent, fontSize: 1), + decoration: const InputDecoration( + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + onChanged: _onKbChanged, + onSubmitted: (_) { + _sendCode('Enter'); + _resetKb(); + _kbFocus.requestFocus(); + }, + ), + ), + ); + } + + Widget _buildStatusOverlay() { + final connecting = _phase == _Phase.connecting; + final denied = _phase == _Phase.denied; + return Positioned.fill( + child: Container( + color: NetDeskColors.background.withValues(alpha: 0.96), + padding: const EdgeInsets.all(NetDeskSpacing.xl), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (connecting) ...[ + const SizedBox( + width: 44, + height: 44, + child: CircularProgressIndicator( + strokeWidth: 3, color: NetDeskColors.accent), + ), + const SizedBox(height: NetDeskSpacing.lg), + Text( + 'Connecting to ${widget.partnerId}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: NetDeskColors.textPrimary, + ), + ), + const SizedBox(height: NetDeskSpacing.xs), + const Text( + 'Negotiating a secure session…', + style: + TextStyle(fontSize: 13, color: NetDeskColors.textMuted), + ), + ] else ...[ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: (denied ? NetDeskColors.danger : NetDeskColors.accent) + .withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: Icon( + denied + ? Icons.gpp_bad_rounded + : Icons.link_off_rounded, + size: 34, + color: + denied ? NetDeskColors.danger : NetDeskColors.textSecondary, + ), + ), + const SizedBox(height: NetDeskSpacing.lg), + Text( + denied ? 'Access denied' : 'Session ended', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: NetDeskColors.textPrimary, + ), + ), + const SizedBox(height: NetDeskSpacing.xs), + Text( + _error ?? 'The connection was closed.', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 13, color: NetDeskColors.textSecondary), + ), + const SizedBox(height: NetDeskSpacing.xl), + SizedBox( + width: 220, + child: FilledButton.icon( + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon(Icons.arrow_back_rounded, size: 18), + label: const Text('Back'), + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +/// A small pulsing green dot indicating an active session. +class _ConnectedDot extends StatelessWidget { + const _ConnectedDot(); + + @override + Widget build(BuildContext context) { + return Container( + width: 9, + height: 9, + decoration: const BoxDecoration( + color: NetDeskColors.accent, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: NetDeskColors.accentGlow, blurRadius: 8, spreadRadius: 2), + ], + ), + ); + } +} + +/// A round toolbar button. +class _ToolButton extends StatelessWidget { + const _ToolButton({ + required this.icon, + required this.tooltip, + required this.onPressed, + this.color, + this.active = false, + }); + + final IconData icon; + final String tooltip; + final VoidCallback onPressed; + final Color? color; + final bool active; + + @override + Widget build(BuildContext context) { + final fg = color ?? + (active ? NetDeskColors.accent : NetDeskColors.textSecondary); + return Tooltip( + message: tooltip, + child: Material( + color: active + ? NetDeskColors.accentGlow + : Colors.transparent, + shape: const CircleBorder(), + child: InkWell( + onTap: onPressed, + customBorder: const CircleBorder(), + child: Padding( + padding: const EdgeInsets.all(11), + child: Icon(icon, size: 22, color: fg), + ), + ), + ), + ); + } +} + +/// A row in a bottom-sheet menu. +class _SheetTile extends StatelessWidget { + const _SheetTile({ + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + this.selected = false, + this.trailing, + }); + + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + + /// Highlight this row as the current choice (e.g. the active monitor). + final bool selected; + + /// Override the trailing glyph; defaults to a chevron, or a check when + /// [selected]. Pass [Icons.check_circle_rounded] etc. explicitly if needed. + final IconData? trailing; + + @override + Widget build(BuildContext context) { + final accent = NetDeskColors.accent; + final trailingIcon = trailing ?? + (selected ? Icons.check_circle_rounded : Icons.chevron_right_rounded); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Material( + color: selected + ? Color.alphaBlend(accent.withValues(alpha: 0.14), + NetDeskColors.surfaceAlt) + : NetDeskColors.surfaceAlt, + borderRadius: BorderRadius.circular(NetDeskRadius.md), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(NetDeskRadius.md), + child: Container( + decoration: selected + ? BoxDecoration( + borderRadius: BorderRadius.circular(NetDeskRadius.md), + border: Border.all(color: accent.withValues(alpha: 0.55)), + ) + : null, + padding: const EdgeInsets.symmetric( + horizontal: NetDeskSpacing.md, vertical: 14), + child: Row( + children: [ + Icon(icon, color: accent, size: 22), + const SizedBox(width: NetDeskSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: NetDeskColors.textPrimary, + ), + ), + const SizedBox(height: 1), + Text( + subtitle, + style: const TextStyle( + fontSize: 12, color: NetDeskColors.textMuted), + ), + ], + ), + ), + Icon(trailingIcon, + color: selected ? accent : NetDeskColors.textMuted, + size: 20), + ], + ), + ), + ), + ), + ); + } +} + +/// Paints the latest remote frame fit-to-box plus the virtual touchpad cursor. +class _RemotePainter extends CustomPainter { + _RemotePainter({ + required this.image, + required this.cursor, + required this.remoteW, + required this.remoteH, + }); + + final ui.Image image; + final Offset cursor; + final int remoteW; + final int remoteH; + + @override + void paint(Canvas canvas, Size size) { + final imgSize = Size(image.width.toDouble(), image.height.toDouble()); + final fitted = applyBoxFit(BoxFit.contain, imgSize, size); + final dst = Alignment.center.inscribe(fitted.destination, Offset.zero & size); + final src = Alignment.center.inscribe(fitted.source, Offset.zero & imgSize); + + canvas.drawImageRect( + image, + src, + dst, + Paint() + ..filterQuality = FilterQuality.medium + ..isAntiAlias = true, + ); + + if (remoteW > 0 && remoteH > 0) { + final cx = dst.left + (cursor.dx / remoteW) * dst.width; + final cy = dst.top + (cursor.dy / remoteH) * dst.height; + _drawCursor(canvas, Offset(cx, cy)); + } + } + + void _drawCursor(Canvas canvas, Offset p) { + // A classic arrow pointer with the tip exactly at p — white fill with a dark + // outline so it stays visible on any background (not a target/crosshair). + final path = Path() + ..moveTo(p.dx, p.dy) + ..lineTo(p.dx, p.dy + 17.5) + ..lineTo(p.dx + 4.4, p.dy + 13.4) + ..lineTo(p.dx + 7.4, p.dy + 20.0) + ..lineTo(p.dx + 10.2, p.dy + 18.7) + ..lineTo(p.dx + 7.3, p.dy + 12.2) + ..lineTo(p.dx + 12.8, p.dy + 12.2) + ..close(); + canvas.drawPath( + path, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.4 + ..strokeJoin = StrokeJoin.round + ..color = const Color(0xD0000000), + ); + canvas.drawPath(path, Paint()..color = Colors.white); + } + + @override + bool shouldRepaint(_RemotePainter old) => + old.image != image || + old.cursor != cursor || + old.remoteW != remoteW || + old.remoteH != remoteH; +} diff --git a/native/ui/lib/theme.dart b/native/ui/lib/theme.dart new file mode 100644 index 0000000..f1c411d --- /dev/null +++ b/native/ui/lib/theme.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// NetDesk design tokens + [ThemeData]. +/// +/// A single, dark, premium theme in the spirit of TeamViewer / RustDesk — +/// deep near-black surfaces, a confident green accent, generous spacing and +/// softly rounded cards. All colors live here as tokens so the rest of the +/// app never hardcodes a hex value. +class NetDeskColors { + NetDeskColors._(); + + /// App background — deep, near-black with a cool blue cast. + static const Color background = Color(0xFF0B0F15); + + /// Raised surface (cards, panels) — one step lighter than the background. + static const Color surface = Color(0xFF121821); + + /// A second elevation used for inputs / inset wells. + static const Color surfaceAlt = Color(0xFF0E141C); + + /// Hairline borders and dividers. + static const Color border = Color(0xFF1E2733); + + /// Primary brand accent — a vivid, trustworthy green. + static const Color accent = Color(0xFF22C55E); + + /// Darker accent for pressed / gradient ends. + static const Color accentDeep = Color(0xFF16A34A); + + /// Soft accent glow used sparingly behind the wordmark. + static const Color accentGlow = Color(0x3322C55E); + + /// High-emphasis text. + static const Color textPrimary = Color(0xFFF2F5F9); + + /// Medium-emphasis text (subtitles, labels). + static const Color textSecondary = Color(0xFF9AA7B8); + + /// Low-emphasis text (hints, captions). + static const Color textMuted = Color(0xFF5E6B7D); + + /// Destructive / disconnect actions. + static const Color danger = Color(0xFFEF4444); +} + +class NetDeskRadius { + NetDeskRadius._(); + static const double sm = 8; + static const double md = 12; + static const double lg = 16; + static const double xl = 22; +} + +class NetDeskSpacing { + NetDeskSpacing._(); + static const double xs = 6; + static const double sm = 10; + static const double md = 16; + static const double lg = 24; + static const double xl = 36; +} + +class NetDeskTheme { + NetDeskTheme._(); + + static ThemeData get dark { + const scheme = ColorScheme.dark( + primary: NetDeskColors.accent, + onPrimary: Color(0xFF04210F), + secondary: NetDeskColors.accentDeep, + onSecondary: Color(0xFF04210F), + surface: NetDeskColors.surface, + onSurface: NetDeskColors.textPrimary, + error: NetDeskColors.danger, + onError: Colors.white, + outline: NetDeskColors.border, + ); + + final base = ThemeData.dark(useMaterial3: true); + + return base.copyWith( + colorScheme: scheme, + scaffoldBackgroundColor: NetDeskColors.background, + canvasColor: NetDeskColors.background, + dividerColor: NetDeskColors.border, + splashFactory: InkSparkle.splashFactory, + + textTheme: base.textTheme + .apply( + bodyColor: NetDeskColors.textPrimary, + displayColor: NetDeskColors.textPrimary, + fontFamily: 'Roboto', + ) + .copyWith( + bodyMedium: const TextStyle( + color: NetDeskColors.textSecondary, + height: 1.4, + ), + labelLarge: const TextStyle( + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), + + appBarTheme: const AppBarTheme( + backgroundColor: NetDeskColors.surfaceAlt, + foregroundColor: NetDeskColors.textPrimary, + elevation: 0, + centerTitle: false, + titleTextStyle: TextStyle( + color: NetDeskColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + systemOverlayStyle: SystemUiOverlayStyle.light, + ), + + cardTheme: CardThemeData( + color: NetDeskColors.surface, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + side: const BorderSide(color: NetDeskColors.border), + borderRadius: BorderRadius.circular(NetDeskRadius.xl), + ), + ), + + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: NetDeskColors.surfaceAlt, + hintStyle: const TextStyle(color: NetDeskColors.textMuted), + labelStyle: const TextStyle(color: NetDeskColors.textSecondary), + contentPadding: const EdgeInsets.symmetric( + horizontal: NetDeskSpacing.md, + vertical: 18, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(NetDeskRadius.md), + borderSide: const BorderSide(color: NetDeskColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(NetDeskRadius.md), + borderSide: const BorderSide(color: NetDeskColors.accent, width: 1.6), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(NetDeskRadius.md), + borderSide: const BorderSide(color: NetDeskColors.danger), + ), + ), + + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: NetDeskColors.accent, + foregroundColor: const Color(0xFF04210F), + disabledBackgroundColor: NetDeskColors.border, + disabledForegroundColor: NetDeskColors.textMuted, + minimumSize: const Size.fromHeight(54), + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + letterSpacing: 0.3, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(NetDeskRadius.md), + ), + ), + ), + + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom( + foregroundColor: NetDeskColors.textSecondary, + hoverColor: NetDeskColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(NetDeskRadius.sm), + ), + ), + ), + + tooltipTheme: const TooltipThemeData( + decoration: BoxDecoration( + color: Color(0xFF1B2430), + borderRadius: BorderRadius.all(Radius.circular(NetDeskRadius.sm)), + ), + textStyle: TextStyle(color: NetDeskColors.textPrimary, fontSize: 12), + ), + + dividerTheme: const DividerThemeData( + color: NetDeskColors.border, + thickness: 1, + space: 1, + ), + ); + } +} diff --git a/native/ui/pubspec.lock b/native/ui/pubspec.lock new file mode 100644 index 0000000..799b8a4 --- /dev/null +++ b/native/ui/pubspec.lock @@ -0,0 +1,605 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.dev" + source: hosted + version: "85.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + url: "https://pub.dev" + source: hosted + version: "7.7.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: e1d40ef3f7934986d5da2271b1ba07794921ce263e44d622fb6c406d76589e33 + url: "https://pub.dev" + source: hosted + version: "4.1.6" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" + url: "https://pub.dev" + source: hosted + version: "9.1.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "44922ecebd8e3d0cef743dadd3b54df4e87e2a4c52a29465a1872aa9e192caa8" + url: "https://pub.dev" + source: hosted + version: "5.1.2" + built_value: + dependency: transitive + description: + name: built_value + sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7 + url: "https://pub.dev" + source: hosted + version: "8.13.0" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" + source: hosted + version: "1.1.3" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: aa5932e94c6c39c2f9ec4e5e06dfdd11a9430a61f6c41b6ba75b28ce0c481baf + url: "https://pub.dev" + source: hosted + version: "4.12.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: "direct main" + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + flutter_rust_bridge: + dependency: "direct main" + description: + name: flutter_rust_bridge + sha256: ec69331e2335ab4d456c0a9fc700892c2c85b72b9c0cb39df3ecfe8833d5d5c8 + url: "https://pub.dev" + source: hosted + version: "2.13.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c" + url: "https://pub.dev" + source: hosted + version: "2.5.8" + freezed_annotation: + dependency: "direct main" + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.dev" + source: hosted + version: "2.4.4" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: "404dbf9bce29beafbe9dcb19219447b6f7d49900cd42b8d3b7af102b24508e25" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + glob: + dependency: transitive + description: + name: glob + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + url: "https://pub.dev" + source: hosted + version: "1.18.3" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" + url: "https://pub.dev" + source: hosted + version: "1.5.3" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: c38b81cbf34450b67e0265d73433569d12e34782e30ed769c9cc99c9d5f2e796 + url: "https://pub.dev" + source: hosted + version: "1.6.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.27.0" diff --git a/native/ui/pubspec.yaml b/native/ui/pubspec.yaml new file mode 100644 index 0000000..9e471b8 --- /dev/null +++ b/native/ui/pubspec.yaml @@ -0,0 +1,43 @@ +name: netdesk +description: NetDesk by Akronlogic — premium cross-platform remote desktop front-end. +publish_to: "none" +version: 0.3.2+7 + +environment: + # Dart 3.6 / Flutter 3.27 for the non-deprecated Color.withValues() API. + sdk: ">=3.6.0 <4.0.0" + flutter: ">=3.27.0" + +dependencies: + flutter: + sdk: flutter + # Cupertino icons are handy for the cross-platform (iOS/macOS) builds. + cupertino_icons: ^1.0.8 + + # --------------------------------------------------------------------------- + # PHASE 3 (Rust core binding). The UI binds to the Rust `netdesk-frb` crate + # (over `netdesk-core`) through flutter_rust_bridge. The typed Dart<->Rust FFI + # glue under lib/rust/ is produced by `flutter_rust_bridge_codegen generate` + # (config: ../frb/flutter_rust_bridge.yaml). Pinned to the installed codegen. + flutter_rust_bridge: 2.13.0 + ffi: ^2.1.0 + # FRB generates @freezed sealed classes for the Rust enums (e.g. NdEvent); + # freezed_annotation is the runtime side of that. + freezed_annotation: ^2.4.4 + # --------------------------------------------------------------------------- + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + # Code generators for the FRB Dart bindings: `freezed` builds the sealed + # classes, driven by `build_runner` (flutter pub run build_runner build). + freezed: ^2.5.7 + build_runner: ^2.4.13 + +flutter: + uses-material-design: true + # Brand marks (NetDesk by Akronlogic) used by the connect screen. + assets: + - assets/icon.png + - assets/icon-512.png diff --git a/native/ui/test/widget_test.dart b/native/ui/test/widget_test.dart new file mode 100644 index 0000000..c788b7a --- /dev/null +++ b/native/ui/test/widget_test.dart @@ -0,0 +1,22 @@ +// Smoke test for the NetDesk connect screen. +// +// Builds [NetDeskApp] directly (bypassing `main`, which would otherwise try to +// load the native Rust engine) and verifies the brand mark and primary action +// render. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:netdesk/main.dart'; + +void main() { + testWidgets('Connect screen shows the NetDesk brand and Connect button', + (WidgetTester tester) async { + await tester.pumpWidget(const NetDeskApp()); + + expect(find.text('NetDesk'), findsOneWidget); + expect(find.text('by Akronlogic'), findsOneWidget); + expect(find.text('Connect'), findsOneWidget); + expect(find.byType(FilledButton), findsWidgets); + }); +} diff --git a/native/viewer/Cargo.toml b/native/viewer/Cargo.toml new file mode 100644 index 0000000..25f3021 --- /dev/null +++ b/native/viewer/Cargo.toml @@ -0,0 +1,64 @@ +[package] +name = "netdesk-viewer" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +description = "NetDesk native viewer window — GPU-filtered (bilinear) remote-screen rendering with a full TeamViewer/RustDesk-style toolbar, built on eframe + egui (winit + glow). Developed by Akronlogic." + +[[bin]] +name = "netdesk-viewer" +path = "src/main.rs" + +[dependencies] +netdesk-core = { path = "../core" } +tokio = { workspace = true } +anyhow = { workspace = true } +# control-protocol payloads (session.info / sys.res) arrive as JSON strings from +# the core; parse them in the net thread into clean Rust before the UI sees them. +serde_json = { workspace = true } +# host-control module (host_ctl.rs) models the shared config.json with derive. +serde = { workspace = true } +# host-control module: blocking HTTPS (rustls) to the relay /api — account +# sign-in / sign-up / sign-out, device list, and /health ping. Enable the +# "json" feature for send_json / into_json (tls + gzip are on by default). +ureq = { version = "2.10", features = ["json"] } +# eframe bundles winit + the GPU backend (glow/OpenGL) + the event loop, and egui +# gives us a real toolbar/menus over the remote image. egui TextureOptions::LINEAR +# = bilinear GPU filtering = smooth + clear scaling of the decoded frame. +eframe = { version = "0.29", default-features = false, features = ["glow", "default_fonts"] } +egui = "0.29" +# OS clipboard read/write for RustDesk-style clipboard sync (egui can set but not +# read the clipboard; arboard does both). Already in the tree via eframe. +arboard = "3" +# decode the embedded NetDesk logo PNG into pixels (window icon + in-app logo +# texture) and encode the live frame to PNG for "Save screenshot". +image = { version = "0.25", default-features = false, features = ["png"] } +# native OS file-picker for the File transfer panel's "Upload…" button (pick a +# local file to send to the host). Sync FileDialog only, so no async runtime +# feature is needed; the Linux-backend features are irrelevant on Windows. +rfd = { version = "0.15", default-features = false } +# pure-Rust QR encoder (Nayuki) for in-app TOTP 2FA enrollment — render the +# host's otpauth:// secret as a QR the user scans with an authenticator app. +qrcodegen = "1.8" +# crisp, consistent icon set (web / copy / folder / lock / power / arrows / …) so +# every button shows a real icon instead of a missing-glyph box. Pure-Rust +# embedded font (Phosphor), no C build; matches egui 0.29. +egui-phosphor = "0.7" +# logging for the headless --host diagnostics +env_logger = "0.11" +# NOTE: remote audio playback ("Play remote computer sound") is implemented in +# src/audio.rs (cpal + Opus) but not yet wired in — the libopus build on this +# windows-gnu toolchain needs a reliable Opus source (audiopus_sys 0.1.8's build +# script mangles extended-length paths). Re-enable cpal + an Opus decoder here +# and `mod audio;` in main.rs once that is sorted. + +# Unified app: the native Rust host engine (capture+encode+SendInput) runs +# IN-PROCESS when the user clicks Start Service, so one NetDesk.exe both views +# and hosts — no Python. Windows-only (DXGI/MediaFoundation/SendInput live here). +[target.'cfg(windows)'.dependencies] +netdesk-host = { path = "../host" } +# Remote audio playback (src/audio.rs): Opus decode via the vendored libopus +# + cpal (WASAPI) output. +netdesk-opus = { path = "../opus" } +cpal = "0.15" diff --git a/native/viewer/build.rs b/native/viewer/build.rs new file mode 100644 index 0000000..81237a2 --- /dev/null +++ b/native/viewer/build.rs @@ -0,0 +1,74 @@ +//! Build script for the NetDesk native viewer. +//! +//! Embeds the NetDesk application icon (`assets/icon.ico`, multi-size) into the +//! Windows executable so Explorer, the taskbar and Alt-Tab all show the branded +//! green NetDesk mark. On the `x86_64-pc-windows-gnu` toolchain used for the +//! portable build, the resource is compiled with `windres` (shipped in the +//! MinGW bin dir that is on `PATH` during the workflow build) into a COFF object +//! which is then forced onto the final binary link line. No extra crates are +//! required — this keeps the build hermetic and offline-safe. +//! +//! If `windres` is unavailable the build still succeeds (a warning is emitted); +//! the runtime `ViewportBuilder::with_icon` call in `main.rs` still gives the +//! window + taskbar the icon, only the on-disk `.exe` file icon is skipped. + +use std::path::PathBuf; +use std::process::Command; + +fn main() { + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + // The .ico resource only applies to Windows PE binaries. + if target_os != "windows" { + return; + } + + let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let ico = manifest.join("..").join("assets").join("icon.ico"); + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed={}", ico.display()); + + if !ico.exists() { + println!( + "cargo:warning=NetDesk icon not found at {} — exe icon not embedded", + ico.display() + ); + return; + } + + let out = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let rc_path = out.join("netdesk.rc"); + let obj_path = out.join("netdesk_icon.o"); + + // windres wants forward slashes inside the quoted path (backslashes are + // treated as escapes in the .rc grammar). Resource id 1 => the app's + // default icon group, which is what Explorer/Shell pick up. + let ico_fwd = ico.display().to_string().replace('\\', "/"); + if std::fs::write(&rc_path, format!("1 ICON \"{ico_fwd}\"\n")).is_err() { + println!("cargo:warning=could not write {} — exe icon not embedded", rc_path.display()); + return; + } + + match Command::new("windres") + .arg(&rc_path) + .args(["-O", "coff", "-o"]) + .arg(&obj_path) + .status() + { + Ok(status) if status.success() => { + // -bins: attach the resource object only to binary targets, and force + // it onto the link line (resources have no referenced symbols, so a + // plain static lib could be dropped — a direct link-arg cannot). + println!("cargo:rustc-link-arg-bins={}", obj_path.display()); + } + Ok(status) => { + println!( + "cargo:warning=windres exited with {status} — exe icon not embedded (window icon still set at runtime)" + ); + } + Err(e) => { + println!( + "cargo:warning=windres not found ({e}) — exe icon not embedded (window icon still set at runtime)" + ); + } + } +} diff --git a/native/viewer/src/audio.rs b/native/viewer/src/audio.rs new file mode 100644 index 0000000..d599ba7 --- /dev/null +++ b/native/viewer/src/audio.rs @@ -0,0 +1,94 @@ +//! Remote audio playback — "Play remote computer sound". +//! +//! The host streams its system audio as Opus (CHAN_AUDIO `0xa1`), one 20 ms +//! frame at 48 kHz stereo per packet — exactly what the web viewer decodes with +//! WebCodecs. Here we decode each Opus packet with libopus (via the vendored +//! `netdesk-opus` crate) and play the PCM through the default output device with +//! `cpal` (WASAPI on Windows). A short ring buffer bridges the decode side (net +//! thread) and the audio callback (cpal's own thread) and caps latency. +//! +//! Developed by Akronlogic. + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use netdesk_opus::Decoder; + +const SR: u32 = 48_000; +const CH: usize = 2; + +/// Decodes the host's Opus audio and plays it. Keep the instance alive for the +/// whole session — dropping it stops the output stream. +pub struct AudioPlayer { + _stream: cpal::Stream, // keep alive: dropping stops playback + decoder: Decoder, + ring: Arc>>, +} + +impl AudioPlayer { + /// Open the default output device at 48 kHz stereo and start playing silence. + pub fn new() -> anyhow::Result { + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or_else(|| anyhow::anyhow!("no default audio output device"))?; + + let config = cpal::StreamConfig { + channels: CH as u16, + sample_rate: cpal::SampleRate(SR), + buffer_size: cpal::BufferSize::Default, + }; + + let ring: Arc>> = + Arc::new(Mutex::new(VecDeque::with_capacity(SR as usize))); + let ring_cb = ring.clone(); + + let stream = device.build_output_stream( + &config, + move |out: &mut [f32], _: &cpal::OutputCallbackInfo| match ring_cb.lock() { + Ok(mut r) => { + for s in out.iter_mut() { + *s = r.pop_front().unwrap_or(0.0); + } + } + Err(_) => { + for s in out.iter_mut() { + *s = 0.0; + } + } + }, + move |err| eprintln!("[audio] output stream error: {err}"), + None, + )?; + stream.play()?; + + let decoder = Decoder::new(SR, CH as u32)?; + Ok(Self { + _stream: stream, + decoder, + ring, + }) + } + + /// Decode one Opus packet and enqueue the resulting PCM for playback. + /// Silently drops malformed/undecodable packets (best-effort, like the web + /// viewer). + pub fn push_opus(&mut self, data: &[u8]) { + let pcm = match self.decoder.decode(data) { + Ok(p) => p, // interleaved i16, stereo + Err(_) => return, + }; + if let Ok(mut r) = self.ring.lock() { + for &s in &pcm { + r.push_back(s as f32 / 32768.0); + } + // Cap buffered audio to ~400 ms so playback can't drift far behind + // the video (drop the oldest samples on overrun). + let cap = SR as usize * CH * 2 / 5; + while r.len() > cap { + r.pop_front(); + } + } + } +} diff --git a/native/viewer/src/home.rs b/native/viewer/src/home.rs new file mode 100644 index 0000000..b8cb1a3 --- /dev/null +++ b/native/viewer/src/home.rs @@ -0,0 +1,2905 @@ +//! NetDesk HOME / HOST PANEL — the enterprise management console. +//! +//! This is the default view of the unified NetDesk app (the app acts as a HOST +//! here; it switches to the in-session VIEWER when the user connects out to a +//! partner). The panel mirrors `netdesk/host/netdesk_gui.html` pixel-for-pixel +//! in layout and palette, but is painted natively in `egui` with a bespoke +//! 3D / depth treatment that flat immediate-mode UI does not give for free: +//! +//! * Layered soft **drop shadows** behind every card and primary button +//! (`egui::epaint::Shadow`), growing on hover for a tactile **lift**. +//! * **Vertical gradient fills** painted as a rounded-rect vertex-colour +//! **mesh** (`rounded_vgrad`): cards are faintly lighter at the top; the +//! primary buttons carry the NetDesk green `#16a765 -> #22d084` ramp with a +//! soft outer **glow**; the big ID number gets a subtle green glow. +//! * A 1px **inner-highlight bevel** along the top edge of each card for a +//! raised, glassy feel. +//! * Raised, tactile **toggle switches** with a soft-shadowed knob and an +//! on-state green glow. +//! +//! Every control is wired to `crate::host_ctl` (the native port of the Python +//! agent's `Api`): real config.json read/write, live engine status, Start/Stop +//! (pause/resume — the Scheduled-Task auto-start is never touched), the +//! unattended password (PBKDF2-200k), 2FA, the five switches, account +//! sign-in/up and the device fleet, and the relay ping. Blocking work runs on +//! short-lived worker threads and reports back over an `mpsc` channel so the +//! egui frame never stalls. +//! +//! Developed by Akronlogic.com. + +use std::sync::mpsc::{channel, Receiver, Sender}; +use std::time::{Duration, Instant}; + +use eframe::egui; +use egui::{ + epaint::Shadow, Align, Color32, FontId, Layout, Margin, Mesh, Pos2, Rect, RichText, Rounding, + Sense, Shape, Stroke, TextEdit, Ui, Vec2, +}; + +use crate::host_ctl::{self, Device, HostConfig, HostState}; +use egui_phosphor::regular as ph; + +// ============================ PALETTE (HOST-PANEL.md) ====================== +const BG_TOP: Color32 = Color32::from_rgb(11, 15, 21); // #0b0f15 +const BG_BOT: Color32 = Color32::from_rgb(6, 8, 12); // #06080c +const TOPBAR_BG: Color32 = Color32::from_rgb(10, 14, 19); // #0a0e13 +const SIDEBAR_BG: Color32 = Color32::from_rgb(13, 17, 23); // #0d1117 +const CARD_TOP: Color32 = Color32::from_rgb(28, 36, 47); // lighter top of card gradient +const CARD_BOT: Color32 = Color32::from_rgb(21, 28, 37); // darker bottom +const CARD_BORDER: Color32 = Color32::from_rgb(38, 48, 63); // #26303f +const CARD_BORDER_HI: Color32 = Color32::from_rgb(49, 63, 82); // hover border +const INPUT_BG: Color32 = Color32::from_rgb(15, 20, 27); // #0f141b +const INPUT_BORDER: Color32 = Color32::from_rgb(37, 47, 61); // #252f3d +const PANEL_INSET: Color32 = Color32::from_rgb(19, 25, 34); // #131922 side-info / chips + +// The accent greens are now runtime fns (brand accent from brand.json): see +// crate::c_accent() + its hi/lo variants in viewer main.rs. + +const FG: Color32 = Color32::from_rgb(230, 237, 245); // #e6edf5 +const FG_SOFT: Color32 = Color32::from_rgb(208, 216, 226); // #d0d8e2 +const MUTED: Color32 = Color32::from_rgb(139, 151, 168); // #8b97a8 +const FAINT: Color32 = Color32::from_rgb(100, 116, 139); // #64748b +const BLUE: Color32 = Color32::from_rgb(59, 158, 255); // #3b9eff + +const CRIMSON_BG: Color32 = Color32::from_rgb(50, 28, 34); // #321c22 +const CRIMSON_BD: Color32 = Color32::from_rgb(124, 49, 57); // #7c3139 +const CRIMSON_FG: Color32 = Color32::from_rgb(224, 90, 97); // #e05a61 +const GREY_BTN_TOP: Color32 = Color32::from_rgb(90, 100, 114); +const GREY_BTN_BOT: Color32 = Color32::from_rgb(72, 81, 94); + +// ======================= LAYOUT METRICS (netdesk_gui.html) ================= +// The original HTML renders at ~0.82 CSS zoom in its default ~1030x700 window +// (fitZoom keeps an effective 1080x850 canvas). These logical sizes reproduce +// the ORIGINAL'S RENDERED density at that window, so the whole Remote Control +// page fits 700px of height with no scrolling: +// topbar 42 + gutter 10 + bar 34 +10+ grid 389 +10+ start 44 +10+ +// toggles 86 +10+ foot 38 + gutter 8 = 691 ≤ 700. +const PAD: f32 = 13.0; // card inner padding (CSS 16px; trimmed to fit no-scroll) +const GAP: f32 = 8.0; // major row / column gap (CSS 14px; trimmed to fit no-scroll) +const FIELD_H: f32 = 31.0; // text input height (CSS 38px ≈ 31px rendered) +const HEAD_H: f32 = 22.0; // card-head row (small-caps title + right pill) +const BAR_H: f32 = 34.0; // connect-to-partner bar (CSS ~38px) +const BTN_H: f32 = 44.0; // Start / Stop row (CSS 44px) +const FOOT_H: f32 = 38.0; // footer buttons (CSS 40px) + +// ============================ ROUTING RETURN =============================== + +/// What the Home panel asks the hosting app to do after a frame. The only +/// cross-view action is "connect out to this partner" which flips the app into +/// the Connecting/Session route. +pub enum HomeAction { + None, + Connect { id: String }, +} + +// ============================ SIDEBAR NAV ================================== +#[derive(Clone, Copy, PartialEq, Eq)] +enum Nav { + Remote, + Computers, + Security, + Logs, +} + +// ============================ WORKER PLUMBING ============================== +#[derive(Clone, Copy, PartialEq, Eq)] +enum Busy { + Service, + Account, + Pw, + None, +} + +/// Result of a background op that may have mutated the config. +struct CfgResult { + cfg: Option, + toast: Option<(String, String)>, + modal: Option<(String, String)>, + busy: Busy, + reveal_pw: bool, +} + +enum Work { + Ping(std::result::Result), + Devices(std::result::Result, String>), + Cfg(CfgResult), +} + +// ============================ HOME STATE =================================== +pub struct HomeState { + cfg: HostConfig, + cfg_err: Option, + state: HostState, + last_snap: Instant, + + nav: Nav, + + // forms + partner_id: String, + pw_input: String, + pw_show: bool, + server_in: String, + reg_in: String, + name_in: String, + acc_email: String, + acc_pass: String, + allow_in: String, + /// Cached QR texture for in-app TOTP enrollment, keyed by the secret. + totp_qr: Option<(String, egui::TextureHandle)>, + id_spaced: bool, + + // relay ping + ping_ms: Option, + relay_offline: bool, + ping_fails: u32, + last_ping: Instant, + + // fleet + devices: Vec, + dev_err: Option, + dev_q: String, + dev_filter: u8, // 0 all, 1 online, 2 offline + last_dev: Instant, + + // logs + log_lines: Vec, + log_loaded: bool, + + // busy flags + busy_service: bool, + busy_account: bool, + busy_pw: bool, + + // transient UI + toasts: Vec<(String, String, Instant)>, + modal: Option<(String, String)>, + pub show_about: bool, + + // infra + tx: Sender, + rx: Receiver, + ctx: egui::Context, +} + +impl HomeState { + pub fn new(ctx: &egui::Context) -> Self { + let (cfg, cfg_err) = match host_ctl::read_config() { + Ok(c) => (c, None), + Err(e) => (HostConfig::default(), Some(e.to_string())), + }; + let state = host_ctl::snapshot(&cfg); + let (tx, rx) = channel(); + let server_in = if cfg.server.trim().is_empty() { + host_ctl::DEFAULT_SERVER.to_string() + } else { + cfg.server.clone() + }; + let mut s = Self { + server_in, + reg_in: cfg.reg_key.clone(), + name_in: cfg.name.clone(), + acc_email: String::new(), + acc_pass: String::new(), + allow_in: cfg.allow_ips.clone(), + totp_qr: None, + pw_input: cfg.pw_plain.clone(), + partner_id: String::new(), + pw_show: false, + id_spaced: true, + cfg, + cfg_err, + state, + last_snap: Instant::now(), + nav: Nav::Remote, + ping_ms: None, + relay_offline: false, + ping_fails: 0, + last_ping: Instant::now() - Duration::from_secs(30), + devices: Vec::new(), + dev_err: None, + dev_q: String::new(), + dev_filter: 0, + last_dev: Instant::now() - Duration::from_secs(30), + log_lines: Vec::new(), + log_loaded: false, + busy_service: false, + busy_account: false, + busy_pw: false, + toasts: Vec::new(), + modal: None, + show_about: false, + tx, + rx, + ctx: ctx.clone(), + }; + // kick off the first live reads + s.ping_now(); + if s.cfg.signed_in() { + s.devices_now(); + } + s + } + + // ---- background plumbing --------------------------------------------- + fn spawn(&self, f: F) + where + F: FnOnce() -> Work + Send + 'static, + { + let tx = self.tx.clone(); + let ctx = self.ctx.clone(); + std::thread::spawn(move || { + let w = f(); + let _ = tx.send(w); + ctx.request_repaint(); + }); + } + + fn resnapshot(&mut self) { + self.state = host_ctl::snapshot(&self.cfg); + self.last_snap = Instant::now(); + } + + /// Re-read engine status + re-ping (called when returning from a session). + pub fn refresh(&mut self) { + self.resnapshot(); + self.ping_now(); + } + + /// The configured relay server — used to seed an outbound partner connect. + pub fn server(&self) -> String { + if self.server_in.trim().is_empty() { + host_ctl::DEFAULT_SERVER.to_string() + } else { + self.server_in.trim().to_string() + } + } + + fn ping_now(&mut self) { + self.last_ping = Instant::now(); + let cfg = self.cfg.clone(); + self.spawn(move || Work::Ping(host_ctl::ping_relay(&cfg).map_err(|e| e.to_string()))); + } + + fn devices_now(&mut self) { + self.last_dev = Instant::now(); + let cfg = self.cfg.clone(); + self.spawn(move || Work::Devices(host_ctl::list_devices(&cfg).map_err(|e| e.to_string()))); + } + + fn toast(&mut self, title: impl Into, sub: impl Into) { + self.toasts.push(( + title.into(), + sub.into(), + Instant::now() + Duration::from_millis(2800), + )); + } + + /// Drain finished background work and apply it. + fn drain(&mut self) { + while let Ok(w) = self.rx.try_recv() { + match w { + Work::Ping(Ok(ms)) => { + self.ping_ms = Some(ms); + self.ping_fails = 0; + self.relay_offline = false; + } + Work::Ping(Err(_)) => { + self.ping_fails += 1; + if self.ping_fails >= 2 { + self.relay_offline = true; + } + } + Work::Devices(Ok(d)) => { + // Feed the relay's online flag for THIS PC into host_ctl so + // engine_status() can report Online from the server's view + // (detection c) without any extra network call. + host_ctl::note_server_devices(&self.cfg.id, &d); + self.devices = d; + self.dev_err = None; + } + Work::Devices(Err(e)) => self.dev_err = Some(e), + Work::Cfg(c) => { + if let Some(cfg) = c.cfg { + self.cfg = cfg; + self.resnapshot(); + if c.reveal_pw { + self.pw_input = self.cfg.pw_plain.clone(); + self.pw_show = true; + } + } + if let Some(t) = c.toast { + self.toast(t.0, t.1); + } + if let Some(m) = c.modal { + self.modal = Some(m); + } + match c.busy { + Busy::Service => self.busy_service = false, + Busy::Account => self.busy_account = false, + Busy::Pw => self.busy_pw = false, + Busy::None => {} + } + } + } + } + } + + /// Timed refreshers: snapshot ~1s, ping ~12s, devices ~20s. + fn tick(&mut self) { + if self.last_snap.elapsed() > Duration::from_secs(1) { + self.resnapshot(); + } + if self.last_ping.elapsed() > Duration::from_secs(12) { + self.ping_now(); + } + if self.cfg.signed_in() && self.last_dev.elapsed() > Duration::from_secs(20) { + self.devices_now(); + } + } + + // ---- actions (all wired to host_ctl) --------------------------------- + fn do_start(&mut self) { + if self.busy_service { + return; + } + self.busy_service = true; + let mut cfg = self.cfg.clone(); + cfg.server = self.server_in.trim().to_string(); + cfg.name = self.name_in.trim().to_string(); + cfg.reg_key = self.reg_in.trim().to_string(); + self.spawn(move || { + let res = host_ctl::write_config(&cfg).and_then(|_| host_ctl::start_service(&cfg)); + match res { + Ok(_) => Work::Cfg(CfgResult { + cfg: Some(cfg), + toast: Some(("Engine starting…".into(), "Connecting to the relay".into())), + modal: None, + busy: Busy::Service, + reveal_pw: false, + }), + Err(e) => Work::Cfg(CfgResult { + cfg: Some(cfg), + toast: None, + modal: Some(("Start service".into(), e.to_string())), + busy: Busy::Service, + reveal_pw: false, + }), + } + }); + } + + fn do_stop(&mut self) { + if self.busy_service { + return; + } + self.busy_service = true; + let cfg = self.cfg.clone(); + self.spawn(move || match host_ctl::stop_service(&cfg) { + Ok(_) => Work::Cfg(CfgResult { + cfg: None, + toast: Some(("Engine stopped".into(), "Auto-start stays on".into())), + modal: None, + busy: Busy::Service, + reveal_pw: false, + }), + Err(e) => Work::Cfg(CfgResult { + cfg: None, + toast: None, + modal: Some(("Stop".into(), e.to_string())), + busy: Busy::Service, + reveal_pw: false, + }), + }); + } + + fn do_toggle(&mut self, name: &'static str, on: bool) { + let slow = matches!( + name, + "service_installed" | "service" | "start_with_windows" | "startup" + ); + if slow { + if self.busy_service { + return; + } + self.busy_service = true; + } + let mut cfg = self.cfg.clone(); + let busy = if slow { Busy::Service } else { Busy::None }; + self.spawn(move || match host_ctl::set_toggle(&mut cfg, name, on) { + Ok(msg) => { + let toast = if msg.is_empty() { + Some(( + format!("{} {}", pretty_toggle(name), if on { "on" } else { "off" }), + String::new(), + )) + } else { + Some((pretty_toggle(name).to_string(), msg)) + }; + Work::Cfg(CfgResult { + cfg: Some(cfg), + toast, + modal: None, + busy, + reveal_pw: false, + }) + } + Err(e) => Work::Cfg(CfgResult { + cfg: None, + toast: None, + modal: Some((pretty_toggle(name).to_string(), e.to_string())), + busy, + reveal_pw: false, + }), + }); + } + + fn do_pw_set(&mut self) { + let v = self.pw_input.clone(); + if v.is_empty() || self.busy_pw { + return; + } + self.busy_pw = true; + let mut cfg = self.cfg.clone(); + self.spawn( + move || match host_ctl::set_unattended_password(&mut cfg, &v) { + Ok(_) => Work::Cfg(CfgResult { + cfg: Some(cfg), + toast: Some(("Password saved".into(), "Applies to the next connection".into())), + modal: None, + busy: Busy::Pw, + reveal_pw: false, + }), + Err(e) => Work::Cfg(CfgResult { + cfg: None, + toast: None, + modal: Some(("Password".into(), e.to_string())), + busy: Busy::Pw, + reveal_pw: false, + }), + }, + ); + } + + fn do_pw_gen(&mut self) { + if self.busy_pw { + return; + } + self.busy_pw = true; + let mut cfg = self.cfg.clone(); + self.spawn(move || match host_ctl::generate_password(&mut cfg) { + Ok(pw) => Work::Cfg(CfgResult { + cfg: Some(cfg), + toast: None, + modal: Some(( + "New password".into(), + format!( + "Your new unattended password:\n\n {pw}\n\nIt applies to the next incoming connection — no restart needed." + ), + )), + busy: Busy::Pw, + reveal_pw: true, + }), + Err(e) => Work::Cfg(CfgResult { + cfg: None, + toast: None, + modal: Some(("Generate".into(), e.to_string())), + busy: Busy::Pw, + reveal_pw: false, + }), + }); + } + + fn do_2fa(&mut self) { + let turn_on = !self.state.totp; + let mut cfg = self.cfg.clone(); + self.spawn(move || match host_ctl::toggle_2fa(&mut cfg, turn_on) { + Ok(tf) => { + let modal = if tf.on { + Some(( + "2FA enabled".into(), + format!( + "Add this secret to your authenticator app:\n\n {}\n\nA 6-digit code is now required at every connect.", + tf.secret.clone().unwrap_or_default() + ), + )) + } else { + None + }; + Work::Cfg(CfgResult { + cfg: Some(cfg), + toast: if tf.on { + None + } else { + Some(("2FA disabled".into(), String::new())) + }, + modal, + busy: Busy::None, + reveal_pw: false, + }) + } + Err(e) => Work::Cfg(CfgResult { + cfg: None, + toast: None, + modal: Some(("2FA".into(), e.to_string())), + busy: Busy::None, + reveal_pw: false, + }), + }); + } + + /// Texture for the TOTP enrollment QR, rebuilt only when the secret changes. + fn totp_qr_texture( + &mut self, + ctx: &egui::Context, + secret: &str, + uri: &str, + ) -> Option { + if let Some((s, tex)) = &self.totp_qr { + if s == secret { + return Some(tex.clone()); + } + } + let img = qr_color_image(uri)?; + let tex = ctx.load_texture("totp_qr", img, egui::TextureOptions::NEAREST); + self.totp_qr = Some((secret.to_string(), tex.clone())); + Some(tex) + } + + fn do_login(&mut self, signup: bool) { + if self.busy_account { + return; + } + let email = self.acc_email.trim().to_string(); + let pass = std::mem::take(&mut self.acc_pass); + if email.is_empty() || pass.is_empty() { + self.toast("Account", "Enter an email and password"); + return; + } + self.busy_account = true; + let mut cfg = self.cfg.clone(); + self.spawn(move || { + let res = if signup { + host_ctl::account_signup(&mut cfg, &email, &pass) + } else { + host_ctl::account_login(&mut cfg, &email, &pass) + }; + match res { + Ok(acc) => Work::Cfg(CfgResult { + cfg: Some(cfg), + toast: Some(( + if signup { "Account created" } else { "Signed in" }.into(), + acc.email, + )), + modal: None, + busy: Busy::Account, + reveal_pw: false, + }), + Err(e) => Work::Cfg(CfgResult { + cfg: None, + toast: None, + modal: Some(( + if signup { "Create account" } else { "Sign in" }.into(), + e.to_string(), + )), + busy: Busy::Account, + reveal_pw: false, + }), + } + }); + } + + fn do_logout(&mut self) { + if self.busy_account { + return; + } + self.busy_account = true; + let mut cfg = self.cfg.clone(); + self.spawn(move || { + let _ = host_ctl::account_logout(&mut cfg); + Work::Cfg(CfgResult { + cfg: Some(cfg), + toast: Some(("Signed out".into(), String::new())), + modal: None, + busy: Busy::Account, + reveal_pw: false, + }) + }); + self.devices.clear(); + } + + fn do_save_allow(&mut self) { + let mut cfg = self.cfg.clone(); + cfg.allow_ips = self.allow_in.trim().to_string(); + let n = cfg + .allow_ips + .split(',') + .filter(|s| !s.trim().is_empty()) + .count(); + self.spawn(move || match host_ctl::write_config(&cfg) { + Ok(_) => Work::Cfg(CfgResult { + cfg: Some(cfg), + toast: Some(( + "Allowlist saved".into(), + if n == 0 { + "cleared — all IPs allowed".into() + } else { + format!("{n} entr{}", if n > 1 { "ies" } else { "y" }) + }, + )), + modal: None, + busy: Busy::None, + reveal_pw: false, + }), + Err(e) => Work::Cfg(CfgResult { + cfg: None, + toast: None, + modal: Some(("IP allowlist".into(), e.to_string())), + busy: Busy::None, + reveal_pw: false, + }), + }); + } + + fn do_copy_id(&mut self) { + match host_ctl::copy_id(&self.cfg) { + Ok(_) => self.toast("ID copied", fmt_id(&self.cfg.id, true)), + Err(e) => self.toast("Copy ID", e.to_string()), + } + } + + fn do_open_web(&mut self) { + if let Err(e) = host_ctl::open_web_viewer(&self.cfg) { + self.toast("Open Web Viewer", e.to_string()); + } + } + + fn do_open_logs(&mut self) { + if let Err(e) = host_ctl::open_logs_folder(&self.cfg) { + self.toast("Logs folder", e.to_string()); + } + } + + fn load_logs(&mut self) { + self.log_loaded = true; + self.log_lines.clear(); + if let Some(dir) = self.cfg.path.parent() { + // the engine writes a rolling log next to the config + for cand in ["netdesk_host.log", "netdesk.log", "host.log"] { + let p = dir.join(cand); + if let Ok(text) = std::fs::read_to_string(&p) { + self.log_lines = text + .lines() + .rev() + .take(300) + .map(|s| s.to_string()) + .collect(); + self.log_lines.reverse(); + return; + } + } + } + } + + // =========================== TOP-LEVEL UI ============================== + pub fn ui(&mut self, ctx: &egui::Context, logo: Option) -> HomeAction { + self.drain(); + self.tick(); + self.toasts.retain(|(_, _, until)| Instant::now() < *until); + + // surface a config-load failure once (defaults are in use) + if let Some(e) = self.cfg_err.take() { + self.modal = Some(( + "Configuration".into(), + format!("Could not read config.json:\n\n{e}\n\nUsing defaults for this session."), + )); + } + + let mut action = HomeAction::None; + + self.top_bar(ctx, logo); + self.sidebar(ctx, logo); + + egui::CentralPanel::default() + .frame(egui::Frame::none().fill(BG_TOP)) + .show(ctx, |ui| { + // page backdrop: soft vertical gradient + faint grid for depth + let full = ui.max_rect(); + ui.painter() + .add(rounded_vgrad(full, 0.0, BG_TOP, BG_BOT)); + paint_grid(ui.painter(), full); + + // The ScrollArea is a SAFETY NET for tiny windows only: at the + // default window the whole Remote Control page fits with no + // scrolling (see the LAYOUT METRICS budget at the top). + let out = egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + // Consistent page gutters (~18px): cards never touch the + // window edge at any width. Every row below budgets its + // children from this ui's available_width(), so nothing + // can overflow horizontally. + egui::Frame::none() + .inner_margin(Margin { + left: 18.0, + right: 18.0, + top: 10.0, + bottom: 8.0, + }) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + // all vertical gaps on this page are explicit + ui.spacing_mut().item_spacing.y = 0.0; + action = self.connect_bar(ui); + ui.add_space(GAP); + match self.nav { + Nav::Remote => { + if let Some(a) = self.view_remote(ui) { + action = a; + } + } + Nav::Computers => { + if let Some(a) = self.view_computers(ui) { + action = a; + } + } + Nav::Security => self.view_security(ui), + Nav::Logs => self.view_logs(ui), + } + }); + }); + // NETDESK_PROBE=1 prints the page-vs-viewport height whenever + // it changes — used to verify the no-scroll fit in self-tests. + if std::env::var_os("NETDESK_PROBE").is_some() { + let key = egui::Id::new("nd_probe_fit"); + let cur = ( + out.content_size.y.round() as i32, + out.inner_rect.height().round() as i32, + ); + let last: Option<(i32, i32)> = ctx.data(|d| d.get_temp(key)); + if last != Some(cur) { + ctx.data_mut(|d| d.insert_temp(key, cur)); + eprintln!( + "[nd-probe] page_h={} viewport_h={} fits={} screen={:.0}x{:.0}", + cur.0, + cur.1, + cur.0 <= cur.1, + ctx.screen_rect().width(), + ctx.screen_rect().height() + ); + } + } + }); + + self.overlays(ctx, logo); + action + } + + // =========================== TOP BAR =================================== + fn top_bar(&mut self, ctx: &egui::Context, logo: Option) { + egui::TopBottomPanel::top("nd_topbar") + .exact_height(42.0) + .frame( + egui::Frame::none() + .fill(TOPBAR_BG) + .inner_margin(Margin::symmetric(14.0, 0.0)), + ) + .show_separator_line(false) + .show(ctx, |ui| { + // hairline under the bar + let r = ui.max_rect(); + ui.painter().hline( + r.left()..=r.right(), + r.bottom() - 0.5, + Stroke::new(1.0_f32, Color32::from_rgb(29, 38, 51)), + ); + ui.horizontal_centered(|ui| { + logo_mark(ui, logo, 20.0); + ui.add_space(8.0); + ui.label(RichText::new(crate::brand_name()).size(15.0).strong().color(FG)); + + // Width budget for the rest of the bar: the relay chip is + // never clipped; the tagline is the first thing to go when + // the window gets narrow, then the chip drops its TLS text. + let rest = ui.available_width(); + let full_chip_w = self.relay_chip_width(ui, false); + let tagline = "— Enterprise Remote Desktop & Unattended Access"; + let tag_w = ui + .painter() + .layout_no_wrap(tagline.to_string(), FontId::proportional(11.5), FAINT) + .size() + .x; + let show_tag = rest >= tag_w + full_chip_w + 40.0; + if show_tag { + ui.label(RichText::new(tagline).size(11.5).color(FAINT)); + } + let compact_chip = !show_tag && rest < full_chip_w + 16.0; + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + self.relay_chip(ui, compact_chip); + }); + }); + }); + } + + /// The relay telemetry segments. `compact` drops the "Relay:" prefix and + /// the TLS suffix so the chip shrinks to "host · ping" on narrow windows. + fn relay_segs(&self, compact: bool) -> (Color32, Vec<(String, Color32, FontId)>) { + let mut host = relay_host(&self.state.server); + // a pathologically long custom hostname must never outgrow the chip + if host.chars().count() > 28 { + host = host.chars().take(27).collect::() + "…"; + } + let (dot_col, ping_txt, ping_col) = if self.relay_offline { + (CRIMSON_FG, "offline".to_string(), CRIMSON_FG) + } else if let Some(ms) = self.ping_ms { + (crate::c_accent(), format!("{ms} ms"), crate::c_accent()) + } else { + (FAINT, "— ms".to_string(), FAINT) + }; + let sep = Color32::from_rgb(51, 65, 85); + let prop = FontId::proportional(11.0); + let mono = FontId::monospace(11.0); + let segs: Vec<(String, Color32, FontId)> = if compact { + vec![ + (host, FG_SOFT, mono.clone()), + ("·".to_string(), sep, prop), + (ping_txt, ping_col, mono), + ] + } else { + vec![ + ("Relay:".to_string(), MUTED, prop.clone()), + (host, FG_SOFT, mono.clone()), + ("·".to_string(), sep, prop.clone()), + (ping_txt, ping_col, mono), + ("·".to_string(), sep, prop.clone()), + ("TLS 1.3 · AES-256-GCM".to_string(), MUTED, prop), + ] + }; + (dot_col, segs) + } + + /// Measured total width of the chip (dot + segments + padding) — used for + /// the top-bar width budget before anything is laid out. + fn relay_chip_width(&self, ui: &Ui, compact: bool) -> f32 { + let (_, segs) = self.relay_segs(compact); + let gap = 6.0; + let text_w: f32 = segs + .into_iter() + .map(|(t, c, f)| ui.painter().layout_no_wrap(t, f, c).size().x + gap) + .sum(); + 13.0 + text_w + 24.0 + } + + /// The live relay telemetry chip. Custom-painted left-to-right at explicit + /// x positions so segment order is correct regardless of the enclosing + /// layout direction (it sits in a right-to-left top-bar region). + fn relay_chip(&self, ui: &mut Ui, compact: bool) { + let (dot_col, segs) = self.relay_segs(compact); + let gap = 6.0; + let dot_w = 13.0; + let padx = 12.0; + let galleys: Vec<_> = segs + .into_iter() + .map(|(t, c, f)| ui.painter().layout_no_wrap(t, f, c)) + .collect(); + let text_w: f32 = galleys.iter().map(|g| g.size().x + gap).sum(); + let size = Vec2::new(dot_w + text_w + padx * 2.0, 23.0); + let (rect, _r) = ui.allocate_exact_size(size, Sense::hover()); + let p = ui.painter(); + p.rect_filled(rect, Rounding::same(999.0), Color32::from_rgb(19, 25, 34)); + p.rect_stroke(rect, Rounding::same(999.0), Stroke::new(1.0_f32, Color32::from_rgb(34, 44, 58))); + let mut x = rect.left() + padx; + p.circle_filled(Pos2::new(x + 3.0, rect.center().y), 3.0, dot_col); + x += dot_w; + for g in galleys { + let gw = g.size().x; + let gy = rect.center().y - g.size().y / 2.0; + p.galley(Pos2::new(x, gy), g, FG); + x += gw + gap; + } + } + + // =========================== SIDEBAR =================================== + fn sidebar(&mut self, ctx: &egui::Context, logo: Option) { + // Below ~980px of window width the sidebar collapses to a 56px icon + // rail (icons + tooltips) so the content keeps its room — the standard + // enterprise responsive shell. + if ctx.screen_rect().width() < 980.0 { + self.sidebar_rail(ctx, logo); + return; + } + egui::SidePanel::left("nd_sidebar") + .exact_width(236.0) + .resizable(false) + .frame( + egui::Frame::none() + .fill(SIDEBAR_BG) + .inner_margin(Margin::same(14.0)), + ) + .show_separator_line(false) + .show(ctx, |ui| { + let r = ui.max_rect(); + ui.painter().vline( + r.right() + 13.0, + r.top() - 14.0..=r.bottom() + 14.0, + Stroke::new(1.0_f32, Color32::from_rgb(30, 39, 52)), + ); + + // brand block + ui.horizontal(|ui| { + logo_mark(ui, logo, 40.0); + ui.add_space(10.0); + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(crate::brand_name()).size(21.0).strong().color(crate::c_accent())); + ui.label( + RichText::new(format!("v{}", netdesk_core::VERSION)) + .size(11.5) + .color(Color32::from_rgb(110, 123, 139)), + ); + }); + ui.label( + RichText::new("Zero-Trust Remote Agent") + .size(10.5) + .color(MUTED), + ); + }); + }); + ui.add_space(10.0); + ui.painter().hline( + ui.max_rect().left()..=ui.max_rect().right(), + ui.cursor().top(), + Stroke::new(1.0_f32, Color32::from_rgb(28, 36, 48)), + ); + ui.add_space(6.0); + + ui.label( + RichText::new("WORKSPACE") + .size(10.0) + .strong() + .color(Color32::from_rgb(88, 101, 119)), + ); + ui.add_space(4.0); + + let two_fa = if self.state.totp { "2FA On" } else { "2FA Off" }; + let n_dev = self.devices.len(); + if nav_item(ui, "Remote Control", NavBadge::Em("Host"), self.nav == Nav::Remote) { + self.nav = Nav::Remote; + } + if nav_item( + ui, + "My Computers", + NavBadge::Grey(n_dev.to_string()), + self.nav == Nav::Computers, + ) { + self.nav = Nav::Computers; + } + if nav_item( + ui, + "Security & 2FA", + if self.state.totp { + NavBadge::Em(two_fa) + } else { + NavBadge::Red(two_fa) + }, + self.nav == Nav::Security, + ) { + self.nav = Nav::Security; + } + if nav_item( + ui, + "Session Logs", + NavBadge::Grey(if self.log_lines.is_empty() { + "·".to_string() + } else { + self.log_lines.len().to_string() + }), + self.nav == Nav::Logs, + ) { + self.nav = Nav::Logs; + if !self.log_loaded { + self.load_logs(); + } + } + + // footer pinned to the bottom + ui.with_layout(Layout::bottom_up(Align::Min), |ui| { + ui.add_space(2.0); + // Akronlogic credit (REQUIRED, always visible) + ui.horizontal(|ui| { + ui.label(RichText::new("Developed by ").size(10.5).color(FAINT)); + if ui + .add( + egui::Label::new( + RichText::new("Akronlogic.com").size(10.5).strong().color(crate::c_accent()), + ) + .sense(Sense::click()), + ) + .on_hover_text("https://akronlogic.com") + .clicked() + { + let _ = open_url("https://akronlogic.com"); + } + }); + if ui + .add(egui::Label::new(RichText::new("About NetDesk").size(10.5).color(MUTED)).sense(Sense::click())) + .clicked() + { + self.show_about = true; + } + ui.add_space(8.0); + + // signed-in chip + if self.cfg.signed_in() { + let mail = self.state.account.clone(); + side_account_chip(ui, &mail); + ui.add_space(8.0); + } + + // local endpoint / daemon status + chip_inset(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new("Local Endpoint").size(11.0).color(MUTED)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label( + RichText::new(if self.state.name.is_empty() { + "—" + } else { + &self.state.name + }) + .size(11.0) + .monospace() + .color(crate::c_accent()), + ); + }); + }); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label(RichText::new("Daemon Status").size(11.0).color(MUTED)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let (txt, col) = if self.state.online { + ("● Online", crate::c_accent_hi()) + } else { + ("● Stopped", CRIMSON_FG) + }; + ui.label(RichText::new(txt).size(11.0).strong().color(col)); + }); + }); + }); + }); + }); + } + + /// The collapsed 56px icon rail used on narrow windows: nav icons with + /// tooltips, the daemon status dot and the About entry (which keeps the + /// Akronlogic credit one click away). + fn sidebar_rail(&mut self, ctx: &egui::Context, logo: Option) { + egui::SidePanel::left("nd_sidebar") + .exact_width(56.0) + .resizable(false) + .frame( + egui::Frame::none() + .fill(SIDEBAR_BG) + .inner_margin(Margin::symmetric(8.0, 10.0)), + ) + .show_separator_line(false) + .show(ctx, |ui| { + let r = ui.max_rect(); + ui.painter().vline( + r.right() + 7.0, + r.top() - 10.0..=r.bottom() + 10.0, + Stroke::new(1.0_f32, Color32::from_rgb(30, 39, 52)), + ); + ui.vertical_centered(|ui| { + logo_mark(ui, logo, 30.0); + ui.add_space(12.0); + let two_fa = if self.state.totp { "2FA On" } else { "2FA Off" }; + let items: [(&str, &str, Nav); 4] = [ + ("🖥", "Remote Control", Nav::Remote), + ("💻", "My Computers", Nav::Computers), + ("🛡", "Security & 2FA", Nav::Security), + ("🗒", "Session Logs", Nav::Logs), + ]; + for (glyph, label, nav) in items { + let tip = if nav == Nav::Security { + format!("{label} — {two_fa}") + } else { + label.to_string() + }; + if rail_item(ui, glyph, self.nav == nav, &tip) { + if nav == Nav::Logs && !self.log_loaded { + self.load_logs(); + } + self.nav = nav; + } + ui.add_space(6.0); + } + ui.with_layout(Layout::bottom_up(Align::Center), |ui| { + ui.add_space(2.0); + if rail_item(ui, "ⓘ", false, "About NetDesk — Developed by Akronlogic.com") { + self.show_about = true; + } + ui.add_space(6.0); + // daemon status dot (tooltip carries the full text) + let (col, tip) = if self.state.online { + (crate::c_accent_hi(), "Daemon: Online") + } else { + (CRIMSON_FG, "Daemon: Stopped") + }; + let (rect, resp) = + ui.allocate_exact_size(Vec2::splat(14.0), Sense::hover()); + ui.painter().circle_filled(rect.center(), 4.0, col); + resp.on_hover_text(tip); + }); + }); + }); + } + + // =========================== CONNECT BAR =============================== + fn connect_bar(&mut self, ui: &mut Ui) -> HomeAction { + let mut action = HomeAction::None; + let avail = ui.available_width(); + let sp = 10.0; + + // Natural (measured) widths of the trailing items. The field flexes to + // fill the remaining width; the chip and the caption keep natural size + // and drop out one by one before anything could clip. + let installed = is_installed(); + let pill_label = if installed { "Installed" } else { "Portable" }; + let pill_w = ui + .painter() + .layout_no_wrap(pill_label.to_string(), FontId::proportional(11.0), Color32::WHITE) + .size() + .x + + 20.0; + let cap_txt = if installed { + "installed on this PC" + } else { + "settings next to the app" + }; + let cap_w = ui + .painter() + .layout_no_wrap(cap_txt.to_string(), FontId::proportional(11.5), FAINT) + .size() + .x; + let mut bar_w = avail; + let show_pill = avail >= 430.0; + if show_pill { + bar_w -= pill_w + sp; + } + let show_cap = show_pill && bar_w - (cap_w + sp) >= 300.0; + if show_cap { + bar_w -= cap_w + sp; + } + bar_w = bar_w.min(620.0).max(220.0); + + // One EXACT-height band (BAR_H): the bar's box is painted on a fixed + // rect and everything inside is centered in it, so the Connect button + // can never paint outside the row or get covered by the card below. + exact_row(ui, BAR_H, Layout::left_to_right(Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = sp; + ui.allocate_ui_with_layout( + Vec2::new(bar_w, BAR_H), + Layout::left_to_right(Align::Center), + |ui| { + ui.set_min_size(Vec2::new(bar_w, BAR_H)); + let rect = Rect::from_min_size(ui.max_rect().min, Vec2::new(bar_w, BAR_H)); + ui.painter().rect_filled(rect, Rounding::same(9.0), INPUT_BG); + ui.painter().rect_stroke( + rect, + Rounding::same(9.0), + Stroke::new(1.0_f32, Color32::from_rgb(38, 50, 66)), + ); + ui.add_space(12.0); + ui.spacing_mut().item_spacing.x = 8.0; + let inner = bar_w - 12.0 - 4.0; // inner budget (side paddings) + let label_txt = "CONNECT TO PARTNER:"; + let label_w = ui + .painter() + .layout_no_wrap(label_txt.to_string(), FontId::proportional(11.0), crate::c_accent()) + .size() + .x; + let btn_w = 104.0; + let show_label = inner >= label_w + btn_w + 150.0; + if show_label { + ui.label(RichText::new(label_txt).size(11.0).strong().color(crate::c_accent())); + } + let used = if show_label { label_w + 8.0 } else { 0.0 }; + let field_w = (inner - used - btn_w - 8.0).max(60.0); + let hint = if field_w < 250.0 { + "Partner ID" + } else { + "Enter remote Partner ID (e.g. 842 190 441)" + }; + let te = ui.add( + TextEdit::singleline(&mut self.partner_id) + .frame(false) + .hint_text(hint) + .desired_width(field_w) + .font(FontId::monospace(13.5)) + .text_color(FG) + .vertical_align(Align::Center), + ); + let go = grad_button( + ui, + &format!("Connect {}", ph::ARROW_RIGHT), + Vec2::new(btn_w, BAR_H - 8.0), + true, + true, + ); + let enter = te.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if go.clicked() || enter { + let digits: String = + self.partner_id.chars().filter(|c| c.is_ascii_digit()).collect(); + if digits.len() >= 6 { + action = HomeAction::Connect { id: digits }; + } else { + self.toast("Connect to Partner", "Enter the 9-digit Partner ID"); + } + } + }, + ); + if show_pill { + pill_tag( + ui, + pill_label, + if installed { PillKind::Em } else { PillKind::Grey }, + ); + } + if show_cap { + ui.label( + RichText::new(cap_txt) + .size(11.5) + .color(Color32::from_rgb(109, 120, 136)), + ); + } + }); + action + } + + // =========================== REMOTE VIEW =============================== + fn view_remote(&mut self, ui: &mut Ui) -> Option { + // TWO COLUMNS ARE THE NORM — the original shows two ~50/50 columns at + // its default window (grid2: minmax(0,1fr) minmax(0,1fr)). Stack to a + // single column only when the content area is truly narrow (phone-ish + // width); at the default 1030x700 window the content is ~758px wide. + let avail = ui.available_width(); + let two_col = avail >= 620.0; + let mut action = None; + let gap = GAP; + + if two_col { + let col_l = ((avail - gap) * 0.5).floor(); + let col_r = (avail - gap - col_l).floor(); + // Column equalizer (the HTML's `flex: 1` bottom cards): remember + // last frame's grid height and stretch each column's bottom card + // to it. The content is static, so this settles in one frame. + let gid = egui::Id::new("nd_grid_h"); + let grid_h: f32 = ui.data(|d| d.get_temp(gid)).unwrap_or(0.0); + let mut l_h = 0.0_f32; + let mut r_h = 0.0_f32; + ui.horizontal_top(|ui| { + // the gap IS the item spacing, so the two columns + gap always + // sum to exactly the available width — no hidden extra spacing + ui.spacing_mut().item_spacing.x = gap; + let rl = ui.allocate_ui_with_layout( + Vec2::new(col_l, 0.0), + Layout::top_down(Align::Min), + |ui| { + ui.set_width(col_l); + ui.spacing_mut().item_spacing.y = 0.0; + let top = ui.cursor().top(); + self.card_id(ui); + ui.add_space(gap); + let used = ui.cursor().top() - top; + self.card_password(ui, (grid_h - used - 2.0 * PAD).max(0.0)); + }, + ); + l_h = rl.response.rect.height(); + let rr = ui.allocate_ui_with_layout( + Vec2::new(col_r, 0.0), + Layout::top_down(Align::Min), + |ui| { + ui.set_width(col_r); + ui.spacing_mut().item_spacing.y = 0.0; + let top = ui.cursor().top(); + self.card_connection(ui); + ui.add_space(gap); + let used = ui.cursor().top() - top; + if let Some(a) = + self.card_account(ui, (grid_h - used - 2.0 * PAD).max(0.0)) + { + action = Some(a); + } + }, + ); + r_h = rr.response.rect.height(); + }); + ui.data_mut(|d| d.insert_temp(gid, l_h.max(r_h))); + } else { + self.card_id(ui); + ui.add_space(gap); + self.card_password(ui, 0.0); + ui.add_space(gap); + self.card_connection(ui); + ui.add_space(gap); + if let Some(a) = self.card_account(ui, 0.0) { + action = Some(a); + } + } + + ui.add_space(gap); + // Start / Stop — two flexible halves; the gap is the item spacing so + // the row sums to exactly the available width + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = gap; + let bw = ((ui.available_width() - gap) / 2.0).floor(); + let running = self.state.online; + let start_label = if running { + format!("{} Service Running", ph::POWER) + } else { + format!("{} Start Service", ph::POWER) + }; + if grad_button(ui, &start_label, Vec2::new(bw, BTN_H), true, false).clicked() + && !running + { + self.do_start(); + } + if grad_button_danger(ui, &format!("{} Stop", ph::STOP), Vec2::new(bw, BTN_H), false) + .clicked() + { + self.do_stop(); + } + }); + + ui.add_space(gap); + self.card_toggles(ui); + + ui.add_space(gap); + // footer buttons — same flexible halves + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = gap; + let bw = ((ui.available_width() - gap) / 2.0).floor(); + if foot_button(ui, &format!("{} Open Web Viewer", ph::GLOBE), bw).clicked() { + self.do_open_web(); + } + if foot_button(ui, &format!("{} Logs Folder", ph::FOLDER_OPEN), bw).clicked() { + self.do_open_logs(); + } + }); + + action + } + + fn card_id(&mut self, ui: &mut Ui) { + card(ui, "card_id", 0.0, |ui| { + // the Copy-ID button keeps its natural size on the right; the big + // ID gets the rest and scales its font down in narrow cards + let id_max = (ui.available_width() - 112.0).max(120.0); + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.horizontal(|ui| { + section_title(ui, "THIS COMPUTER'S ID"); + if ui + .add( + egui::Label::new( + RichText::new(" Format ") + .size(10.0) + .color(FAINT), + ) + .sense(Sense::click()), + ) + .on_hover_text("Toggle spaced format") + .clicked() + { + self.id_spaced = !self.id_spaced; + } + }); + ui.add_space(3.0); + big_id(ui, &fmt_id(&self.cfg.id, self.id_spaced), id_max); + }); + ui.with_layout(Layout::right_to_left(Align::Min), |ui| { + if copy_button(ui, "Copy ID").clicked() { + self.do_copy_id(); + } + }); + }); + ui.add_space(9.0); + exact_row(ui, 25.0, Layout::left_to_right(Align::Center), |ui| { + status_pill(ui, self.state.online, self.state.in_session); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + e2ee_tag(ui); + }); + }); + }); + } + + /// `min_h`: stretch the card content to this height (the column equalizer) + /// with the throttle/2FA footer pinned to the card bottom like the HTML. + fn card_password(&mut self, ui: &mut Ui, min_h: f32) { + card(ui, "card_pw", min_h, |ui| { + let top = ui.cursor().top(); + card_head(ui, "UNATTENDED PASSWORD", |ui| { + pill_tag(ui, "PBKDF2 · 200k", PillKind::Em); + }); + ui.add_space(8.0); + // ONE FIXED-HEIGHT ROW (the giant-password-box fix): laid out + // right-to-left inside an exact FIELD_H band — the eye / regen / + // Set / 2FA controls keep their natural size at the right edge and + // the password field takes exactly the remaining width. A bare + // with_layout here would grab all the height under the card. + exact_row(ui, FIELD_H, Layout::right_to_left(Align::Center), |ui| { + let (txt, kind) = if self.state.totp { + ("2FA: ON", PillKind::Em) + } else { + ("2FA: off", PillKind::Crimson) + }; + if pill_button(ui, txt, kind).clicked() { + self.do_2fa(); + } + if ui + .add(egui::Label::new(RichText::new("Set").size(13.0).strong().color(BLUE)).sense(Sense::click())) + .clicked() + { + self.do_pw_set(); + } + if icon_button(ui, ph::ARROWS_CLOCKWISE).on_hover_text("Generate a strong password").clicked() { + self.do_pw_gen(); + } + if icon_button(ui, if self.pw_show { ph::EYE_SLASH } else { ph::EYE }) + .on_hover_text("Show / hide") + .clicked() + { + self.pw_show = !self.pw_show; + } + let field_w = (ui.available_width() - 9.0).max(80.0); + let hint = if field_w < 215.0 { + "no password yet" + } else { + "no password yet — generate one" + }; + nd_input(ui, &mut self.pw_input, hint, !self.pw_show, true, field_w); + }); + ui.add_space(8.0); + ui.label( + RichText::new( + "Anyone who knows this ID and password can control this PC. Set a strong password; turn on 2FA for an authenticator code at connect.", + ) + .size(11.5) + .color(Color32::from_rgb(115, 128, 145)), + ); + // pin the footer to the card bottom (the HTML's flex column) + let foot_h = 26.0; + let used = ui.cursor().top() - top; + ui.add_space((min_h - used - foot_h).max(8.0)); + card_divider(ui); + ui.add_space(8.0); + exact_row(ui, 18.0, Layout::left_to_right(Align::Center), |ui| { + dot(ui, crate::c_accent(), 6.0); + ui.label(RichText::new("Brute-force throttle:").size(11.5).color(MUTED)); + ui.label(RichText::new("0.4s / attempt").size(11.5).strong().color(FG_SOFT)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui + .add(egui::Label::new(RichText::new("Configure Authenticator →").size(11.5).color(crate::c_accent())).sense(Sense::click())) + .clicked() + { + self.do_2fa(); + } + }); + }); + }); + } + + fn card_connection(&mut self, ui: &mut Ui) { + card(ui, "card_conn", 0.0, |ui| { + card_head(ui, "CONNECTION", |ui| { + let label = match self.ping_ms { + Some(ms) => format!("Ping Relay ({ms} ms)"), + None => "Ping Relay".to_string(), + }; + if pill_button(ui, &label, PillKind::EmOutline).clicked() { + self.ping_now(); + self.toast("Pinging relay…", ""); + } + }); + ui.add_space(6.0); + field_label(ui, "Server Address", None); + nd_input(ui, &mut self.server_in, "wss://netdesk.space/ws", false, true, ui.available_width()); + ui.add_space(6.0); + field_label(ui, "Registration Key", Some("(optional)")); + nd_input(ui, &mut self.reg_in, "Leave blank for open relay", false, true, ui.available_width()); + ui.add_space(6.0); + field_label(ui, "Computer Name", None); + nd_input(ui, &mut self.name_in, "", false, false, ui.available_width()); + }); + } + + fn card_account(&mut self, ui: &mut Ui, min_h: f32) -> Option { + let mut action = None; + card(ui, "card_acct", min_h, |ui| { + card_head(ui, "NETDESK ACCOUNT", |ui| { + if self.cfg.signed_in() { + pill_tag(ui, "Fleet Synced", PillKind::Em); + } + }); + ui.add_space(7.0); + + if self.cfg.signed_in() { + exact_row(ui, 16.0, Layout::left_to_right(Align::Center), |ui| { + ui.label(RichText::new("✓ Signed in as ").size(13.0).color(crate::c_accent())); + ui.label(RichText::new(&self.state.account).size(13.0).strong().color(crate::c_accent())); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui + .add(egui::Label::new(RichText::new("Sign out").size(12.5).color(BLUE)).sense(Sense::click())) + .clicked() + { + self.do_logout(); + } + }); + }); + ui.add_space(5.0); + ui.label( + RichText::new("This PC shows under “My Computers” on the website.") + .size(11.5) + .color(Color32::from_rgb(115, 128, 145)), + ); + ui.add_space(6.0); + card_divider(ui); + ui.add_space(6.0); + exact_row(ui, 14.0, Layout::left_to_right(Align::Center), |ui| { + ui.label( + RichText::new("LINKED DEVICES") + .size(10.0) + .strong() + .color(MUTED), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui + .add(egui::Label::new(RichText::new("View all").size(11.0).color(BLUE)).sense(Sense::click())) + .clicked() + { + self.nav = Nav::Computers; + } + }); + }); + ui.add_space(5.0); + if self.devices.is_empty() { + ui.label( + RichText::new("No computers linked yet — this PC appears here once the engine registers.") + .size(11.5) + .color(FAINT), + ); + } else { + // up to 3 compact device tiles per row (the HTML fleet-grid) + let devs: Vec = self.devices.iter().take(3).cloned().collect(); + let n = devs.len().max(1) as f32; + let sp = 8.0; + let w = ((ui.available_width() - sp * (n - 1.0)) / n).floor(); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = sp; + for d in devs { + if fleet_card(ui, &d.name, &d.id, d.online, d.busy, w).clicked() + && d.online + { + action = Some(HomeAction::Connect { id: d.id.clone() }); + } + } + }); + } + } else { + nd_input(ui, &mut self.acc_email, "email", false, false, ui.available_width()); + ui.add_space(6.0); + nd_input(ui, &mut self.acc_pass, "password", true, false, ui.available_width()); + ui.add_space(8.0); + let gap = 10.0; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = gap; + let bw = ((ui.available_width() - gap) / 2.0).floor(); + if grad_button(ui, "Sign In", Vec2::new(bw, 34.0), true, false).clicked() { + self.do_login(false); + } + if grad_button(ui, "Create Account", Vec2::new(bw, 34.0), false, false).clicked() + { + self.do_login(true); + } + }); + } + }); + action + } + + fn card_toggles(&mut self, ui: &mut Ui) { + let items = [ + ("service_installed", "Service\nInstalled", self.state.service_installed), + ("startup", "Start with\nWindows", self.state.startup), + ("privacy", "Privacy\nMode", self.state.privacy), + ("lock_on_end", "Lock on\ndisconnect", self.state.lock_on_end), + ("close_to_tray", "Close to\ntray", self.state.close_to_tray), + ]; + card(ui, "card_toggles", 0.0, |ui| { + // Responsive cells: at least ~96px each (spacing included in the + // budget); the row wraps to extra rows when the card is narrow + // instead of clipping on the right. + let sp = 8.0; + let total = ui.available_width(); + let per_row = (((total + sp) / (96.0 + sp)).floor() as usize).clamp(1, items.len()); + let cell = ((total - sp * (per_row as f32 - 1.0)) / per_row as f32).floor(); + for (ri, row) in items.chunks(per_row).enumerate() { + if ri > 0 { + ui.add_space(10.0); + } + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = sp; + for (name, label, on) in row.iter().copied() { + ui.allocate_ui_with_layout( + Vec2::new(cell, 58.0), + Layout::top_down(Align::Center), + |ui| { + ui.set_width(cell); + ui.spacing_mut().item_spacing.y = 0.0; + if toggle_switch(ui, on).clicked() { + self.do_toggle(name, !on); + } + ui.add_space(4.0); + for line in label.split('\n') { + ui.label(RichText::new(line).size(11.5).color(Color32::from_rgb(154, 166, 181))); + } + }, + ); + } + }); + } + }); + } + + // =========================== MY COMPUTERS ============================== + fn view_computers(&mut self, ui: &mut Ui) -> Option { + let mut action = None; + card(ui, "view_devs", 0.0, |ui| { + card_head(ui, "MY COMPUTERS", |ui| { + if ui + .add(egui::Label::new(RichText::new("↻ Refresh").size(11.5).color(crate::c_accent())).sense(Sense::click())) + .clicked() + { + self.devices_now(); + } + }); + ui.add_space(8.0); + // filter pills keep natural size on the right; the search field + // flexes to exactly the remaining width — in an exact-height band + exact_row(ui, FIELD_H, Layout::right_to_left(Align::Center), |ui| { + for (idx, lbl) in [(2u8, "Offline"), (1, "Online"), (0, "All")] { + if pill_button( + ui, + lbl, + if self.dev_filter == idx { + PillKind::EmOutline + } else { + PillKind::Grey + }, + ) + .clicked() + { + self.dev_filter = idx; + } + } + let w = (ui.available_width() - 9.0).max(80.0); + nd_input(ui, &mut self.dev_q, "Search by name or ID…", false, false, w); + }); + ui.add_space(10.0); + + if !self.cfg.signed_in() { + ui.label( + RichText::new("Sign in to your NetDesk account to see the computers linked to it.") + .size(12.0) + .color(FAINT), + ); + return; + } + if let Some(e) = &self.dev_err { + ui.label(RichText::new(format!("Could not load devices: {e}")).size(12.0).color(CRIMSON_FG)); + } + let q = self.dev_q.to_lowercase(); + let devs: Vec = self + .devices + .iter() + .filter(|d| match self.dev_filter { + 1 => d.online, + 2 => !d.online, + _ => true, + }) + .filter(|d| { + q.is_empty() + || d.name.to_lowercase().contains(&q) + || d.id.contains(&q) + }) + .cloned() + .collect(); + if devs.is_empty() { + ui.label( + RichText::new(if self.devices.is_empty() { + "No computers linked yet — this PC appears here once the engine registers." + } else { + "No computers match this filter." + }) + .size(12.0) + .color(FAINT), + ); + } + for d in devs { + if list_row(ui, &d.name, &d.id, d.online, d.busy).clicked() && d.online { + action = Some(HomeAction::Connect { id: d.id.clone() }); + } + ui.add_space(8.0); + } + }); + action + } + + // =========================== SECURITY ================================== + fn view_security(&mut self, ui: &mut Ui) { + card(ui, "sec_2fa", 0.0, |ui| { + ui.horizontal(|ui| { + section_title(ui, "TWO-FACTOR AUTHENTICATION (TOTP)"); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let (t, k) = if self.state.totp { + ("2FA: ON", PillKind::Em) + } else { + ("2FA: off", PillKind::Crimson) + }; + pill_tag(ui, t, k); + }); + }); + ui.add_space(8.0); + ui.label( + RichText::new("Adds a 6-digit authenticator code required at every connect — even with the correct password.") + .size(12.0) + .color(Color32::from_rgb(115, 128, 145)), + ); + ui.add_space(10.0); + let label = if self.state.totp { "Disable 2FA" } else { "Enable 2FA" }; + if grad_button(ui, label, Vec2::new(220.0, 38.0), !self.state.totp, false).clicked() { + self.do_2fa(); + } + // In-app QR enrollment: while 2FA is on, show a scannable QR of the + // host's otpauth:// secret so the user can add it to an authenticator + // app without typing the base32 string. + if self.state.totp && !self.cfg.totp.is_empty() { + let secret = self.cfg.totp.clone(); + let uri = totp_otpauth_uri(&secret, "this PC"); + ui.add_space(12.0); + ui.label( + RichText::new("Scan with your authenticator app (Google Authenticator, Authy, 1Password…):") + .size(12.0) + .color(Color32::from_rgb(115, 128, 145)), + ); + ui.add_space(8.0); + if let Some(tex) = self.totp_qr_texture(ui.ctx(), &secret, &uri) { + ui.add(egui::Image::new(egui::load::SizedTexture::new( + tex.id(), + Vec2::new(188.0, 188.0), + ))); + } + ui.add_space(6.0); + ui.label( + RichText::new(format!("Secret: {secret}")) + .size(11.0) + .monospace() + .color(Color32::from_rgb(150, 160, 175)), + ); + } + }); + ui.add_space(14.0); + card(ui, "sec_allow", 0.0, |ui| { + ui.horizontal(|ui| { + section_title(ui, "VIEWER IP ALLOWLIST"); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let on = !self.allow_in.trim().is_empty(); + pill_tag( + ui, + if on { "on — restricted" } else { "off — everyone with the password" }, + if on { PillKind::Em } else { PillKind::Grey }, + ); + }); + }); + ui.add_space(8.0); + ui.label( + RichText::new("Only these IPs / CIDR ranges may connect (e.g. 203.0.113.7, 10.0.0.0/8). Empty = any IP that knows the password.") + .size(12.0) + .color(Color32::from_rgb(115, 128, 145)), + ); + ui.add_space(10.0); + // the Save button keeps natural size on the right; the field flexes + // — in an exact-height band so the row can never balloon + exact_row(ui, FIELD_H, Layout::right_to_left(Align::Center), |ui| { + if grad_button(ui, "Save Allowlist", Vec2::new(138.0, FIELD_H), true, false).clicked() { + self.do_save_allow(); + } + let w = (ui.available_width() - 9.0).max(80.0); + nd_input(ui, &mut self.allow_in, "empty — all IPs allowed", false, true, w); + }); + }); + ui.add_space(14.0); + card(ui, "sec_svc", 0.0, |ui| { + ui.horizontal(|ui| { + section_title(ui, "BOOT-TIME SERVICE"); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + pill_tag( + ui, + if self.state.service_installed { "installed" } else { "not installed" }, + if self.state.service_installed { PillKind::Em } else { PillKind::Grey }, + ); + }); + }); + ui.add_space(8.0); + ui.label( + RichText::new("Installs a SYSTEM service so this PC is reachable at the logon screen and across reboots. Requires one UAC approval.") + .size(12.0) + .color(Color32::from_rgb(115, 128, 145)), + ); + ui.add_space(10.0); + ui.horizontal(|ui| { + if grad_button(ui, "Install Service", Vec2::new(190.0, 38.0), true, false).clicked() { + self.do_toggle("service_installed", true); + } + ui.add_space(10.0); + if grad_button(ui, "Remove Service", Vec2::new(160.0, 38.0), false, false).clicked() { + self.do_toggle("service_installed", false); + } + }); + if self.busy_service { + ui.add_space(8.0); + ui.label(RichText::new("Working… (accept the UAC prompt if it appears)").size(11.5).color(MUTED)); + } + }); + } + + // =========================== LOGS ====================================== + fn view_logs(&mut self, ui: &mut Ui) { + card(ui, "view_logs", 0.0, |ui| { + ui.horizontal(|ui| { + section_title(ui, "SESSION AUDIT LOG"); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui + .add(egui::Label::new(RichText::new("Open folder").size(11.5).color(crate::c_accent())).sense(Sense::click())) + .clicked() + { + self.do_open_logs(); + } + if ui + .add(egui::Label::new(RichText::new("↻ Refresh").size(11.5).color(crate::c_accent())).sense(Sense::click())) + .clicked() + { + self.load_logs(); + } + }); + }); + ui.add_space(10.0); + let frame = egui::Frame::none() + .fill(Color32::from_rgb(10, 14, 19)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(30, 39, 52))) + .rounding(Rounding::same(8.0)) + .inner_margin(Margin::same(12.0)); + frame.show(ui, |ui| { + ui.set_min_height(260.0); + ui.set_width(ui.available_width()); + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + // the page ui zeroes item_spacing.y; log lines need a gap + ui.spacing_mut().item_spacing.y = 4.0; + if self.log_lines.is_empty() { + ui.label( + RichText::new(format!( + "No log file found next to the config yet.\nConfig: {}", + self.cfg.path.display() + )) + .size(11.5) + .monospace() + .color(FAINT), + ); + } else { + for line in &self.log_lines { + ui.label(RichText::new(line).size(11.5).monospace().color(Color32::from_rgb(159, 176, 196))); + } + } + }); + }); + }); + } + + // =========================== OVERLAYS ================================== + fn overlays(&mut self, ctx: &egui::Context, logo: Option) { + // toasts (bottom-right) + if !self.toasts.is_empty() { + egui::Area::new(egui::Id::new("nd_toasts")) + .anchor(egui::Align2::RIGHT_BOTTOM, Vec2::new(-16.0, -16.0)) + .interactable(false) + .show(ctx, |ui| { + ui.with_layout(Layout::bottom_up(Align::Max), |ui| { + for (title, sub, _) in self.toasts.iter().rev() { + toast_card(ui, title, sub); + ui.add_space(8.0); + } + }); + }); + } + + // modal dialog + if let Some((title, body)) = self.modal.clone() { + let mut open = true; + egui::Window::new(RichText::new(title).color(crate::c_accent()).strong()) + .collapsible(false) + .resizable(false) + .open(&mut open) + .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) + .frame( + egui::Frame::window(&ctx.style()) + .fill(Color32::from_rgb(22, 29, 39)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(42, 54, 72))) + .rounding(Rounding::same(12.0)) + .shadow(Shadow { + offset: Vec2::new(0.0, 24.0), + blur: 70.0, + spread: 0.0, + color: Color32::from_black_alpha(180), + }), + ) + .show(ctx, |ui| { + ui.set_max_width(460.0_f32.min(ctx.screen_rect().width() - 48.0)); + ui.add_space(4.0); + ui.label(RichText::new(body).size(13.0).color(Color32::from_rgb(203, 213, 225))); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if grad_button(ui, "Close", Vec2::new(90.0, 32.0), true, false).clicked() { + self.modal = None; + } + }); + }); + if !open { + self.modal = None; + } + } + + // About dialog (REQUIRED Akronlogic credit) + if self.show_about { + let mut open = true; + egui::Window::new("About NetDesk") + .collapsible(false) + .resizable(false) + .open(&mut open) + .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) + .frame( + egui::Frame::window(&ctx.style()) + .fill(Color32::from_rgb(22, 29, 39)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(42, 54, 72))) + .rounding(Rounding::same(12.0)), + ) + .show(ctx, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(6.0); + logo_mark(ui, logo, 64.0); + ui.add_space(8.0); + ui.label(RichText::new(crate::brand_name()).size(26.0).strong().color(FG)); + ui.label(RichText::new(format!("Enterprise Remote Desktop · v{}", netdesk_core::VERSION)).size(12.0).color(MUTED)); + ui.add_space(8.0); + ui.label(RichText::new("Developed by Akronlogic.com").size(14.0).strong().color(crate::c_accent())); + ui.add_space(4.0); + if ui.add(egui::Label::new(RichText::new("akronlogic.com").size(12.0).color(BLUE)).sense(Sense::click())).clicked() { + let _ = open_url("https://akronlogic.com"); + } + ui.add_space(6.0); + }); + }); + if !open { + self.show_about = false; + } + } + } +} + +// ============================ REUSABLE 3D WIDGETS ========================== + +/// A soft-shadowed, gradient-filled, bevelled card whose content is laid out by +/// `add`. The drop shadow grows on hover (one-frame-lagged lift). The gradient +/// + border + top highlight are painted BEHIND the content via a reserved shape +/// slot, so they never wash over text. +/// A row of EXACTLY `h` logical px: the child ui is clamped to that band, so +/// `Align::Center` centers within it. This is the fix for the giant-password- +/// box bug: a bare `ui.with_layout(right_to_left(..))` in a top-down ui takes +/// ALL the remaining height (to the bottom of the ScrollArea canvas) and +/// centers its widgets in the middle of it, ballooning the card. +fn exact_row(ui: &mut Ui, h: f32, layout: Layout, add: impl FnOnce(&mut Ui) -> R) -> R { + let w = ui.available_width(); + ui.allocate_ui_with_layout(Vec2::new(w, h), layout, |ui| { + ui.set_min_size(Vec2::new(w, h)); + add(ui) + }) + .inner +} + +/// The standard card head: small-caps title left, optional controls right — +/// one fixed-height band like the HTML's `.card-head`. +fn card_head(ui: &mut Ui, title: &str, right: impl FnOnce(&mut Ui)) { + exact_row(ui, HEAD_H, Layout::left_to_right(Align::Center), |ui| { + section_title(ui, title); + ui.with_layout(Layout::right_to_left(Align::Center), right); + }); +} + +fn card(ui: &mut Ui, id_src: &str, min_h: f32, add: impl FnOnce(&mut Ui) -> R) -> R { + let id = egui::Id::new(("nd_card", id_src)); + // one-frame-lagged hover, kept in egui memory (avoids borrowing self) + let hovered = ui.data(|d| d.get_temp::(id)).unwrap_or(false); + + // reserve the slot the depth stack will be painted into (behind content) + let bg = ui.painter().add(Shape::Noop); + + let frame = egui::Frame::none() + .inner_margin(Margin::same(PAD)) + .rounding(Rounding::same(12.0)); + let res = frame.show(ui, |ui| { + // every card fills its column exactly — uniform widths, no overflow + ui.set_width(ui.available_width()); + // geometry is managed with explicit add_space inside cards + ui.spacing_mut().item_spacing.y = 0.0; + if min_h > 0.0 { + // stretch to the column height (the HTML's `flex: 1` card) + ui.set_min_height(min_h); + } + add(ui) + }); + let rect = res.response.rect; + + let shadow = if hovered { + Shadow { + offset: Vec2::new(0.0, 12.0), + blur: 34.0, + spread: -2.0, + color: Color32::from_black_alpha(150), + } + } else { + Shadow { + offset: Vec2::new(0.0, 7.0), + blur: 22.0, + spread: -3.0, + color: Color32::from_black_alpha(120), + } + }; + + let border = if hovered { CARD_BORDER_HI } else { CARD_BORDER }; + let shapes = vec![ + Shape::from(shadow.as_shape(rect, Rounding::same(12.0))), + rounded_vgrad(rect, 12.0, CARD_TOP, CARD_BOT), + Shape::rect_stroke(rect, Rounding::same(12.0), Stroke::new(1.0_f32, border)), + // top inner-highlight bevel + Shape::line_segment( + [ + Pos2::new(rect.left() + 12.0, rect.top() + 1.2), + Pos2::new(rect.right() - 12.0, rect.top() + 1.2), + ], + Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 16)), + ), + ]; + ui.painter().set(bg, Shape::Vec(shapes)); + + let now = ui.rect_contains_pointer(rect); + ui.data_mut(|d| d.insert_temp(id, now)); + res.inner +} + +/// Primary (green gradient + glow) or secondary (grey) pill button, size-fixed +/// so it can paint its depth before the label. `compact` tightens the label. +// Red "danger" gradient for destructive actions (Stop service). Harmonizes with +// the CRIMSON_* status palette. +const DANGER_HI: Color32 = Color32::from_rgb(232, 93, 100); // #e85d64 +const DANGER_LO: Color32 = Color32::from_rgb(179, 44, 52); // #b32c34 +const DANGER_BD: Color32 = Color32::from_rgb(243, 120, 126); // #f3787e + +fn grad_button(ui: &mut Ui, label: &str, size: Vec2, primary: bool, compact: bool) -> egui::Response { + if primary { + grad_button_col(ui, label, size, crate::c_accent_hi(), crate::c_accent_lo(), Color32::from_rgb(53, 230, 153), + Color32::WHITE, Some(Color32::from_rgb(25, 200, 126)), compact) + } else { + grad_button_col(ui, label, size, GREY_BTN_TOP, GREY_BTN_BOT, Color32::from_rgb(100, 112, 128), + Color32::from_rgb(220, 228, 238), None, compact) + } +} + +/// Red variant for destructive actions (e.g. Stop service). +fn grad_button_danger(ui: &mut Ui, label: &str, size: Vec2, compact: bool) -> egui::Response { + grad_button_col(ui, label, size, DANGER_HI, DANGER_LO, DANGER_BD, + Color32::WHITE, Some(Color32::from_rgb(214, 69, 76)), compact) +} + +#[allow(clippy::too_many_arguments)] +fn grad_button_col( + ui: &mut Ui, + label: &str, + size: Vec2, + top: Color32, + bot: Color32, + border: Color32, + txt_col: Color32, + glow: Option, + compact: bool, +) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); + let hovered = resp.hovered(); + let rounding = if size.y >= 44.0 { 9.0 } else { 8.0 }; + let p = ui.painter(); + + // outer glow (primary / danger) + if let Some(gc) = glow { + let glow = Shadow { + offset: Vec2::ZERO, + blur: if hovered { 26.0 } else { 20.0 }, + spread: 1.0, + color: Color32::from_rgba_unmultiplied(gc.r(), gc.g(), gc.b(), if hovered { 120 } else { 90 }), + }; + p.add(Shape::from(glow.as_shape(rect, Rounding::same(rounding)))); + } + // drop shadow + let drop = Shadow { + offset: Vec2::new(0.0, 3.0), + blur: 10.0, + spread: -1.0, + color: Color32::from_black_alpha(110), + }; + p.add(Shape::from(drop.as_shape(rect, Rounding::same(rounding)))); + + // gradient fill + let (top, bot) = if hovered { + (lighten(top, 0.08), lighten(bot, 0.08)) + } else { + (top, bot) + }; + p.add(rounded_vgrad(rect, rounding, top, bot)); + // border + top bevel + p.add(Shape::rect_stroke(rect, Rounding::same(rounding), Stroke::new(1.0_f32, border))); + p.add(Shape::line_segment( + [ + Pos2::new(rect.left() + rounding, rect.top() + 1.0), + Pos2::new(rect.right() - rounding, rect.top() + 1.0), + ], + Stroke::new(1.2_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 56)), + )); + + // press offset + let dy = if resp.is_pointer_button_down_on() { 1.0 } else { 0.0 }; + let fsize = if compact { 12.5 } else if size.y >= 44.0 { 14.5 } else { 13.5 }; + ui.painter().text( + rect.center() + Vec2::new(0.0, dy), + egui::Align2::CENTER_CENTER, + label, + FontId::proportional(fsize), + txt_col, + ); + resp +} + +/// Low-profile outlined footer button (dark fill, green hairline + glow). +fn foot_button(ui: &mut Ui, label: &str, w: f32) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(Vec2::new(w, FOOT_H), Sense::click()); + let hovered = resp.hovered(); + let p = ui.painter(); + let glow = Shadow { + offset: Vec2::ZERO, + blur: if hovered { 14.0 } else { 9.0 }, + spread: 0.0, + color: Color32::from_rgba_unmultiplied(25, 200, 126, if hovered { 60 } else { 30 }), + }; + p.add(Shape::from(glow.as_shape(rect, Rounding::same(8.0)))); + let fill = if hovered { + Color32::from_rgb(28, 38, 50) + } else { + Color32::from_rgb(22, 29, 38) + }; + p.add(Shape::rect_filled(rect, Rounding::same(8.0), fill)); + let border = if hovered { crate::c_accent() } else { Color32::from_rgb(31, 122, 83) }; + p.add(Shape::rect_stroke(rect, Rounding::same(8.0), Stroke::new(1.0_f32, border))); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + label, + FontId::proportional(13.5), + if hovered { Color32::WHITE } else { FG_SOFT }, + ); + resp +} + +/// A raised, tactile toggle switch (48x26) with on-state green glow + shadowed +/// knob. Returns the click response. +fn toggle_switch(ui: &mut Ui, on: bool) -> egui::Response { + let size = Vec2::new(48.0, 25.0); + let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); + let p = ui.painter(); + let r = rect.height() / 2.0; + + if on { + // glow + let glow = Shadow { + offset: Vec2::ZERO, + blur: 12.0, + spread: 0.0, + color: Color32::from_rgba_unmultiplied(25, 200, 126, 110), + }; + p.add(Shape::from(glow.as_shape(rect, Rounding::same(r)))); + p.add(rounded_vgrad(rect, r, Color32::from_rgb(36, 210, 134), Color32::from_rgb(24, 184, 114))); + p.add(Shape::rect_stroke(rect, Rounding::same(r), Stroke::new(1.0_f32, Color32::from_rgb(57, 229, 154)))); + } else { + p.add(rounded_vgrad(rect, r, Color32::from_rgb(54, 63, 76), Color32::from_rgb(44, 52, 64))); + p.add(Shape::rect_stroke(rect, Rounding::same(r), Stroke::new(1.0_f32, Color32::from_rgb(71, 83, 100)))); + } + + // knob + let knob_r = 9.5; + let cx = if on { + rect.right() - r + } else { + rect.left() + r + }; + let center = Pos2::new(cx, rect.center().y); + // knob shadow + p.add(Shape::from( + Shadow { + offset: Vec2::new(0.0, 1.5), + blur: 4.0, + spread: 0.0, + color: Color32::from_black_alpha(120), + } + .as_shape(Rect::from_center_size(center, Vec2::splat(knob_r * 2.0)), Rounding::same(knob_r)), + )); + p.circle_filled(center, knob_r, if on { Color32::WHITE } else { Color32::from_rgb(139, 150, 166) }); + resp +} + +enum PillKind { + Em, + EmOutline, + Crimson, + Grey, +} + +/// A non-interactive status tag pill. +fn pill_tag(ui: &mut Ui, text: &str, kind: PillKind) { + pill_draw(ui, text, kind, false); +} + +/// A clickable pill button (same look, click sense). +fn pill_button(ui: &mut Ui, text: &str, kind: PillKind) -> egui::Response { + pill_draw(ui, text, kind, true) +} + +fn pill_draw(ui: &mut Ui, text: &str, kind: PillKind, clickable: bool) -> egui::Response { + let galley = ui.painter().layout_no_wrap( + text.to_string(), + FontId::proportional(11.0), + Color32::WHITE, + ); + let pad = Vec2::new(10.0, 4.5); + let size = galley.size() + pad * 2.0; + let sense = if clickable { Sense::click() } else { Sense::hover() }; + let (rect, resp) = ui.allocate_exact_size(size, sense); + let hovered = clickable && resp.hovered(); + let (fill, border, fg) = match kind { + PillKind::Em => ( + Color32::from_rgba_unmultiplied(25, 200, 126, 30), + Color32::from_rgba_unmultiplied(25, 200, 126, 120), + crate::c_accent_hi(), + ), + PillKind::EmOutline => ( + Color32::from_rgba_unmultiplied(25, 200, 126, if hovered { 45 } else { 26 }), + Color32::from_rgba_unmultiplied(25, 200, 126, 90), + crate::c_accent_hi(), + ), + PillKind::Crimson => (CRIMSON_BG, CRIMSON_BD, CRIMSON_FG), + PillKind::Grey => ( + if hovered { Color32::from_rgb(30, 41, 59) } else { Color32::from_rgb(22, 29, 40) }, + Color32::from_rgb(38, 50, 65), + Color32::from_rgb(148, 163, 184), + ), + }; + let p = ui.painter(); + p.rect_filled(rect, Rounding::same(999.0), fill); + p.rect_stroke(rect, Rounding::same(999.0), Stroke::new(1.0_f32, border)); + let pos = Pos2::new(rect.left() + pad.x, rect.center().y - galley.size().y / 2.0); + p.galley(pos, galley, fg); + resp +} + +fn status_pill(ui: &mut Ui, online: bool, in_session: bool) { + let (txt, fg, bg, bd) = if in_session { + ( + "In Session", + Color32::from_rgb(217, 185, 106), + Color32::from_rgb(51, 48, 28), + Color32::from_rgb(124, 101, 53), + ) + } else if online { + ( + "Online", + crate::c_accent_hi(), + Color32::from_rgba_unmultiplied(25, 200, 126, 30), + Color32::from_rgba_unmultiplied(25, 200, 126, 115), + ) + } else { + ("Stopped", CRIMSON_FG, CRIMSON_BG, CRIMSON_BD) + }; + let galley = ui.painter().layout_no_wrap(txt.to_string(), FontId::proportional(12.5), fg); + let pad = Vec2::new(13.0, 5.0); + let size = Vec2::new(galley.size().x + pad.x * 2.0 + 14.0, galley.size().y + pad.y * 2.0); + let (rect, _resp) = ui.allocate_exact_size(size, Sense::hover()); + let p = ui.painter(); + p.rect_filled(rect, Rounding::same(999.0), bg); + p.rect_stroke(rect, Rounding::same(999.0), Stroke::new(1.0_f32, bd)); + p.circle_filled(Pos2::new(rect.left() + pad.x + 4.0, rect.center().y), 4.0, fg); + p.galley( + Pos2::new(rect.left() + pad.x + 14.0, rect.center().y - galley.size().y / 2.0), + galley, + fg, + ); +} + +/// The big glowing monospace ID number. The font scales down (34 → 16) when +/// the card is narrow so the number never clips. +fn big_id(ui: &mut Ui, text: &str, max_w: f32) { + let mut size = 34.0; + let probe = ui + .painter() + .layout_no_wrap(text.to_string(), FontId::monospace(size), Color32::WHITE); + if probe.size().x > max_w && probe.size().x > 0.0 { + size = (size * max_w / probe.size().x).clamp(16.0, 34.0); + } + let font = FontId::monospace(size); + let galley = + ui.painter() + .layout_no_wrap(text.to_string(), font.clone(), Color32::WHITE); + let (rect, _resp) = + ui.allocate_exact_size(galley.size() + Vec2::new(2.0, 6.0), Sense::hover()); + let pos = Pos2::new(rect.left(), rect.top() + 3.0); + let painter = ui.painter(); + // A subtle dark drop shadow gives depth without any green tint (the old green + // glow smeared below the digits and read as a stray green line). + painter.text( + pos + Vec2::new(0.0, 2.0), + egui::Align2::LEFT_TOP, + text, + font.clone(), + Color32::from_black_alpha(90), + ); + painter.text(pos, egui::Align2::LEFT_TOP, text, font, Color32::WHITE); +} + +fn copy_button(ui: &mut Ui, label: &str) -> egui::Response { + let galley = ui.painter().layout_no_wrap(label.to_string(), FontId::proportional(13.0), FG); + let size = Vec2::new(galley.size().x + 40.0, 34.0); + let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); + let hovered = resp.hovered(); + let p = ui.painter(); + p.rect_filled(rect, Rounding::same(9.0), if hovered { Color32::from_rgb(44, 54, 70) } else { Color32::from_rgb(35, 43, 56) }); + p.rect_stroke(rect, Rounding::same(9.0), Stroke::new(1.0_f32, if hovered { Color32::from_rgb(75, 91, 112) } else { Color32::from_rgb(56, 69, 88) })); + // copy glyph + p.text(Pos2::new(rect.left() + 13.0, rect.center().y), egui::Align2::CENTER_CENTER, ph::COPY, FontId::proportional(14.0), MUTED); + p.galley(Pos2::new(rect.left() + 24.0, rect.center().y - galley.size().y / 2.0), galley, FG); + resp +} + +fn e2ee_tag(ui: &mut Ui) { + let galley = ui.painter().layout_no_wrap(format!("{} E2EE Ready", ph::LOCK), FontId::proportional(11.5), MUTED); + let size = galley.size() + Vec2::new(18.0, 6.0); + let (rect, _r) = ui.allocate_exact_size(size, Sense::hover()); + let p = ui.painter(); + p.rect_filled(rect, Rounding::same(6.0), Color32::from_rgb(17, 22, 29)); + p.rect_stroke(rect, Rounding::same(6.0), Stroke::new(1.0_f32, Color32::from_rgb(36, 46, 61))); + p.galley(Pos2::new(rect.left() + 9.0, rect.center().y - galley.size().y / 2.0), galley, Color32::from_rgb(148, 163, 184)); +} + +fn icon_button(ui: &mut Ui, glyph: &str) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(Vec2::new(FIELD_H, FIELD_H), Sense::click()); + let hovered = resp.hovered(); + let p = ui.painter(); + p.rect_filled(rect, Rounding::same(7.0), if hovered { Color32::from_rgb(48, 59, 76) } else { Color32::from_rgb(38, 47, 61) }); + p.rect_stroke(rect, Rounding::same(7.0), Stroke::new(1.0_f32, if hovered { Color32::from_rgb(75, 91, 112) } else { Color32::from_rgb(56, 69, 86) })); + p.text(rect.center(), egui::Align2::CENTER_CENTER, glyph, FontId::proportional(15.0), if hovered { Color32::WHITE } else { Color32::from_rgb(208, 216, 226) }); + resp +} + +/// Dark rounded text input matching the host GUI (#0f141b, green focus ring). +/// `width` is the TOTAL control width INCLUDING the frame padding, so passing +/// `ui.available_width()` always fits exactly and can never overflow the card. +/// The control is EXACTLY `FIELD_H` tall — the box is painted on an allocated +/// fixed rect and the single-line TextEdit is centered inside it, so the field +/// can never balloon vertically whatever layout it sits in. +fn nd_input(ui: &mut Ui, buf: &mut String, hint: &str, password: bool, mono: bool, width: f32) -> egui::Response { + let w = width.max(40.0); + ui.allocate_ui_with_layout( + Vec2::new(w, FIELD_H), + Layout::left_to_right(Align::Center), + |ui| { + ui.set_min_size(Vec2::new(w, FIELD_H)); + let rect = Rect::from_min_size(ui.max_rect().min, Vec2::new(w, FIELD_H)); + ui.painter().rect_filled(rect, Rounding::same(7.0), INPUT_BG); + ui.painter() + .rect_stroke(rect, Rounding::same(7.0), Stroke::new(1.0_f32, INPUT_BORDER)); + ui.add_space(8.0); + let mut te = TextEdit::singleline(buf) + .frame(false) + .hint_text(hint) + .desired_width(w - 18.0) + .text_color(FG) + .vertical_align(Align::Center); + if password { + te = te.password(true); + } + if mono { + te = te.font(FontId::monospace(13.0)); + } + let r = ui.add(te); + if r.has_focus() { + ui.painter() + .rect_stroke(rect, Rounding::same(7.0), Stroke::new(1.5_f32, crate::c_accent())); + } + r + }, + ) + .inner +} + +fn field_label(ui: &mut Ui, label: &str, opt: Option<&str>) { + exact_row(ui, 15.0, Layout::left_to_right(Align::Center), |ui| { + ui.label(RichText::new(label).size(11.5).color(Color32::from_rgb(154, 166, 181))); + if let Some(o) = opt { + ui.label(RichText::new(o).size(11.0).color(Color32::from_rgb(99, 112, 129))); + } + }); + ui.add_space(2.0); +} + +/// True when NetDesk runs from its installed location (the installer writes +/// `uninstall.exe` next to NetDesk.exe); false for the portable copy. Cached on +/// first call so the pill reflects how this build was launched. +fn is_installed() -> bool { + use std::sync::OnceLock; + static INSTALLED: OnceLock = OnceLock::new(); + *INSTALLED.get_or_init(|| { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.join("uninstall.exe").exists())) + .unwrap_or(false) + }) +} + +fn section_title(ui: &mut Ui, text: &str) { + ui.label(RichText::new(text).size(11.0).strong().color(MUTED)); +} + +fn card_divider(ui: &mut Ui) { + let r = ui.max_rect(); + let y = ui.cursor().top(); + ui.painter().hline(r.left()..=r.right(), y, Stroke::new(1.0_f32, Color32::from_rgb(34, 43, 56))); +} + +fn dot(ui: &mut Ui, color: Color32, r: f32) { + let (rect, _resp) = ui.allocate_exact_size(Vec2::splat(r * 2.0 + 2.0), Sense::hover()); + ui.painter().circle_filled(rect.center(), r, color); +} + +enum NavBadge { + Em(&'static str), + Grey(String), + Red(&'static str), +} + +fn nav_item(ui: &mut Ui, label: &str, badge: NavBadge, active: bool) -> bool { + let (rect, resp) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 36.0), Sense::click()); + let hovered = resp.hovered(); + let p = ui.painter(); + if active { + p.add(rounded_vgrad( + rect, + 8.0, + Color32::from_rgba_unmultiplied(25, 200, 126, 40), + Color32::from_rgba_unmultiplied(25, 200, 126, 8), + )); + p.rect_stroke(rect, Rounding::same(8.0), Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(25, 200, 126, 82))); + } else if hovered { + p.rect_filled(rect, Rounding::same(8.0), Color32::from_rgba_unmultiplied(255, 255, 255, 10)); + } + let fg = if active { + Color32::WHITE + } else if hovered { + Color32::from_rgb(226, 232, 240) + } else { + Color32::from_rgb(148, 163, 184) + }; + // small monitor/window glyph (filled tile + top strip) — not a checkbox + let icol = if active { crate::c_accent() } else { Color32::from_rgb(110, 123, 139) }; + let ic = Rect::from_min_size(Pos2::new(rect.left() + 11.0, rect.center().y - 7.0), Vec2::new(15.0, 13.0)); + p.rect_filled(ic, Rounding::same(3.0), icol.linear_multiply(0.22)); + p.rect_stroke(ic, Rounding::same(3.0), Stroke::new(1.3_f32, icol)); + p.hline(ic.left() + 2.5..=ic.right() - 2.5, ic.top() + 4.0, Stroke::new(1.0_f32, icol)); + p.text( + Pos2::new(rect.left() + 36.0, rect.center().y), + egui::Align2::LEFT_CENTER, + label, + FontId::proportional(13.0), + fg, + ); + // badge + let (btxt, bfill, bborder, bfg) = match badge { + NavBadge::Em(t) => ( + t.to_string(), + Color32::from_rgba_unmultiplied(25, 200, 126, 38), + Color32::from_rgba_unmultiplied(25, 200, 126, 0), + crate::c_accent_hi(), + ), + NavBadge::Grey(t) => (t, Color32::from_rgb(30, 41, 59), Color32::from_rgb(30, 41, 59), Color32::from_rgb(148, 163, 184)), + NavBadge::Red(t) => (t.to_string(), CRIMSON_BG, CRIMSON_BD, CRIMSON_FG), + }; + let bg = ui.painter().layout_no_wrap(btxt, FontId::proportional(10.0), bfg); + let bsz = bg.size() + Vec2::new(14.0, 5.0); + let brect = Rect::from_min_size(Pos2::new(rect.right() - bsz.x - 8.0, rect.center().y - bsz.y / 2.0), bsz); + let p = ui.painter(); + p.rect_filled(brect, Rounding::same(6.0), bfill); + if bborder != bfill { + p.rect_stroke(brect, Rounding::same(6.0), Stroke::new(1.0_f32, bborder)); + } + p.galley(Pos2::new(brect.left() + 7.0, brect.center().y - bg.size().y / 2.0), bg, bfg); + resp.clicked() +} + +/// A 40x36 icon button for the collapsed sidebar rail: same active/hover +/// treatment as `nav_item`, label carried by the tooltip. +fn rail_item(ui: &mut Ui, glyph: &str, active: bool, tip: &str) -> bool { + let (rect, resp) = ui.allocate_exact_size(Vec2::new(40.0, 36.0), Sense::click()); + let hovered = resp.hovered(); + let p = ui.painter(); + if active { + p.add(rounded_vgrad( + rect, + 8.0, + Color32::from_rgba_unmultiplied(25, 200, 126, 40), + Color32::from_rgba_unmultiplied(25, 200, 126, 8), + )); + p.rect_stroke( + rect, + Rounding::same(8.0), + Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(25, 200, 126, 82)), + ); + } else if hovered { + p.rect_filled(rect, Rounding::same(8.0), Color32::from_rgba_unmultiplied(255, 255, 255, 10)); + } + let col = if active { + crate::c_accent() + } else if hovered { + Color32::from_rgb(226, 232, 240) + } else { + Color32::from_rgb(148, 163, 184) + }; + p.text(rect.center(), egui::Align2::CENTER_CENTER, glyph, FontId::proportional(16.0), col); + resp.on_hover_text(tip).clicked() +} + +fn fleet_card(ui: &mut Ui, name: &str, id: &str, online: bool, busy: bool, w: f32) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(Vec2::new(w, 38.0), Sense::click()); + let hovered = resp.hovered() && online; + let p = ui.painter(); + p.rect_filled(rect, Rounding::same(7.0), Color32::from_rgb(17, 22, 29)); + p.rect_stroke( + rect, + Rounding::same(7.0), + Stroke::new(1.0_f32, if hovered { Color32::from_rgba_unmultiplied(25, 200, 126, 128) } else { Color32::from_rgb(37, 48, 64) }), + ); + p.text(Pos2::new(rect.left() + 12.0, rect.center().y - 7.0), egui::Align2::LEFT_CENTER, name, FontId::proportional(12.0), FG_SOFT); + let sub = if online { if busy { "In session" } else { "Online" } } else { "Offline" }; + p.text(Pos2::new(rect.left() + 12.0, rect.center().y + 8.0), egui::Align2::LEFT_CENTER, sub, FontId::proportional(10.0), if online { crate::c_accent() } else { FAINT }); + p.circle_filled(Pos2::new(rect.right() - 14.0, rect.center().y), 4.0, if online { crate::c_accent() } else { Color32::from_rgb(100, 116, 139) }); + let _ = id; + resp +} + +fn list_row(ui: &mut Ui, name: &str, id: &str, online: bool, busy: bool) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 48.0), Sense::click()); + let p = ui.painter(); + p.rect_filled(rect, Rounding::same(8.0), Color32::from_rgb(17, 22, 29)); + p.rect_stroke(rect, Rounding::same(8.0), Stroke::new(1.0_f32, Color32::from_rgb(37, 48, 64))); + p.circle_filled(Pos2::new(rect.left() + 16.0, rect.center().y), 5.0, if online { crate::c_accent() } else { Color32::from_rgb(100, 116, 139) }); + p.text(Pos2::new(rect.left() + 30.0, rect.center().y - 8.0), egui::Align2::LEFT_CENTER, name, FontId::proportional(13.0), FG_SOFT); + let sub = if online { if busy { "In session" } else { "Online" } } else { "Offline" }; + p.text(Pos2::new(rect.left() + 30.0, rect.center().y + 9.0), egui::Align2::LEFT_CENTER, sub, FontId::proportional(10.5), if online { crate::c_accent() } else { FAINT }); + p.text(Pos2::new(rect.right() - 16.0, rect.center().y), egui::Align2::RIGHT_CENTER, &fmt_id(id, true), FontId::monospace(12.0), FAINT); + resp +} + +fn side_account_chip(ui: &mut Ui, email: &str) { + chip_inset(ui, |ui| { + ui.horizontal(|ui| { + let (rect, _r) = ui.allocate_exact_size(Vec2::splat(28.0), Sense::hover()); + ui.painter().circle_filled(rect.center(), 14.0, Color32::from_rgba_unmultiplied(25, 200, 126, 51)); + ui.painter().circle_stroke(rect.center(), 14.0, Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(25, 200, 126, 110))); + let initials: String = email.chars().take(2).collect::().to_uppercase(); + ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, initials, FontId::proportional(11.0), crate::c_accent()); + ui.add_space(4.0); + ui.vertical(|ui| { + ui.label(RichText::new(email).size(11.0).color(Color32::from_rgb(226, 232, 240))); + ui.label(RichText::new("✓ Cloud Synced").size(10.0).color(crate::c_accent())); + }); + }); + }); +} + +fn toast_card(ui: &mut Ui, title: &str, sub: &str) { + let frame = egui::Frame::none() + .fill(Color32::from_rgb(19, 25, 34)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(44, 56, 73))) + .rounding(Rounding::same(9.0)) + .inner_margin(Margin::symmetric(14.0, 10.0)) + .shadow(Shadow { + offset: Vec2::new(0.0, 10.0), + blur: 30.0, + spread: 0.0, + color: Color32::from_black_alpha(130), + }); + frame.show(ui, |ui| { + ui.set_min_width(220.0); + // green accent bar + let r = ui.max_rect(); + ui.painter().rect_filled( + Rect::from_min_size(Pos2::new(r.left() - 14.0, r.top() - 10.0), Vec2::new(3.0, r.height() + 20.0)), + Rounding::ZERO, + crate::c_accent(), + ); + ui.label(RichText::new(title).size(12.5).strong().color(Color32::WHITE)); + if !sub.is_empty() { + ui.label(RichText::new(sub).size(11.0).color(Color32::from_rgb(148, 163, 184))); + } + }); +} + +fn chip_inset(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { + egui::Frame::none() + .fill(PANEL_INSET) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(34, 44, 58))) + .rounding(Rounding::same(9.0)) + .inner_margin(Margin::symmetric(11.0, 10.0)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + add(ui) + }) + .inner +} + +fn logo_mark(ui: &mut Ui, logo: Option, sz: f32) { + let (rect, _resp) = ui.allocate_exact_size(Vec2::splat(sz), Sense::hover()); + let rounding = Rounding::same(sz * 0.26); + // glow + gradient tile + let p = ui.painter(); + p.add(Shape::from( + Shadow { + offset: Vec2::new(0.0, 3.0), + blur: 14.0, + spread: 0.0, + color: Color32::from_rgba_unmultiplied(25, 200, 126, 90), + } + .as_shape(rect, rounding), + )); + p.add(rounded_vgrad(rect, sz * 0.26, Color32::from_rgb(37, 211, 134), Color32::from_rgb(22, 173, 105))); + if let Some(tex) = logo { + // draw the embedded logo glyph centred within the tile + let inner = rect.shrink(sz * 0.16); + ui.painter().image( + tex, + inner, + Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), + Color32::WHITE, + ); + } else { + // fallback: a simple white mountain glyph + ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, "▲", FontId::proportional(sz * 0.5), Color32::WHITE); + } +} + +fn paint_grid(painter: &egui::Painter, rect: Rect) { + let step = 28.0; + let col = Color32::from_rgba_unmultiplied(255, 255, 255, 4); + let mut x = rect.left(); + while x < rect.right() { + painter.vline(x, rect.top()..=rect.bottom(), Stroke::new(1.0_f32, col)); + x += step; + } + let mut y = rect.top(); + while y < rect.bottom() { + painter.hline(rect.left()..=rect.right(), y, Stroke::new(1.0_f32, col)); + y += step; + } +} + +// ============================ LOW-LEVEL HELPERS ============================ + +/// A filled rounded-rectangle with a smooth vertical colour gradient, built as +/// a per-vertex-coloured triangle fan. This is the core of the "3D" depth look. +fn rounded_vgrad(rect: Rect, radius: f32, top: Color32, bot: Color32) -> Shape { + if rect.width() <= 0.0 || rect.height() <= 0.0 { + return Shape::Noop; + } + let r = radius.min(rect.width() * 0.5).min(rect.height() * 0.5).max(0.0); + let col = |y: f32| -> Color32 { + let t = ((y - rect.top()) / rect.height().max(1.0)).clamp(0.0, 1.0); + lerp_color(top, bot, t) + }; + let mut mesh = Mesh::default(); + let center = rect.center(); + mesh.colored_vertex(center, col(center.y)); + + let seg = 6usize; + let corners = [ + (Pos2::new(rect.left() + r, rect.top() + r), 180.0f32, 270.0f32), + (Pos2::new(rect.right() - r, rect.top() + r), 270.0, 360.0), + (Pos2::new(rect.right() - r, rect.bottom() - r), 0.0, 90.0), + (Pos2::new(rect.left() + r, rect.bottom() - r), 90.0, 180.0), + ]; + let mut pts: Vec = Vec::with_capacity((seg + 1) * 4); + for (c, a0, a1) in corners { + for i in 0..=seg { + let a = (a0 + (a1 - a0) * (i as f32 / seg as f32)).to_radians(); + pts.push(Pos2::new(c.x + r * a.cos(), c.y + r * a.sin())); + } + } + for p in &pts { + mesh.colored_vertex(*p, col(p.y)); + } + let n = pts.len() as u32; + for i in 0..n { + let a = 1 + i; + let b = 1 + (i + 1) % n; + mesh.add_triangle(0, a, b); + } + Shape::mesh(mesh) +} + +fn lerp_u8(a: u8, b: u8, t: f32) -> u8 { + (a as f32 + (b as f32 - a as f32) * t).round().clamp(0.0, 255.0) as u8 +} + +fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 { + Color32::from_rgba_unmultiplied( + lerp_u8(a.r(), b.r(), t), + lerp_u8(a.g(), b.g(), t), + lerp_u8(a.b(), b.b(), t), + lerp_u8(a.a(), b.a(), t), + ) +} + +fn lighten(c: Color32, amt: f32) -> Color32 { + lerp_color(c, Color32::WHITE, amt) +} + +/// Format a 9-digit host ID with spaces: 123456789 -> "123 456 789". +fn fmt_id(id: &str, spaced: bool) -> String { + let digits: String = id.chars().filter(|c| c.is_ascii_digit()).collect(); + if !spaced || digits.len() != 9 { + return if digits.is_empty() { id.to_string() } else { digits }; + } + format!("{} {} {}", &digits[0..3], &digits[3..6], &digits[6..9]) +} + +/// Extract the relay host from a ws(s):// URL for the top-bar chip. +fn relay_host(server: &str) -> String { + let s = server + .trim() + .trim_start_matches("wss://") + .trim_start_matches("ws://") + .trim_start_matches("https://") + .trim_start_matches("http://"); + s.split('/').next().unwrap_or("—").to_string() +} + +fn pretty_toggle(name: &str) -> &'static str { + match name { + "service_installed" | "service" => "Service Installed", + "start_with_windows" | "startup" => "Start with Windows", + "privacy" => "Privacy Mode", + "lock_on_end" | "lock" => "Lock on disconnect", + "close_to_tray" => "Close to tray", + _ => "Setting", + } +} + +#[cfg(windows)] +fn open_url(url: &str) -> std::io::Result<()> { + std::process::Command::new("cmd") + .args(["/C", "start", "", url]) + .spawn() + .map(|_| ()) +} +#[cfg(not(windows))] +fn open_url(url: &str) -> std::io::Result<()> { + std::process::Command::new("xdg-open").arg(url).spawn().map(|_| ()) +} + + +/// Build the standard otpauth:// URI for a base32 TOTP secret, so an +/// authenticator app (or our QR) enrolls it with SHA1 / 6 digits / 30s — +/// matching the host engine's RFC-6238 verifier (core::crypto::totp_verify). +fn totp_otpauth_uri(secret_b32: &str, account: &str) -> String { + fn enc(s: &str) -> String { + s.bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + (b as char).to_string() + } + _ => format!("%{:02X}", b), + }) + .collect() + } + let label = enc(&format!("NetDesk:{account}")); + format!( + "otpauth://totp/{label}?secret={secret_b32}&issuer=NetDesk&algorithm=SHA1&digits=6&period=30" + ) +} + +/// Render `uri` to a QR as an egui ColorImage (black modules on white, a 4-module +/// quiet zone, 4px/module). None if the text is too long for a QR. +fn qr_color_image(uri: &str) -> Option { + let qr = qrcodegen::QrCode::encode_text(uri, qrcodegen::QrCodeEcc::Medium).ok()?; + let n = qr.size(); // modules per side (i32) + let quiet = 4i32; + let scale = 4usize; + let dim = ((n + 2 * quiet) as usize) * scale; + let mut px = vec![255u8; dim * dim * 4]; // white RGBA + for y in 0..n { + for x in 0..n { + if qr.get_module(x, y) { + for dy in 0..scale { + for dx in 0..scale { + let py = ((y + quiet) as usize) * scale + dy; + let pxi = ((x + quiet) as usize) * scale + dx; + let idx = (py * dim + pxi) * 4; + px[idx] = 0; + px[idx + 1] = 0; + px[idx + 2] = 0; + px[idx + 3] = 255; + } + } + } + } + } + Some(egui::ColorImage::from_rgba_unmultiplied([dim, dim], &px)) +} + +#[cfg(test)] +mod totp_qr_tests { + use super::*; + + #[test] + fn otpauth_uri_and_qr_generate() { + let uri = totp_otpauth_uri("JBSWY3DPEHPK3PXP", "this PC"); + assert!( + uri.starts_with("otpauth://totp/NetDesk%3Athis%20PC?secret=JBSWY3DPEHPK3PXP"), + "uri={uri}" + ); + let img = qr_color_image(&uri).expect("qr encodes"); + assert!(img.size[0] > 20 && img.size[0] == img.size[1], "square qr, got {:?}", img.size); + let black = img.pixels.iter().filter(|p| p.r() == 0).count(); + assert!(black > 0 && black < img.pixels.len(), "qr must mix black + white modules"); + println!("TOTP_QR_OK size={} black_px={}", img.size[0], black); + } +} diff --git a/native/viewer/src/host_ctl.rs b/native/viewer/src/host_ctl.rs new file mode 100644 index 0000000..6a9ea45 --- /dev/null +++ b/native/viewer/src/host_ctl.rs @@ -0,0 +1,1959 @@ +//! NetDesk host-control backend — a native Rust port of the Python desktop +//! agent's `Api` class (`netdesk/host/netdesk_gui.py`). +//! +//! This module gives the (stage-2) Rust host panel everything it needs to show +//! THIS PC's NetDesk identity and drive the local engine/service, WITHOUT any +//! browser or Python: +//! * read / write the shared `config.json` (same file the engine uses, so the +//! native panel and the existing engine share one identity), +//! * report engine status (online / in-session) from `gui_status.json`, +//! * start / stop the engine process (the auto-start Scheduled Task is left +//! intact — Stop only pauses the running engine), +//! * set / generate the unattended password (PBKDF2-200k verifier identical +//! to what the host expects, via `netdesk_core::crypto`), +//! * toggle 2FA and the privacy / lock-on-end / startup / tray / service +//! switches (writing config + doing the matching OS action), +//! * sign in / sign up / sign out against the relay `/api`, list this +//! account's devices, and ping the relay. +//! +//! Everything returns `Result` with a clear error; nothing panics. All OS- and +//! network-specific work is isolated in small helpers and `#[cfg]`-gated so the +//! module also compiles on non-Windows targets. +//! +//! Developed by Akronlogic (https://akronlogic.com). +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +#[cfg(not(windows))] +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +/// The built-in relay, used when `config.json` has no `server` (mirrors the +/// engine's `BUILTIN_SERVER` / `DEFAULT_SERVER`). +pub const DEFAULT_SERVER: &str = "wss://netdesk.space/ws"; + +/// Name of the host engine executable we spawn for Start, and kill for Stop. +#[cfg(windows)] +const ENGINE_EXE: &str = "NetDeskHost.exe"; +#[cfg(not(windows))] +const ENGINE_EXE: &str = "netdesk-host"; + +/// `CreateProcess` flag to launch the engine with no console window. +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +// =========================================================================== +// Error type — a clear, non-panicking error for every public call. +// =========================================================================== + +#[derive(Debug, Clone)] +pub enum CtlError { + /// Bad user input (e.g. a malformed email or a too-short password). + Invalid(String), + /// Local filesystem / config read-write failure. + Io(String), + /// Could not reach the relay (connection/TLS/timeout). + Http(String), + /// The relay answered with an error payload (`{"error": "..."}`). + Server(String), + /// An OS action failed (spawning the engine, registry, clipboard, …). + Os(String), +} + +impl std::fmt::Display for CtlError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CtlError::Invalid(m) => write!(f, "{m}"), + CtlError::Io(m) => write!(f, "{m}"), + CtlError::Http(m) => write!(f, "{m}"), + CtlError::Server(m) => write!(f, "{m}"), + CtlError::Os(m) => write!(f, "{m}"), + } + } +} + +impl std::error::Error for CtlError {} + +impl From for CtlError { + fn from(e: std::io::Error) -> Self { + CtlError::Io(e.to_string()) + } +} +impl From for CtlError { + fn from(e: serde_json::Error) -> Self { + CtlError::Io(format!("json: {e}")) + } +} + +pub type Result = std::result::Result; + +// =========================================================================== +// Config model — mirrors the engine's `config.json` (netdesk_host.py `Config`). +// =========================================================================== + +fn d_true() -> bool { + true +} +fn d_iters() -> u32 { + 200_000 +} + +/// The four per-session permission switches stored under `perms`. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Perms { + #[serde(default = "d_true")] + pub input: bool, + #[serde(default = "d_true")] + pub clipboard: bool, + #[serde(default = "d_true")] + pub files: bool, + #[serde(default = "d_true")] + pub audio: bool, +} +impl Default for Perms { + fn default() -> Self { + Self { + input: true, + clipboard: true, + files: true, + audio: true, + } + } +} + +/// The shared host configuration (`config.json`). Unknown keys are preserved in +/// `extra` so a round-trip never drops a field the engine wrote. `path` is where +/// this instance was loaded from / will be saved to (never serialized). +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct HostConfig { + #[serde(default)] + pub id: String, + #[serde(default)] + pub salt: String, + #[serde(default = "d_iters")] + pub iters: u32, + #[serde(default)] + pub verifier: String, + #[serde(default)] + pub server: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub perms: Perms, + #[serde(default)] + pub lock_on_end: bool, + #[serde(default)] + pub privacy_on_session: bool, + #[serde(default)] + pub allow_display_power: bool, + #[serde(default)] + pub server_locked_by_user: bool, + #[serde(default)] + pub account_token: String, + #[serde(default)] + pub account_email: String, + #[serde(default)] + pub allow_ips: String, + /// Approve-before-connect: hold a password-authenticated viewer until the + /// person at this PC clicks Accept (20s timeout denies). Off by default — + /// unattended hosts stay reachable. + #[serde(default)] + pub approve_click: bool, + #[serde(default)] + pub pw_plain: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub reg_key: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub totp: String, + #[serde(default = "d_true")] + pub close_to_tray: bool, + /// Any other keys present in the file (preserved verbatim on save). + #[serde(flatten)] + pub extra: Map, + /// Where this config lives on disk. Not part of the JSON. + #[serde(skip)] + pub path: PathBuf, +} + +impl Default for HostConfig { + fn default() -> Self { + Self { + id: String::new(), + salt: String::new(), + iters: 200_000, + verifier: String::new(), + server: String::new(), + name: String::new(), + perms: Perms::default(), + lock_on_end: false, + privacy_on_session: false, + allow_display_power: false, + server_locked_by_user: false, + account_token: String::new(), + account_email: String::new(), + allow_ips: String::new(), + approve_click: false, + pw_plain: String::new(), + reg_key: String::new(), + totp: String::new(), + close_to_tray: true, + extra: Map::new(), + path: PathBuf::new(), + } + } +} + +impl HostConfig { + /// Load from the resolved shared location (see [`config_path`]). + pub fn load() -> Result { + read_config() + } + + /// Persist back to `self.path`. + pub fn save(&self) -> Result<()> { + write_config(self) + } + + /// `https://host[:port]` base for the relay's HTTP API, derived from + /// `server` exactly like the Python `_api_base` (ws→http, drop trailing + /// `/ws`). e.g. `wss://netdesk.space/ws` → `https://netdesk.space`. + pub fn api_base(&self) -> String { + let mut url = if self.server.trim().is_empty() { + DEFAULT_SERVER.to_string() + } else { + self.server.trim().to_string() + }; + url = url.replace("wss://", "https://").replace("ws://", "http://"); + if let Some(idx) = url.rfind("/ws") { + url.truncate(idx); + } + url.trim_end_matches('/').to_string() + } + + /// `true` once this account has a saved relay token. + pub fn signed_in(&self) -> bool { + !self.account_token.is_empty() + } +} + +// =========================================================================== +// Output types for the UI. +// =========================================================================== + +/// Live engine state: online when a fresh (< 12 s) `gui_status.json` heartbeat +/// is found in any engine dir, OR a `--engine` process is running, OR the relay +/// recently reported this id online (see [`engine_status`]). `state` is the raw +/// engine state string (`online` / `session` / `connecting` / `error…` / +/// `running` / `stopped`). +#[derive(Serialize, Clone, Debug)] +pub struct EngineStatus { + pub online: bool, + pub in_session: bool, + pub state: String, +} + +/// One of this account's computers, from `GET /api/devices`. +#[derive(Serialize, Deserialize, Clone, Debug, Default)] +pub struct Device { + #[serde(default)] + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub online: bool, + #[serde(default)] + pub busy: bool, + #[serde(default)] + pub ver: String, + #[serde(default)] + pub mac: String, + #[serde(default)] + pub last_seen: i64, +} + +/// Result of a successful sign-in / sign-up. +#[derive(Serialize, Clone, Debug, Default)] +pub struct Account { + pub email: String, + pub msg: String, +} + +/// Result of toggling 2FA. When enabling, `secret` is the new base32 secret and +/// `secret_file` is the `totp_secret.txt` written next to the config. +#[derive(Serialize, Clone, Debug, Default)] +pub struct TwoFa { + pub on: bool, + pub secret: Option, + pub secret_file: Option, +} + +/// A ready-to-render snapshot of this PC's identity + state (mirrors the Python +/// `Api.get_state`), assembled from the config and the live checks. +#[derive(Serialize, Clone, Debug)] +pub struct HostState { + pub id: String, + pub name: String, + pub server: String, + pub account: String, + pub password: String, + pub state: String, + pub online: bool, + pub in_session: bool, + pub totp: bool, + pub privacy: bool, + pub lock_on_end: bool, + pub close_to_tray: bool, + pub service_installed: bool, + pub startup: bool, + pub config_path: String, +} + +// =========================================================================== +// Config path resolution + read/write. +// =========================================================================== + +/// Resolve the shared `config.json` path, mirroring the engine's +/// `default_config_dir()` so the native panel and the engine agree: +/// 1. `$NETDESK_CONFIG` (explicit override, handy for dev), +/// 2. `config.json` next to the current executable (portable layout), +/// 3. the per-user location (`%APPDATA%\NetDesk\config.json`, or +/// `~/.netdesk/config.json`) if it already exists, +/// 4. otherwise create in the portable location when that dir is writable, +/// 5. else the per-user location. +pub fn config_path() -> PathBuf { + if let Ok(p) = std::env::var("NETDESK_CONFIG") { + if !p.trim().is_empty() { + return PathBuf::from(p); + } + } + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let c = dir.join("config.json"); + if c.is_file() { + return c; + } + } + } + if let Some(c) = per_user_config() { + if c.is_file() { + return c; + } + } + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + if dir_writable(dir) { + return dir.join("config.json"); + } + } + } + per_user_config().unwrap_or_else(|| PathBuf::from("config.json")) +} + +#[cfg(windows)] +fn per_user_config() -> Option { + std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("NetDesk").join("config.json")) +} +#[cfg(not(windows))] +fn per_user_config() -> Option { + std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".netdesk").join("config.json")) +} + +/// Read the shared config from [`config_path`]. +pub fn read_config() -> Result { + read_config_at(config_path()) +} + +/// Read (or create) the config at a specific path. Missing identity fields are +/// generated and persisted exactly like the engine's `Config.__init__`. +pub fn read_config_at(path: PathBuf) -> Result { + let existed = path.is_file(); + let mut cfg = if existed { + match std::fs::read_to_string(&path) { + Ok(s) => parse_config(&s), + Err(_) => HostConfig::default(), + } + } else { + HostConfig::default() + }; + cfg.path = path; + + let mut mutated = !existed; + if cfg.id.trim().is_empty() { + cfg.id = netdesk_core::crypto::gen_host_id(); + mutated = true; + } + if cfg.verifier.trim().is_empty() { + let pw = netdesk_core::crypto::gen_password(8); + let (salt, iters, verifier) = netdesk_core::crypto::new_password_record(&pw); + cfg.salt = salt; + cfg.iters = iters; + cfg.verifier = verifier; + cfg.pw_plain = pw; + mutated = true; + } + if cfg.iters == 0 { + cfg.iters = 200_000; + } + if cfg.server.trim().is_empty() { + cfg.server = DEFAULT_SERVER.to_string(); + } + if cfg.name.trim().is_empty() { + cfg.name = computer_name(); + } + if mutated { + write_config(&cfg)?; + } + Ok(cfg) +} + +/// Apply a machine-wide managed policy (GPO-style) over a loaded config. Any key +/// present in `%ProgramData%\NetDesk\policy.json` OVERRIDES the user's value and +/// is reported as LOCKED (the UI greys the field). The overlay is in-memory only +/// — never written back to config.json — so removing the policy reverts to the +/// user's own settings. Returns the set of locked top-level keys. +#[cfg(windows)] +pub fn apply_policy(cfg: &mut HostConfig) -> std::collections::HashSet { + apply_policy_from(cfg, programdata_dir().join("policy.json")) +} + +/// Core of [`apply_policy`], taking an explicit policy path (so it's testable). +pub fn apply_policy_from( + cfg: &mut HostConfig, + path: PathBuf, +) -> std::collections::HashSet { + let mut locked = std::collections::HashSet::new(); + let text = match std::fs::read_to_string(&path) { + Ok(t) => t, + Err(_) => return locked, // no policy -> nothing locked + }; + let obj = match serde_json::from_str::(&text) { + Ok(Value::Object(m)) => m, + _ => return locked, + }; + for (k, val) in &obj { + match k.as_str() { + "server" => { + if let Some(s) = val.as_str() { + cfg.server = s.to_string(); + locked.insert(k.clone()); + } + } + "reg_key" => { + if let Some(s) = val.as_str() { + cfg.reg_key = s.to_string(); + locked.insert(k.clone()); + } + } + "allow_ips" => { + if let Some(s) = val.as_str() { + cfg.allow_ips = s.to_string(); + locked.insert(k.clone()); + } + } + "totp" => { + if let Some(s) = val.as_str() { + cfg.totp = s.to_string(); + locked.insert(k.clone()); + } + } + "lock_on_end" => { + if let Some(b) = val.as_bool() { + cfg.lock_on_end = b; + locked.insert(k.clone()); + } + } + "approve_click" => { + if let Some(b) = val.as_bool() { + cfg.approve_click = b; + locked.insert(k.clone()); + } + } + "perms" => { + if let Some(p) = val.as_object() { + if let Some(b) = p.get("input").and_then(|x| x.as_bool()) { + cfg.perms.input = b; + } + if let Some(b) = p.get("clipboard").and_then(|x| x.as_bool()) { + cfg.perms.clipboard = b; + } + if let Some(b) = p.get("files").and_then(|x| x.as_bool()) { + cfg.perms.files = b; + } + if let Some(b) = p.get("audio").and_then(|x| x.as_bool()) { + cfg.perms.audio = b; + } + locked.insert(k.clone()); + } + } + _ => {} + } + } + locked +} + +/// Parse config JSON. Tries the typed form first; on any shape mismatch falls +/// back to a tolerant field-by-field load so a single odd value never discards +/// the device identity. +fn parse_config(s: &str) -> HostConfig { + if let Ok(c) = serde_json::from_str::(s) { + return c; + } + match serde_json::from_str::(s) { + Ok(Value::Object(m)) => HostConfig::from_raw(m), + _ => HostConfig::default(), + } +} + +/// Write the config atomically (tmp file + rename), mirroring `Config.save`. +pub fn write_config(cfg: &HostConfig) -> Result<()> { + let path = &cfg.path; + if path.as_os_str().is_empty() { + return Err(CtlError::Io("config path is empty".into())); + } + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).ok(); + } + } + let json = serde_json::to_string_pretty(cfg)?; + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, json.as_bytes())?; + // std::fs::rename overwrites the destination on Windows and Unix. + std::fs::rename(&tmp, path)?; + Ok(()) +} + +impl HostConfig { + /// Tolerant loader used when the strict typed parse fails. + fn from_raw(mut m: Map) -> HostConfig { + let mut c = HostConfig::default(); + if let Some(v) = m.remove("id") { + c.id = str_of(&v); + } + if let Some(v) = m.remove("salt") { + c.salt = str_of(&v); + } + if let Some(v) = m.remove("iters") { + c.iters = u32_of(&v, 200_000); + } + if let Some(v) = m.remove("verifier") { + c.verifier = str_of(&v); + } + if let Some(v) = m.remove("server") { + c.server = str_of(&v); + } + if let Some(v) = m.remove("name") { + c.name = str_of(&v); + } + if let Some(Value::Object(p)) = m.remove("perms") { + c.perms = Perms { + input: p.get("input").map(bool_of).unwrap_or(true), + clipboard: p.get("clipboard").map(bool_of).unwrap_or(true), + files: p.get("files").map(bool_of).unwrap_or(true), + audio: p.get("audio").map(bool_of).unwrap_or(true), + }; + } + if let Some(v) = m.remove("lock_on_end") { + c.lock_on_end = bool_of(&v); + } + if let Some(v) = m.remove("privacy_on_session") { + c.privacy_on_session = bool_of(&v); + } + if let Some(v) = m.remove("allow_display_power") { + c.allow_display_power = bool_of(&v); + } + if let Some(v) = m.remove("server_locked_by_user") { + c.server_locked_by_user = bool_of(&v); + } + if let Some(v) = m.remove("account_token") { + c.account_token = str_of(&v); + } + if let Some(v) = m.remove("account_email") { + c.account_email = str_of(&v); + } + if let Some(v) = m.remove("allow_ips") { + c.allow_ips = str_of(&v); + } + if let Some(v) = m.remove("pw_plain") { + c.pw_plain = str_of(&v); + } + if let Some(v) = m.remove("reg_key") { + c.reg_key = str_of(&v); + } + if let Some(v) = m.remove("totp") { + c.totp = str_of(&v); + } + if let Some(v) = m.remove("close_to_tray") { + c.close_to_tray = bool_of(&v); + } + c.extra = m; + c + } +} + +// =========================================================================== +// Engine status + start / stop. +// =========================================================================== + +/// Live engine status for the panel. The engine is reported ONLINE when ANY of +/// these hold (fixing the panel showing "Stopped" while the real engine runs): +/// (a) a fresh (< 12 s) `gui_status.json` heartbeat — read from EVERY folder +/// the engine/broker might write it to (the panel's config dir, the +/// engine exe's folder, `%APPDATA%\NetDesk`, the ProgramData service +/// mirror), trusting the freshest. The old code looked only in the +/// panel's own config dir, so a portable engine heartbeating next to +/// `NetDeskHost.exe` was never seen; +/// (b) a `NetDeskHost --engine` process is running locally; +/// (c) the relay recently reported THIS id online — fed from the panel's +/// `/api/devices` poll (see [`note_server_devices`]) and refreshed in the +/// background, so this call never blocks on the network. +pub fn engine_status(cfg: &HostConfig) -> EngineStatus { + let mut es = engine_status_local(cfg); + if !es.online && server_says_online(&cfg.id) { + es.online = true; + if es.state == "stopped" { + es.state = "online".to_string(); + } + } + // Offline on local signals -> learn the relay's view for next time, without + // ever blocking this (UI-thread) call. + if !es.online { + maybe_refresh_server_online(cfg); + } + es +} + +/// Local-only liveness (status files + process check). This is the basis of +/// [`engine_status`] AND the guard used by [`start_service`], so a stale server +/// flag or a remote holder can never block starting the LOCAL engine. +fn engine_status_local(cfg: &HostConfig) -> EngineStatus { + // Unified app: the in-process native Rust engine is authoritative when it is + // running (no heartbeat file / separate process to detect). + #[cfg(windows)] + if let Some(ns) = native_engine_status() { + let state = if ns.viewer_connected { + "session" + } else if ns.registered { + "online" + } else { + "running" + }; + return EngineStatus { + online: true, + in_session: ns.viewer_connected, + state: state.to_string(), + }; + } + + let mut state = String::from("stopped"); + let mut in_session = false; + let mut fresh = false; + + if let Some((_, st, insess)) = freshest_status(cfg) { + fresh = true; + state = st; + in_session = insess; + } + + // A stale duplicate engine writes "already connected elsewhere" between the + // real engine's heartbeats; with a fresh local heartbeat that means the ID + // IS served here -> online (mirrors _get_state_inner's anti-flicker). + if fresh && state.contains("already connected") { + state = "online".to_string(); + } + + let mut online = fresh && state != "stopped"; + if !online && engine_process_running() { + online = true; + if state == "stopped" { + state = "running".to_string(); + } + } + if state == "session" { + in_session = true; + } + + EngineStatus { + online, + in_session, + state, + } +} + +/// The freshest (< 12 s) `gui_status.json` heartbeat for THIS id across every +/// directory the engine/broker might write it to. Returns `(ts, state, +/// in_session)`, or `None` when nothing fresh for this id is found. +fn freshest_status(cfg: &HostConfig) -> Option<(f64, String, bool)> { + let now = now_secs(); + let mut best: Option<(f64, String, bool)> = None; + for dir in status_dirs(cfg) { + let s = match std::fs::read_to_string(dir.join("gui_status.json")) { + Ok(s) => s, + Err(_) => continue, + }; + let v: Value = match serde_json::from_str(&s) { + Ok(v) => v, + Err(_) => continue, + }; + let ts = v.get("ts").and_then(Value::as_f64).unwrap_or(0.0); + if now - ts >= 12.0 { + continue; // stale heartbeat + } + // A status file naming a DIFFERENT id belongs to another device (e.g. a + // shared folder) — never read this machine's state from it. + if !cfg.id.trim().is_empty() { + if let Some(fid) = v.get("id").and_then(Value::as_str) { + if !fid.is_empty() && fid != cfg.id { + continue; + } + } + } + if best.as_ref().map(|(b, _, _)| ts > *b).unwrap_or(true) { + let state = v + .get("state") + .and_then(Value::as_str) + .unwrap_or("running") + .to_string(); + let in_session = v.get("in_session").and_then(Value::as_bool).unwrap_or(false); + best = Some((ts, state, in_session)); + } + } + best +} + +/// Every directory a NetDesk engine or broker might have written +/// `gui_status.json` into. The Python engine writes it next to ITS config +/// (`default_config_dir()`): the exe folder for a frozen/portable build, else +/// `%APPDATA%\NetDesk`. A unified install keeps the engine beside the viewer, +/// and the SYSTEM service mirrors to ProgramData. We read them all and trust the +/// freshest, so the panel sees the real engine regardless of how it launched. +fn status_dirs(cfg: &HostConfig) -> Vec { + let mut dirs: Vec = Vec::new(); + let mut push = |d: PathBuf| { + if !d.as_os_str().is_empty() && !dirs.iter().any(|x| x == &d) { + dirs.push(d); + } + }; + // 1. The config dir the panel itself resolved. + if let Some(p) = cfg.path.parent() { + push(p.to_path_buf()); + } + // 2. Beside the running viewer (unified/portable layout: engine next to us). + if let Ok(exe) = std::env::current_exe() { + if let Some(d) = exe.parent() { + push(d.to_path_buf()); + } + } + // 3. Beside the engine exe we would spawn / that is installed. + if let Some(d) = host_engine_exe(cfg).parent() { + push(d.to_path_buf()); + } + // 4. The per-user location (%APPDATA%\NetDesk or ~/.netdesk). + if let Some(d) = per_user_config().and_then(|c| c.parent().map(Path::to_path_buf)) { + push(d); + } + // 5. The SYSTEM service's ProgramData mirror. + #[cfg(windows)] + push(programdata_dir()); + dirs +} + +// --- relay online flag (fed by the panel's /api/devices poll) --------------- + +/// The relay's last-known online flag for a host id, and when we observed it. +struct ServerOnline { + id: String, + online: bool, + at: Instant, +} +static SERVER_ONLINE: Mutex> = Mutex::new(None); +static SERVER_REFRESH_AT: Mutex> = Mutex::new(None); +static SERVER_REFRESH_INFLIGHT: AtomicBool = AtomicBool::new(false); + +/// Record what `GET /api/devices` reported for `id`. The panel already polls the +/// device list every ~20 s; calling this on each result lets [`engine_status`] +/// honor the relay's online flag for free, with no extra network traffic. +pub fn note_server_devices(id: &str, devices: &[Device]) { + let online = devices.iter().any(|d| d.id == id && d.online); + note_server_online(id, online); +} + +/// Record a single relay online observation for `id`. +pub fn note_server_online(id: &str, online: bool) { + if let Ok(mut g) = SERVER_ONLINE.lock() { + *g = Some(ServerOnline { + id: id.to_string(), + online, + at: Instant::now(), + }); + } +} + +/// `true` when the relay reported THIS id online within the last 45 s (longer +/// than the panel's 20 s poll so one missed poll never flips it to Stopped). +fn server_says_online(id: &str) -> bool { + if id.trim().is_empty() { + return false; + } + SERVER_ONLINE + .lock() + .ok() + .and_then(|g| { + g.as_ref() + .map(|s| s.id == id && s.online && s.at.elapsed() < Duration::from_secs(45)) + }) + .unwrap_or(false) +} + +/// Best-effort, throttled (<= once / 15 s) background refresh of the relay +/// online flag for signed-in accounts. Never blocks the caller: the result +/// lands in `SERVER_ONLINE` for the next [`engine_status`]. Keeps the status +/// honest even when nothing else happens to be polling `/api/devices`. +fn maybe_refresh_server_online(cfg: &HostConfig) { + if cfg.account_token.trim().is_empty() || cfg.id.trim().is_empty() { + return; + } + { + let mut g = match SERVER_REFRESH_AT.lock() { + Ok(g) => g, + Err(_) => return, + }; + if let Some(t) = *g { + if t.elapsed() < Duration::from_secs(15) { + return; + } + } + *g = Some(Instant::now()); + } + if SERVER_REFRESH_INFLIGHT.swap(true, Ordering::SeqCst) { + return; // one refresh already in flight + } + let cfg = cfg.clone(); + std::thread::spawn(move || { + if let Ok(devs) = list_devices(&cfg) { + note_server_devices(&cfg.id, &devs); + } + SERVER_REFRESH_INFLIGHT.store(false, Ordering::SeqCst); + }); +} + +/// Handle to the in-process native Rust host engine (the unified app). Start +/// runs `netdesk_host::run::run_engine` on a background thread IN THIS PROCESS — +/// capture + hardware encode + SendInput, no external Python engine. Windows. +#[cfg(windows)] +static NATIVE_ENGINE: Mutex> = Mutex::new(None); + +/// Live status of the in-process native engine, or `None` when not running. +#[cfg(windows)] +pub fn native_engine_status() -> Option { + NATIVE_ENGINE + .lock() + .ok() + .and_then(|g| g.as_ref().map(|h| h.snapshot())) +} +#[cfg(not(windows))] +pub fn native_engine_status() -> Option<()> { + None +} + +/// Kill any stray external Python `NetDeskHost.exe --engine` so only the native +/// in-process engine holds the ID. +#[cfg(windows)] +fn stop_python_engine() { + let ps = "Get-CimInstance Win32_Process -Filter \"Name='NetDeskHost.exe'\" | \ + Where-Object { $_.CommandLine -like '*--engine*' } | \ + ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"; + let _ = run_ps(ps); +} + +/// Start the host engine IN-PROCESS (native Rust): DXGI capture + hardware +/// H.264 encode + SendInput, serving viewers with auto-reconnect. This is the +/// unified app — no Python engine is spawned. +pub fn start_service(cfg: &HostConfig) -> Result<()> { + #[cfg(windows)] + { + if NATIVE_ENGINE.lock().map(|g| g.is_some()).unwrap_or(false) { + return Err(CtlError::Os("Engine is already running.".into())); + } + // Apply a machine-wide managed policy (if present) BEFORE starting, so an + // admin's locked server / permissions / lock-on-end win over the local + // config. In-memory only (not persisted), so removing the policy reverts. + let mut cfg_owned = cfg.clone(); + let locked = apply_policy(&mut cfg_owned); + if !locked.is_empty() { + eprintln!("[netdesk] managed policy active; locked: {locked:?}"); + } + let cfg = &cfg_owned; + let id = cfg.id.trim().to_string(); + if id.len() != 9 || !id.chars().all(|c| c.is_ascii_digit()) { + return Err(CtlError::Invalid("No valid 9-digit host ID.".into())); + } + let password = cfg.pw_plain.trim().to_string(); + if password.chars().count() < 8 { + return Err(CtlError::Invalid( + "Set a password (8+ characters) before starting.".into(), + )); + } + let ws_url = if cfg.server.trim().is_empty() { + DEFAULT_SERVER.to_string() + } else { + cfg.server.trim().to_string() + }; + let name = if cfg.name.trim().is_empty() { + "NetDesk".to_string() + } else { + cfg.name.trim().to_string() + }; + + // Only the native engine should hold the ID. + stop_python_engine(); + + let engine_cfg = netdesk_host::run::EngineConfig { + ws_url, + host_id: id, + name, + password, + monitor: 0, + fps: 60, + bitrate: 8_000_000, + // The config.json permission switches are ENFORCED by the engine + // (events for a disabled channel are never emitted) and advertised + // to viewers in session.accept info.perms. + perms: netdesk_core::host::HostPerms { + input: cfg.perms.input, + clipboard: cfg.perms.clipboard, + files: cfg.perms.files, + audio: cfg.perms.audio, + }, + approve: cfg.approve_click, + totp_secret: cfg.totp.clone(), + // The GUI host uses the relay; direct-IP is opt-in via the engine bin + // (ND_DIRECT_PORT) for now. The CIDR allow-list still applies if set. + direct_port: None, + allow_ips: cfg.allow_ips.clone(), + max_viewers: 1, + }; + let handle = netdesk_host::run::EngineHandle::new(); + let thread_handle = handle.clone(); + std::thread::Builder::new() + .name("netdesk-engine".into()) + .spawn(move || { + match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(rt) => { + if let Err(e) = + rt.block_on(netdesk_host::run::run_engine(engine_cfg, thread_handle)) + { + eprintln!("[netdesk] engine exited: {e}"); + } + } + Err(e) => eprintln!("[netdesk] engine runtime: {e}"), + } + }) + .map_err(|e| CtlError::Os(format!("spawn engine thread: {e}")))?; + *NATIVE_ENGINE.lock().unwrap() = Some(handle); + Ok(()) + } + #[cfg(not(windows))] + { + let _ = cfg; + Err(CtlError::Os("Hosting is Windows-only in this build.".into())) + } +} + +/// Stop the in-process native engine (and kill any stray Python engine). +pub fn stop_service(cfg: &HostConfig) -> Result<()> { + let _ = cfg; + if let Ok(mut g) = LAST_ENGINE_PID.lock() { + *g = None; + } + #[cfg(windows)] + { + if let Ok(mut g) = NATIVE_ENGINE.lock() { + if let Some(h) = g.take() { + h.stop(); + } + } + stop_python_engine(); + Ok(()) + } + #[cfg(not(windows))] + { + let _ = std::process::Command::new("pkill") + .args(["-f", "--engine"]) + .status(); + Ok(()) + } +} + +// =========================================================================== +// Password / 2FA. +// =========================================================================== + +/// Set the unattended password: fresh salt + PBKDF2-200k verifier (identical to +/// the host engine), saved to `salt` / `iters` / `verifier` (+ `pw_plain`). +pub fn set_unattended_password(cfg: &mut HostConfig, pw: &str) -> Result<()> { + let pw = pw.trim(); + if pw.chars().count() < 8 { + return Err(CtlError::Invalid("Use at least 8 characters.".into())); + } + let (salt, iters, verifier) = netdesk_core::crypto::new_password_record(pw); + cfg.salt = salt; + cfg.iters = iters; + cfg.verifier = verifier; + cfg.pw_plain = pw.to_string(); + write_config(cfg) +} + +/// One-click random password (10 chars), saved and returned (mirrors +/// `generate_password`). The engine picks it up at the next connection. +pub fn generate_password(cfg: &mut HostConfig) -> Result { + let pw = netdesk_core::crypto::gen_password(10); + let (salt, iters, verifier) = netdesk_core::crypto::new_password_record(&pw); + cfg.salt = salt; + cfg.iters = iters; + cfg.verifier = verifier; + cfg.pw_plain = pw.clone(); + write_config(cfg)?; + Ok(pw) +} + +/// Enable or disable 2FA. Enabling generates a 32-char base32 secret, writes it +/// to config and to `totp_secret.txt` next to the config (mirrors `toggle_2fa`). +pub fn toggle_2fa(cfg: &mut HostConfig, on: bool) -> Result { + if on { + let secret = netdesk_core::crypto::gen_totp_secret(); + cfg.totp = secret.clone(); + write_config(cfg)?; + let mut secret_file = None; + if let Some(dir) = cfg.path.parent() { + let p = dir.join("totp_secret.txt"); + if std::fs::write(&p, format!("NetDesk 2FA secret (base32)\n\n{secret}\n")).is_ok() { + secret_file = Some(p); + } + } + Ok(TwoFa { + on: true, + secret: Some(secret), + secret_file, + }) + } else { + cfg.totp.clear(); + write_config(cfg)?; + Ok(TwoFa { + on: false, + secret: None, + secret_file: None, + }) + } +} + +// =========================================================================== +// Switches: privacy / lock_on_end / startup / close_to_tray / service. +// =========================================================================== + +/// Flip a named switch and perform the matching OS action. Recognised names: +/// `privacy`, `lock_on_end` (alias `lock`), `close_to_tray`, +/// `start_with_windows` (alias `startup`), `service_installed` (alias +/// `service`). Returns a short human message (may be empty). +pub fn set_toggle(cfg: &mut HostConfig, name: &str, on: bool) -> Result { + match name { + "privacy" => { + cfg.privacy_on_session = on; + cfg.allow_display_power = on; + write_config(cfg)?; + Ok(String::new()) + } + "lock_on_end" | "lock" => { + cfg.lock_on_end = on; + write_config(cfg)?; + Ok(String::new()) + } + "close_to_tray" => { + cfg.close_to_tray = on; + write_config(cfg)?; + Ok(String::new()) + } + "start_with_windows" | "startup" => set_startup(cfg, on), + "service_installed" | "service" => { + if on { + install_service(cfg) + } else { + remove_service() + } + } + other => Err(CtlError::Invalid(format!("unknown toggle: {other}"))), + } +} + +#[cfg(windows)] +fn set_startup(_cfg: &HostConfig, on: bool) -> Result { + const RUN_KEY: &str = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run"; + if on { + // Auto-start the unified app itself in headless host mode (native Rust + // in-process engine) — not the old Python NetDeskHost.exe. + let exe = std::env::current_exe() + .map_err(|e| CtlError::Os(format!("cannot resolve the app path: {e}")))?; + let val = format!("\"{}\" --host", exe.display()); + let (code, _) = run_capture( + "reg", + &["add", RUN_KEY, "/v", "NetDesk", "/t", "REG_SZ", "/d", &val, "/f"], + )?; + cache_set(&STU_CACHE, true); + if code != 0 { + return Err(CtlError::Os("could not write the startup registry entry".into())); + } + Ok("Startup entry added.".into()) + } else { + let _ = run_capture("reg", &["delete", RUN_KEY, "/v", "NetDesk", "/f"]); + cache_set(&STU_CACHE, false); + Ok("Startup entry removed.".into()) + } +} +#[cfg(not(windows))] +fn set_startup(_cfg: &HostConfig, _on: bool) -> Result { + Err(CtlError::Os("the startup toggle is Windows-only".into())) +} + +#[cfg(windows)] +fn install_service(cfg: &HostConfig) -> Result { + // Hand over: stop the panel-spawned engine so the service owns the link. + let _ = stop_service(cfg); + let exe = host_engine_exe(cfg); + let server = if cfg.server.trim().is_empty() { + DEFAULT_SERVER + } else { + cfg.server.trim() + }; + let result_path = programdata_dir().join("install_result.json"); + let _ = std::fs::remove_file(&result_path); + + // Elevated install via UAC (Start-Process -Verb RunAs). The elevated + // NetDeskHost.exe --install creates the Scheduled Task. + let mut arglist = format!("'--install','--server','{}'", ps_single_quote(server)); + if !cfg.reg_key.trim().is_empty() { + arglist.push_str(&format!(",'--reg-key','{}'", ps_single_quote(cfg.reg_key.trim()))); + } + let ps = format!( + "Start-Process -FilePath '{}' -ArgumentList {} -Verb RunAs", + ps_single_quote(&exe.display().to_string()), + arglist + ); + run_ps(&ps).map_err(|e| CtlError::Os(format!("could not launch the installer: {e}")))?; + + // Wait for the elevated installer (self-extract + task create). Trust + // install_result.json, fall back to querying the task (mirrors Python). + for _ in 0..90 { + if let Ok(s) = std::fs::read_to_string(&result_path) { + if let Ok(v) = serde_json::from_str::(&s) { + if v.get("ok").and_then(Value::as_bool).unwrap_or(false) { + cache_set(&SVC_CACHE, true); + return Ok("Service installed — reachable at boot, the logon screen \ + and UAC prompts. The engine handed over to the service." + .into()); + } + let err = v + .get("error") + .and_then(Value::as_str) + .unwrap_or("install failed"); + return Err(CtlError::Os(err.to_string())); + } + } + if service_installed_uncached() == Some(true) { + cache_set(&SVC_CACHE, true); + return Ok("Service installed — reachable at boot, the logon screen \ + and UAC prompts." + .into()); + } + std::thread::sleep(Duration::from_millis(500)); + } + Err(CtlError::Os( + "Install is taking longer than expected. If you accepted the UAC prompt, \ + the toggle should turn on shortly — reopen the panel to check." + .into(), + )) +} +#[cfg(not(windows))] +fn install_service(_cfg: &HostConfig) -> Result { + Err(CtlError::Os("the service is Windows-only".into())) +} + +#[cfg(windows)] +fn remove_service() -> Result { + let _ = run_capture("schtasks", &["/Delete", "/TN", "NetDesk", "/F"]); + // Also stop any broker/engine processes left from the removed service. + let ps = "Get-CimInstance Win32_Process -Filter \"Name='NetDeskHost.exe'\" | \ + Where-Object { $_.CommandLine -like '*--broker*' -or $_.CommandLine -like '*--engine*' } | \ + ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"; + let _ = run_ps(ps); + cache_set(&SVC_CACHE, false); + Ok("Service removed (if it existed).".into()) +} +#[cfg(not(windows))] +fn remove_service() -> Result { + Err(CtlError::Os("the service is Windows-only".into())) +} + +/// Is the NetDesk Scheduled Task installed? Distinguishes not-found from +/// access-denied (task EXISTS) by HRESULT, locale-independent. Cached 10 s. +pub fn service_installed() -> bool { + #[cfg(windows)] + { + if let Some(v) = cache_get(&SVC_CACHE, Duration::from_secs(10)) { + return v; + } + let v = service_installed_uncached().unwrap_or_else(|| cache_last(&SVC_CACHE)); + cache_set(&SVC_CACHE, v); + v + } + #[cfg(not(windows))] + { + false + } +} + +#[cfg(windows)] +fn service_installed_uncached() -> Option { + let 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}}"; + match run_capture("powershell", &["-NoProfile", "-Command", ps]) { + Ok((_, out)) => Some(out.trim().ends_with('1')), + Err(_) => None, + } +} + +/// Is the HKCU Run startup entry present? Cached 10 s. +pub fn startup_present() -> bool { + #[cfg(windows)] + { + if let Some(v) = cache_get(&STU_CACHE, Duration::from_secs(10)) { + return v; + } + let v = match run_capture( + "reg", + &[ + "query", + r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run", + "/v", + "NetDesk", + ], + ) { + Ok((code, _)) => code == 0, + Err(_) => cache_last(&STU_CACHE), + }; + cache_set(&STU_CACHE, v); + v + } + #[cfg(not(windows))] + { + false + } +} + +// =========================================================================== +// Account (relay /api) + devices + ping — HTTP via blocking ureq (rustls). +// =========================================================================== + +fn http_agent() -> ureq::Agent { + ureq::AgentBuilder::new() + .timeout_connect(Duration::from_secs(10)) + .timeout_read(Duration::from_secs(20)) + .timeout_write(Duration::from_secs(20)) + .build() +} + +/// POST JSON to ``, attaching the account bearer token when set. +/// Returns the parsed JSON body; a 4xx/5xx with `{"error":…}` becomes +/// `CtlError::Server`, an unreachable relay becomes `CtlError::Http`. +fn api_post(cfg: &HostConfig, path: &str, body: Value) -> Result { + let url = format!("{}{}", cfg.api_base(), path); + let mut req = http_agent() + .post(&url) + .set("Content-Type", "application/json"); + if !cfg.account_token.is_empty() { + req = req.set("Authorization", &format!("Bearer {}", cfg.account_token)); + } + match req.send_json(body) { + Ok(resp) => resp + .into_json::() + .map_err(|e| CtlError::Server(format!("bad server response: {e}"))), + Err(ureq::Error::Status(code, resp)) => Err(CtlError::Server(server_error(resp, code))), + Err(ureq::Error::Transport(t)) => { + Err(CtlError::Http(format!("cannot reach server: {t}"))) + } + } +} + +/// Apply a successful auth response (save token + email) or surface its error. +fn account_result(cfg: &mut HostConfig, r: Value) -> Result { + let ok = r.get("ok").and_then(Value::as_bool).unwrap_or(false); + let token = r.get("token").and_then(Value::as_str).unwrap_or(""); + if ok && !token.is_empty() { + let email = r + .get("email") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let msg = r + .get("msg") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + cfg.account_token = token.to_string(); + cfg.account_email = email.clone(); + write_config(cfg)?; + Ok(Account { email, msg }) + } else { + let err = r + .get("error") + .and_then(Value::as_str) + .unwrap_or("sign-in failed") + .to_string(); + Err(CtlError::Server(err)) + } +} + +/// Sign in with an existing account and bind this PC to it. +pub fn account_login(cfg: &mut HostConfig, email: &str, password: &str) -> Result { + let email = email.trim().to_lowercase(); + if !email.contains('@') || password.is_empty() { + return Err(CtlError::Invalid("Enter your email and password.".into())); + } + let r = api_post( + cfg, + "/api/auth/login", + serde_json::json!({ "email": email, "password": password, "app": true }), + )?; + account_result(cfg, r) +} + +/// Create a new account (8+ char password) and bind this PC to it. +pub fn account_signup(cfg: &mut HostConfig, email: &str, password: &str) -> Result { + let email = email.trim().to_lowercase(); + if !email.contains('@') { + return Err(CtlError::Invalid("Enter a valid email.".into())); + } + if password.chars().count() < 8 { + return Err(CtlError::Invalid("Password needs 8+ characters.".into())); + } + let r = api_post( + cfg, + "/api/auth/signup", + serde_json::json!({ "email": email, "password": password, "app": true }), + )?; + account_result(cfg, r) +} + +/// Sign out: best-effort server logout, then clear the saved token + email. +pub fn account_logout(cfg: &mut HostConfig) -> Result<()> { + if !cfg.account_token.is_empty() { + let _ = api_post(cfg, "/api/auth/logout", serde_json::json!({})); + } + cfg.account_token.clear(); + cfg.account_email.clear(); + write_config(cfg) +} + +/// List this account's computers from `GET /api/devices`. +pub fn list_devices(cfg: &HostConfig) -> Result> { + if cfg.account_token.is_empty() { + return Err(CtlError::Invalid("not signed in".into())); + } + let url = format!("{}/api/devices", cfg.api_base()); + let resp = http_agent() + .get(&url) + .set("Authorization", &format!("Bearer {}", cfg.account_token)) + .call(); + match resp { + Ok(r) => { + let v: Value = r + .into_json() + .map_err(|e| CtlError::Server(format!("bad server response: {e}")))?; + let arr = v.get("devices").cloned().unwrap_or(Value::Array(vec![])); + Ok(serde_json::from_value::>(arr).unwrap_or_default()) + } + Err(ureq::Error::Status(code, r)) => Err(CtlError::Server(server_error(r, code))), + Err(ureq::Error::Transport(t)) => { + Err(CtlError::Http(format!("cannot reach server: {t}"))) + } + } +} + +/// Round-trip time (ms) to the relay's `/health`. Retries once so a single +/// dropped packet never flashes "offline" (mirrors `ping_relay`). +pub fn ping_relay(cfg: &HostConfig) -> Result { + let url = format!("{}/health", cfg.api_base()); + let mut last = String::from("ping failed"); + for _ in 0..2 { + let t0 = Instant::now(); + match http_agent().get(&url).call() { + Ok(resp) => { + let _ = resp.into_string(); + return Ok(t0.elapsed().as_millis() as u32); + } + Err(e) => last = e.to_string(), + } + } + Err(CtlError::Http(last)) +} + +// =========================================================================== +// Misc actions: copy id, open web viewer, open logs, state snapshot. +// =========================================================================== + +/// Copy this PC's 9-digit NetDesk id to the clipboard. +pub fn copy_id(cfg: &HostConfig) -> Result<()> { + let mut cb = + arboard::Clipboard::new().map_err(|e| CtlError::Os(format!("clipboard: {e}")))?; + cb.set_text(cfg.id.clone()) + .map_err(|e| CtlError::Os(format!("clipboard: {e}")))?; + Ok(()) +} + +/// Open the web viewer (`https://netdesk.space/app`) in the default browser. +pub fn open_web_viewer(cfg: &HostConfig) -> Result<()> { + open_url(&format!("{}/app", cfg.api_base())) +} + +/// Open the folder that holds the config + logs in the OS file manager. +pub fn open_logs_folder(cfg: &HostConfig) -> Result<()> { + let dir = cfg + .path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + #[cfg(windows)] + { + let mut cmd = std::process::Command::new("explorer"); + cmd.arg(&dir); + hidden(&mut cmd); + // explorer returns a non-zero exit code even on success; just launch it. + cmd.spawn() + .map_err(|e| CtlError::Os(format!("open logs: {e}")))?; + Ok(()) + } + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg(&dir) + .spawn() + .map_err(|e| CtlError::Os(format!("open logs: {e}")))?; + Ok(()) + } + #[cfg(all(unix, not(target_os = "macos")))] + { + std::process::Command::new("xdg-open") + .arg(&dir) + .spawn() + .map_err(|e| CtlError::Os(format!("open logs: {e}")))?; + Ok(()) + } +} + +/// A ready-to-render snapshot of this PC's identity + live state (mirrors the +/// Python `Api.get_state`). +pub fn snapshot(cfg: &HostConfig) -> HostState { + let es = engine_status(cfg); + HostState { + id: cfg.id.clone(), + name: cfg.name.clone(), + server: if cfg.server.trim().is_empty() { + DEFAULT_SERVER.to_string() + } else { + cfg.server.clone() + }, + account: cfg.account_email.clone(), + password: cfg.pw_plain.clone(), + state: es.state, + online: es.online, + in_session: es.in_session, + totp: !cfg.totp.trim().is_empty(), + privacy: cfg.privacy_on_session && cfg.allow_display_power, + lock_on_end: cfg.lock_on_end, + close_to_tray: cfg.close_to_tray, + service_installed: service_installed(), + startup: startup_present(), + config_path: cfg.path.display().to_string(), + } +} + +// =========================================================================== +// Internal helpers. +// =========================================================================== + +/// Tracks the PID of the engine this panel last spawned (Start), so the UI can +/// know it launched one. Stop kills by name+command-line regardless. +static LAST_ENGINE_PID: Mutex> = Mutex::new(None); + +// Short status caches so UI polling never spawns a subprocess every frame. +static SVC_CACHE: Mutex> = Mutex::new(None); +static STU_CACHE: Mutex> = Mutex::new(None); +static PROC_CACHE: Mutex> = Mutex::new(None); + +fn cache_get(cell: &Mutex>, max: Duration) -> Option { + if let Ok(g) = cell.lock() { + if let Some((v, t)) = *g { + if t.elapsed() < max { + return Some(v); + } + } + } + None +} +fn cache_set(cell: &Mutex>, v: bool) { + if let Ok(mut g) = cell.lock() { + *g = Some((v, Instant::now())); + } +} +fn cache_last(cell: &Mutex>) -> bool { + cell.lock().ok().and_then(|g| g.map(|(v, _)| v)).unwrap_or(false) +} + +fn now_secs() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) +} + +fn computer_name() -> String { + std::env::var("COMPUTERNAME") + .or_else(|_| std::env::var("HOSTNAME")) + .unwrap_or_else(|_| "host".to_string()) +} + +/// Locate the host engine executable: next to the current exe, then next to the +/// config (portable folder), else the bare name (resolved via PATH). +fn host_engine_exe(cfg: &HostConfig) -> PathBuf { + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let c = dir.join(ENGINE_EXE); + if c.is_file() { + return c; + } + } + } + if let Some(dir) = cfg.path.parent() { + let c = dir.join(ENGINE_EXE); + if c.is_file() { + return c; + } + } + PathBuf::from(ENGINE_EXE) +} + +fn dir_writable(dir: &Path) -> bool { + let probe = dir.join(".nd_write_test"); + match std::fs::write(&probe, b"") { + Ok(_) => { + let _ = std::fs::remove_file(&probe); + true + } + Err(_) => false, + } +} + +/// Pull a `{"error": "..."}` message out of an error response body. +fn server_error(resp: ureq::Response, code: u16) -> String { + match resp.into_json::() { + Ok(v) => v + .get("error") + .and_then(Value::as_str) + .map(String::from) + .unwrap_or_else(|| format!("server error {code}")), + Err(_) => format!("server error {code}"), + } +} + +// ---- Value coercion helpers for the tolerant config loader ---------------- + +fn str_of(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + _ => String::new(), + } +} +fn bool_of(v: &Value) -> bool { + match v { + Value::Bool(b) => *b, + Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false), + Value::String(s) => { + let s = s.to_lowercase(); + s == "1" || s == "true" || s == "yes" + } + _ => false, + } +} +fn u32_of(v: &Value, dflt: u32) -> u32 { + match v { + Value::Number(n) => n.as_u64().map(|x| x as u32).unwrap_or(dflt), + Value::String(s) => s.parse().unwrap_or(dflt), + _ => dflt, + } +} + +// ---- Windows process / shell helpers -------------------------------------- + +#[cfg(windows)] +fn hidden(cmd: &mut std::process::Command) -> &mut std::process::Command { + use std::os::windows::process::CommandExt; + cmd.creation_flags(CREATE_NO_WINDOW) +} +#[cfg(not(windows))] +fn hidden(cmd: &mut std::process::Command) -> &mut std::process::Command { + cmd +} + +/// Run a program hidden and capture `(exit_code, stdout)`. +fn run_capture(program: &str, args: &[&str]) -> Result<(i32, String)> { + let mut cmd = std::process::Command::new(program); + cmd.args(args); + hidden(&mut cmd); + let out = cmd + .output() + .map_err(|e| CtlError::Os(format!("{program}: {e}")))?; + let code = out.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + Ok((code, stdout)) +} + +#[cfg(windows)] +fn run_ps(ps: &str) -> Result { + let (_, out) = run_capture("powershell", &["-NoProfile", "-Command", ps])?; + Ok(out) +} + +/// Escape a value for embedding inside a PowerShell single-quoted string. +#[cfg(windows)] +fn ps_single_quote(s: &str) -> String { + s.replace('\'', "''") +} + +#[cfg(windows)] +fn programdata_dir() -> PathBuf { + PathBuf::from(std::env::var("ProgramData").unwrap_or_else(|_| r"C:\ProgramData".to_string())) + .join("NetDesk") +} + +#[cfg(windows)] +fn engine_process_running() -> bool { + if let Some(v) = cache_get(&PROC_CACHE, Duration::from_secs(5)) { + return v; + } + let ps = "if (Get-CimInstance Win32_Process -Filter \"Name='NetDeskHost.exe'\" | \ + Where-Object { $_.CommandLine -like '*--engine*' }) \ + { Write-Output 1 } else { Write-Output 0 }"; + let v = match run_capture("powershell", &["-NoProfile", "-Command", ps]) { + Ok((_, out)) => out.trim().ends_with('1'), + Err(_) => false, + }; + cache_set(&PROC_CACHE, v); + v +} +#[cfg(not(windows))] +fn engine_process_running() -> bool { + false +} + +/// Open a URL (or folder) in the OS default handler. +fn open_url(target: &str) -> Result<()> { + #[cfg(windows)] + { + let mut cmd = std::process::Command::new("cmd"); + cmd.args(["/C", "start", "", target]); + hidden(&mut cmd); + cmd.spawn().map_err(|e| CtlError::Os(format!("open: {e}")))?; + Ok(()) + } + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg(target) + .spawn() + .map_err(|e| CtlError::Os(format!("open: {e}")))?; + Ok(()) + } + #[cfg(all(unix, not(target_os = "macos")))] + { + std::process::Command::new("xdg-open") + .arg(target) + .spawn() + .map_err(|e| CtlError::Os(format!("open: {e}")))?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A managed policy overrides + locks the listed fields in memory, leaves + /// everything else alone, and reports exactly the locked keys. No policy + /// file => nothing locked. + #[test] + fn managed_policy_overrides_and_locks() { + // Unique temp policy path (no Date/rand in tests; use pid + a counter). + let dir = std::env::temp_dir(); + let path = dir.join(format!("nd_policy_test_{}.json", std::process::id())); + + // No file yet -> nothing locked, config untouched. + let mut cfg = HostConfig::default(); + cfg.server = "wss://user-choice/ws".to_string(); + let locked = apply_policy_from(&mut cfg, path.clone()); + assert!(locked.is_empty()); + assert_eq!(cfg.server, "wss://user-choice/ws"); + + // Write a policy that pins the server + disables file transfer + forces + // lock-on-end, and leaves the password/name alone. + std::fs::write( + &path, + r#"{"server":"wss://corp-relay/ws","lock_on_end":true, + "perms":{"files":false,"clipboard":false},"reg_key":"CORPKEY"}"#, + ) + .unwrap(); + let mut cfg = HostConfig::default(); + cfg.server = "wss://user-choice/ws".to_string(); + cfg.name = "my-pc".to_string(); + cfg.perms.input = true; + let locked = apply_policy_from(&mut cfg, path.clone()); + + assert_eq!(cfg.server, "wss://corp-relay/ws", "policy pins the server"); + assert!(cfg.lock_on_end, "policy forces lock-on-end"); + assert!(!cfg.perms.files, "policy disables file transfer"); + assert!(!cfg.perms.clipboard, "policy disables clipboard"); + assert!(cfg.perms.input, "unlisted perm untouched"); + assert_eq!(cfg.reg_key, "CORPKEY"); + assert_eq!(cfg.name, "my-pc", "unlisted field untouched"); + // The locked set is exactly the policy's top-level keys. + assert!(locked.contains("server") && locked.contains("lock_on_end") + && locked.contains("perms") && locked.contains("reg_key")); + assert!(!locked.contains("name")); + + let _ = std::fs::remove_file(&path); + } + + /// The relay HTTP base is derived from `server` like the Python `_api_base`. + #[test] + fn api_base_derivation() { + let base = |s: &str| { + let mut c = HostConfig::default(); + c.server = s.to_string(); + c.api_base() + }; + assert_eq!(base("wss://netdesk.space/ws"), "https://netdesk.space"); + assert_eq!(base("ws://127.0.0.1:8443/ws"), "http://127.0.0.1:8443"); + assert_eq!(base("https://netdesk.space"), "https://netdesk.space"); + assert_eq!(base(""), "https://netdesk.space"); + assert_eq!(base("wss://host:9/ws/"), "https://host:9"); + } + + /// Parsing then re-serialising the real config shape preserves every field, + /// including unknown ones — so the panel never corrupts the shared identity. + #[test] + fn config_round_trip_preserves_fields() { + let src = r#"{ + "id": "123456789", + "salt": "077dcb07e5435d1bce2b4285d401225e", + "iters": 200000, + "verifier": "bf664d73be94bd4d5288d7ee8ebcbbf20182cd326eebfefd2b637a908dfac514", + "server": "wss://netdesk.space/ws", + "name": "DEVPC", + "perms": { "input": true, "clipboard": true, "files": true, "audio": true }, + "lock_on_end": false, + "privacy_on_session": false, + "allow_display_power": false, + "server_locked_by_user": true, + "account_token": "TEST-DUMMY-ACCOUNT-TOKEN", + "account_email": "owner@example.com", + "allow_ips": "", + "pw_plain": "test-password-123", + "future_unknown_field": {"keep": [1, 2, 3]} +}"#; + let cfg = parse_config(src); + assert_eq!(cfg.id, "123456789"); + assert_eq!(cfg.iters, 200_000); + assert_eq!(cfg.name, "DEVPC"); + assert_eq!(cfg.account_email, "owner@example.com"); + assert_eq!(cfg.pw_plain, "test-password-123"); + assert!(cfg.server_locked_by_user); + assert!(cfg.perms.audio); + // Unknown key must survive in `extra`. + assert!(cfg.extra.contains_key("future_unknown_field")); + + // Re-serialise and re-parse: nothing lost. + let out = serde_json::to_string_pretty(&cfg).unwrap(); + let again = parse_config(&out); + assert_eq!(again.id, cfg.id); + assert_eq!(again.verifier, cfg.verifier); + assert_eq!(again.salt, cfg.salt); + assert_eq!(again.account_token, cfg.account_token); + assert_eq!(again.pw_plain, cfg.pw_plain); + assert!(again.extra.contains_key("future_unknown_field")); + } + + /// End-to-end proof that the native PBKDF2 verifier matches the real host + /// config byte-for-byte: PBKDF2-HMAC-SHA256(pw, salt, 200000, 32) over the + /// sample config's salt + `pw_plain` must equal its stored `verifier`. + #[test] + fn verifier_matches_real_config() { + // hex 077dcb07e5435d1bce2b4285d401225e + let salt: [u8; 16] = [ + 0x07, 0x7d, 0xcb, 0x07, 0xe5, 0x43, 0x5d, 0x1b, 0xce, 0x2b, 0x42, 0x85, 0xd4, 0x01, + 0x22, 0x5e, + ]; + let got = netdesk_core::crypto::verifier_hex("test-password-123", &salt, 200_000); + assert_eq!( + got, + "bf664d73be94bd4d5288d7ee8ebcbbf20182cd326eebfefd2b637a908dfac514" + ); + } + + /// A tolerant load must still recover identity when a field has an odd type + /// (here `iters` as a string) instead of discarding the whole config. + #[test] + fn lenient_parse_recovers_identity() { + let src = r#"{ "id": "900000001", "iters": "200000", "verifier": "ab", "server": "x" }"#; + let cfg = parse_config(src); + assert_eq!(cfg.id, "900000001"); + assert_eq!(cfg.iters, 200_000); + assert_eq!(cfg.verifier, "ab"); + } + + // ---- engine_status: status-file reading ------------------------------ + + /// Write a `gui_status.json` into a fresh temp dir and return a `HostConfig` + /// whose `path` sits in that dir (so the dir is candidate #1 of + /// `status_dirs`). `age`/`id`/`state` let each test shape the heartbeat. + fn cfg_with_status(tag: &str, id: &str, state: &str, age_secs: f64, in_session: bool) -> HostConfig { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let dir = std::env::temp_dir().join(format!("nd_ctl_{tag}_{nanos}")); + std::fs::create_dir_all(&dir).unwrap(); + let ts = now_secs() - age_secs; + let body = serde_json::json!({ + "state": state, "id": id, "name": "T", + "server": "wss://x/ws", "in_session": in_session, + "ts": ts, "pid": 1 + }); + std::fs::write(dir.join("gui_status.json"), body.to_string()).unwrap(); + let mut c = HostConfig::default(); + c.id = id.to_string(); + c.account_token = String::new(); // never touch the network in these tests + c.path = dir.join("config.json"); + c + } + + /// A fresh "online" heartbeat sitting next to the config is seen as online — + /// the core of the fix (the dir is resolved from the config, not hard-coded). + #[test] + fn freshest_status_reads_fresh_online() { + let cfg = cfg_with_status("fresh", "811000001", "online", 2.0, false); + let got = freshest_status(&cfg).expect("fresh heartbeat should be found"); + assert_eq!(got.1, "online"); + assert!(!got.2); + let _ = std::fs::remove_dir_all(cfg.path.parent().unwrap()); + } + + /// A heartbeat older than 12 s is ignored (engine gone / status stale). + #[test] + fn freshest_status_ignores_stale() { + let cfg = cfg_with_status("stale", "811000002", "online", 60.0, false); + assert!(freshest_status(&cfg).is_none(), "stale heartbeat must be ignored"); + let _ = std::fs::remove_dir_all(cfg.path.parent().unwrap()); + } + + /// A fresh heartbeat that names a DIFFERENT id is not this machine's status. + #[test] + fn freshest_status_ignores_other_id() { + let mut cfg = cfg_with_status("otherid", "999000003", "online", 2.0, false); + // Ask about a different id than the file carries. + cfg.id = "811000003".to_string(); + assert!(freshest_status(&cfg).is_none(), "another device's status must be ignored"); + let _ = std::fs::remove_dir_all(cfg.path.parent().unwrap()); + } + + /// A fresh "error: already connected elsewhere" heartbeat (stale-duplicate + /// noise) is treated as online when a local heartbeat is present. + #[test] + fn already_connected_maps_to_online() { + let cfg = cfg_with_status( + "aconn", + "811000004", + "error: already connected elsewhere", + 2.0, + false, + ); + let es = engine_status_local(&cfg); + assert!(es.online, "already-connected heartbeat should read online"); + assert_eq!(es.state, "online"); + let _ = std::fs::remove_dir_all(cfg.path.parent().unwrap()); + } + + /// A fresh "session" heartbeat reports online AND in_session. + #[test] + fn session_state_sets_in_session() { + let cfg = cfg_with_status("sess", "811000005", "session", 2.0, true); + let es = engine_status_local(&cfg); + assert!(es.online); + assert!(es.in_session); + let _ = std::fs::remove_dir_all(cfg.path.parent().unwrap()); + } + + // ---- engine_status: relay online flag (detection c) ------------------ + + /// The relay-online cache round-trips per id and respects the online flag, + /// so `engine_status` can honor `/api/devices` without a blocking call. + #[test] + fn server_online_cache_roundtrips() { + let id = "822000101"; + note_server_online(id, true); + assert!(server_says_online(id)); + // A devices list without this id online clears it. + let devs = vec![Device { id: id.to_string(), online: false, ..Default::default() }]; + note_server_devices(id, &devs); + assert!(!server_says_online(id)); + // And an online device in the list sets it. + let devs = vec![Device { id: id.to_string(), online: true, ..Default::default() }]; + note_server_devices(id, &devs); + assert!(server_says_online(id)); + // A different id is unaffected by this one's flag. + assert!(!server_says_online("822000102")); + } + + /// LIVE check (ignored by default). With the engine running and + /// `NETDESK_CONFIG` pointed at its config, `engine_status` must read online. + /// cargo test -p netdesk-viewer -- --ignored --nocapture live_engine_online + #[test] + #[ignore] + fn live_engine_online() { + let cfg = read_config().expect("config"); + let mut es = engine_status(&cfg); + // Give the background relay refresh one moment to land, in case the + // local heartbeat dir isn't visible to this process. + for _ in 0..4 { + if es.online { + break; + } + std::thread::sleep(Duration::from_millis(1500)); + es = engine_status(&cfg); + } + eprintln!( + "LIVE id={} online={} state={} in_session={} config={}", + cfg.id, es.online, es.state, es.in_session, cfg.path.display() + ); + assert!(es.online, "engine_status should be online; state={}", es.state); + } +} diff --git a/native/viewer/src/main.rs b/native/viewer/src/main.rs new file mode 100644 index 0000000..0214d29 --- /dev/null +++ b/native/viewer/src/main.rs @@ -0,0 +1,4653 @@ +//! NetDesk native viewer — the branded, full-feature in-session client. +//! +//! GPU-filtered remote-desktop viewer built on `eframe` + `egui` (winit + glow), +//! matching the NetDesk web viewer feature-for-feature at RustDesk/TeamViewer +//! quality. The decoded H.264 frame is uploaded to an egui texture with bilinear +//! (`TextureOptions::LINEAR`) filtering so scaling stays smooth AND clear; a real +//! egui menu-bar + toolbar + dockable panels sit over the image. +//! +//! Two launch modes: +//! * No args -> a branded dark CONNECT screen (logo + Partner ID + password). +//! * `netdesk-viewer [ws-url] [totp]` -> connect immediately. +//! +//! In-session parity with the web viewer (server/web/app.html + app.js): +//! Menu bar : Actions · View · Communicate · Extras · Help(About) +//! Toolbar : monitor / quality selectors, view-only, clipboard sync, chat, +//! power+processes, privacy, record, fullscreen, monitor cycle, +//! live stats, disconnect, collapse tab. +//! Panels : Chat, Remote power (power actions + run-command + processes + +//! lock-on-end), File transfer (remote browser: navigate, upload, +//! download, new folder, rename, delete, with live progress). +//! Dialogs : About, Session information, confirmations. +//! +//! Every control is wired to the `netdesk_core::Session` control protocol. +//! +//! Developed by Akronlogic (https://akronlogic.com). + +// Ship as a GUI app: in release builds no black console window pops up next to +// the window. Debug builds keep the console so logs are visible while developing. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use std::collections::VecDeque; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use eframe::egui; +use egui_phosphor::regular as ph; +use egui::{ + Align, Color32, Key, Layout, Margin, Modifiers, Pos2, Rect, RichText, Sense, + Rounding, Stroke, TextureOptions, Vec2, +}; + +use netdesk_core::decode::{best_decoder, best_decoder_for}; +use netdesk_core::signaling::{FileEntry, MonitorInfo, UPLOAD_CHUNK}; +use netdesk_core::{connect, ConnectConfig, Session, SessionEvent}; + +use tokio::sync::mpsc; + +/// HOST-CONTROL backend (native port of the Python agent's `Api`): reads/writes +/// the shared `config.json`, reports engine status, starts/stops the engine, +/// manages the unattended password / 2FA / switches, and signs in to the relay. +/// Wired here so it compiles into the crate; the stage-2 host panel UI calls it. +mod host_ctl; + +/// Remote audio playback (Opus decode + cpal output). Windows-only for now — +/// the vendored libopus in netdesk-opus targets the x86_64-pc-windows-gnu ABI. +#[cfg(windows)] +mod audio; + +/// HOME / HOST PANEL (stage-2 native UI): the enterprise management console the +/// app launches into. It controls the engine through `host_ctl` and, on a +/// partner connect, hands routing back to the viewer. +mod home; + +/// Mouse-button bitmask bits understood by the host. +const BTN_LEFT: u32 = 1; +const BTN_RIGHT: u32 = 2; +const BTN_MIDDLE: u32 = 4; + +/// The NetDesk logo (green mark, 256px). Embedded so the window icon and the +/// in-app logo work from a single-file portable exe (no assets dir at runtime). +const LOGO_PNG: &[u8] = include_bytes!("../../assets/icon.png"); + +// ----- NetDesk brand palette (dark premium theme) --------------------------- +const C_BG: Color32 = Color32::from_rgb(11, 15, 21); // #0B0F15 page background +const C_PANEL: Color32 = Color32::from_rgb(18, 24, 33); // card / window +const C_BAR: Color32 = Color32::from_rgb(15, 20, 28); // toolbar +const C_FIELD: Color32 = Color32::from_rgb(26, 33, 44); // input / inactive widget +const C_HOVER: Color32 = Color32::from_rgb(34, 43, 56); +const C_STROKE: Color32 = Color32::from_rgb(38, 47, 60); +// c_accent() / c_accent_hi() are now runtime: c_accent() / c_accent_hi() (brand.json). +const C_TEXT: Color32 = Color32::from_rgb(223, 231, 240); +const C_MUTED: Color32 = Color32::from_rgb(139, 150, 166); +const C_DANGER: Color32 = Color32::from_rgb(220, 60, 60); + +/// Quality presets — identical fps/bitrate(kbps)/scale to the web viewer QUALITY. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Quality { + Min, + Low, + Balanced, + High, + Extreme, +} +impl Quality { + const ALL: [Quality; 5] = [ + Quality::Min, + Quality::Low, + Quality::Balanced, + Quality::High, + Quality::Extreme, + ]; + fn params(self) -> (u32, u32, f32) { + match self { + Quality::Min => (8, 700, 1.0), + Quality::Low => (15, 2500, 1.0), + Quality::Balanced => (30, 6000, 1.0), + Quality::High => (30, 12000, 1.0), + Quality::Extreme => (60, 20000, 1.0), + } + } + fn label(self) -> &'static str { + match self { + Quality::Min => "Min · 8fps", + Quality::Low => "Low · 15fps · 2 Mbps", + Quality::Balanced => "Balanced · 30fps · 6 Mbps", + Quality::High => "High · 30fps · 12 Mbps", + Quality::Extreme => "Extreme · 60fps · 20 Mbps", + } + } + fn short(self) -> &'static str { + match self { + Quality::Min => "Min", + Quality::Low => "Low", + Quality::Balanced => "Balanced", + Quality::High => "High", + Quality::Extreme => "Extreme", + } + } +} + +/// Canvas scaling mode (client-only; mirrors the web viewer's Best-fit / 1:1). +#[derive(Clone, Copy, PartialEq, Eq)] +enum ScaleMode { + Fit, + Orig, +} + +/// A decoded frame, packed RGB8 (w*h*3 bytes), plus the instant its access unit +/// arrived (used for a best-effort pipeline-latency readout). +struct Frame { + w: u32, + h: u32, + rgb: Vec, + recv_at: Instant, +} + +/// Live stats + lifecycle, written by the net thread, read by the UI thread. +#[derive(Default, Clone)] +struct NetStats { + fps: f32, + mbps: f32, + res_w: u32, + res_h: u32, + e2e: bool, + host_name: String, + connected: bool, + status: String, + ended: Option, + /// Direct-P2P fast path active (media bypasses the relay over a WebRTC + /// DataChannel). Set from `SessionEvent::P2pState`; drives the path badge. + p2p: bool, +} + +/// One process row from a `sys.res` `kind:"processes"` reply. +#[derive(Clone)] +struct ProcInfo { + pid: u32, + name: String, + mem_kb: u64, +} + +/// A chat line in the transcript. +#[derive(Clone)] +struct ChatLine { + who: String, + text: String, + mine: bool, +} + +/// Parsed `session.info` (web viewer's `onInfo`). +#[derive(Default, Clone)] +struct SessionInfo { + w: u32, + h: u32, + mon: i32, + monitors: Vec, + fps: u32, + bitrate: u32, + host_name: String, + e2e: bool, + audio: bool, + lock_on_end: bool, + privacy_active: bool, + logon_screen: bool, + clipboard_allowed: bool, + files_allowed: bool, +} + +/// Control-protocol facts the net thread hands the UI thread each frame. +enum UiEvent { + Chat { from: String, text: String }, + Clipboard(String), + /// Remote cursor bitmap decoded to RGBA on the net thread; the UI uploads it + /// to a texture (shapes change rarely, so this is cheap). + CursorShape { rgba: Vec, w: u32, h: u32, hx: f32, hy: f32 }, + Info(SessionInfo), + Processes(Vec), + Modes { + items: Vec<(u32, u32)>, + current: Option<(u32, u32)>, + }, + /// privacy / block_input / wallpaper toggle echo. + Toggle { + kind: String, + on: bool, + ok: bool, + msg: String, + }, + /// run / kill / cad / setmode and any other generic result. + Generic { kind: String, ok: bool, msg: String }, + + // ---- file transfer (mirrors the web viewer's Files panel) ------------- + /// A remote directory listing arrived (host `file.roots.res` / `file.list.res`). + /// `path` is `""` for the drives/home listing. + FileList { path: String, entries: Vec }, + /// Bytes transferred so far for transfer `fid` (downloads: emitted by the + /// core per 0xf1 chunk; uploads: emitted by our upload worker per chunk). + FileProgress { fid: u32, done: u64 }, + /// A new transfer appeared and should be tracked in the panel. Emitted by + /// the upload worker (downloads add their row synchronously on click). + TransferStart { fid: u32, name: String, total: u64, up: bool }, + /// The upload worker has streamed every byte of `fid` to the host (the host + /// still confirms with `file.ok`). + UploadSent { fid: u32 }, + /// A download finished and was written to disk at `path`. + FileSaved { fid: u32, name: String, path: String, size: u64 }, + /// A transfer with a known `fid` failed (e.g. a local save error). + FileFail { fid: u32, msg: String }, + /// Host `file.ok` for an upload / mkdir / delete / rename. `refresh` ⇒ + /// re-list the current folder. + FileOk { msg: String, refresh: bool }, + /// Host `file.err` (no fid): shown on the panel's status line. + FileErr { msg: String }, +} + +/// Remote mouse-cursor position (frame-pixel coords), updated by the net thread +/// and read each frame by the UI to draw the overlay. +#[derive(Default, Clone, Copy)] +struct RemoteCursor { + x: f32, + y: f32, + visible: bool, +} + +/// Remote cursor bitmap uploaded to a GPU texture, plus its hotspot in bitmap px. +struct CursorTex { + tex: egui::TextureHandle, + hx: f32, + hy: f32, +} + +/// Shared between the net/decode thread and the egui UI thread. +struct Shared { + frame: Mutex>, + stats: Mutex, + inbox: Mutex>, + cursor: Mutex, + /// Active session recording: the net thread tees received frames into it and + /// (codec-aware) creates it on the first keyframe; the UI arms + finalizes it. + recorder: Mutex>, + /// Record armed (UI Rec on). The net thread creates the recorder on the next + /// keyframe for whatever codec is live (VP9 -> .webm, H.264 -> .mp4). + recording: AtomicBool, + /// Pending recording target set by the UI: (path stem, w, h, want_audio). + rec_pending: Mutex>, + /// "Play remote computer sound" toggle. The UI thread sets it; the net + /// thread reads it to decide whether to decode + play incoming Opus audio. + sound_on: AtomicBool, + /// Voice call: Some(stop) while the local-mic capture thread is running + /// (viewer mic -> Opus -> Session::voice -> host). Toggling off sets the + /// stop flag, which ends the thread. + voice_stop: Mutex>>, + /// Set by the UI when the user deliberately ends this session (Disconnect / + /// Home / starting a new session). The net thread checks it so a USER + /// disconnect never triggers the auto-reconnect loop — only an unexpected + /// drop does. + stop: AtomicBool, +} + +/// A live recording target: H.264 -> fragmented MP4, or VP9(+Opus) -> WebM. Both +/// muxers remux the received frames with NO re-encode. +enum SessionRec { + Mp4(netdesk_core::recorder::Mp4Recorder), + Webm(netdesk_core::webm::WebmRecorder), +} +impl SessionRec { + fn push_video(&mut self, data: &[u8], keyframe: bool, ts_ms: u64) { + let _ = match self { + SessionRec::Mp4(r) => r.push(data, keyframe, ts_ms), + SessionRec::Webm(r) => r.push_video(data, keyframe, ts_ms), + }; + } + fn push_audio(&mut self, data: &[u8], ts_ms: u64) { + if let SessionRec::Webm(r) = self { + let _ = r.push_audio(data, ts_ms); + } + } + fn finish(self) -> netdesk_core::Result<(PathBuf, u64)> { + match self { + SessionRec::Mp4(r) => r.finish(), + SessionRec::Webm(r) => r.finish(), + } + } +} + +impl Shared { + fn new() -> Arc { + Arc::new(Shared { + frame: Mutex::new(None), + stats: Mutex::new(NetStats::default()), + inbox: Mutex::new(Vec::new()), + cursor: Mutex::new(RemoteCursor::default()), + recorder: Mutex::new(None), + recording: AtomicBool::new(false), + rec_pending: Mutex::new(None), + sound_on: AtomicBool::new(true), + voice_stop: Mutex::new(None), + stop: AtomicBool::new(false), + }) + } + fn push(&self, ev: UiEvent) { + self.inbox.lock().unwrap().push(ev); + } +} + +/// A queued destructive action awaiting a Yes/Cancel confirmation. +#[derive(Clone)] +enum Confirm { + Reboot, + Shutdown, + Logoff, + Kill(u32, String), + SetMode(u32, u32), + /// Delete a remote file or folder (the web viewer's `confirm("Delete …")`). + DeleteFile { path: String, is_dir: bool, name: String }, +} + +/// Lifecycle of one file transfer shown in the Files panel. +#[derive(Clone)] +enum TransferState { + Active, + Done, + Failed(String), +} + +/// One in-flight or finished transfer, rendered with a progress bar. `total` +/// is the expected byte count (from the listing for a download, the local file +/// size for an upload); `done` is bytes transferred so far. +#[derive(Clone)] +struct Transfer { + fid: u32, + name: String, + total: u64, + done: u64, + up: bool, + state: TransferState, + detail: String, +} + +/// A small text-input modal for the Files panel (egui has no `prompt()`), used +/// for "New folder" and "Rename" — the web viewer's `prompt(...)` calls. +#[derive(Clone)] +enum FilePrompt { + Mkdir { name: String }, + Rename { src: String, name: String }, +} + +/// App-level route. The app launches in `Home` (the host panel); entering a +/// Partner ID + Connect moves to `Connecting` (the branded connect flow) and +/// then `Session` (the full viewer). A disconnect returns to `Home`. +#[derive(PartialEq, Clone, Copy)] +enum Route { + Home, + Connecting, + Session, +} + +/// Headless host/service mode: run the native Rust host engine in-process with +/// no window (unattended hosting, like RustDesk's service mode). Uses the +/// resolved config (honours `$NETDESK_CONFIG`). Runs until the process is +/// killed. This is the same in-process engine the GUI's Start Service uses. +fn run_headless_host() -> anyhow::Result<()> { + let _ = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).try_init(); + let cfg = host_ctl::read_config().map_err(|e| anyhow::anyhow!("read config: {e}"))?; + let server = if cfg.server.trim().is_empty() { + host_ctl::DEFAULT_SERVER + } else { + cfg.server.trim() + }; + println!( + "NetDesk host (headless) — id {} via {}", + cfg.id, server + ); + if std::env::var("NETDESK_HOST_DEBUG").is_ok() { + // Never print the password itself, even in debug — only its presence. + eprintln!( + "DEBUG config_path={} id={:?} pw_len={} verifier_present={}", + cfg.path.display(), + cfg.id, + cfg.pw_plain.len(), + !cfg.verifier.is_empty() + ); + } + host_ctl::start_service(&cfg).map_err(|e| anyhow::anyhow!("start host: {e}"))?; + loop { + std::thread::sleep(Duration::from_secs(2)); + #[cfg(windows)] + if let Some(s) = host_ctl::native_engine_status() { + println!( + "host: registered={} viewer_connected={} e2e={} encoder=\"{}\"{}", + s.registered, + s.viewer_connected, + s.e2e, + s.encoder, + s.last_error + .as_ref() + .map(|e| format!(" error={e}")) + .unwrap_or_default() + ); + } + } +} + +/// Enable "admin mode": register a per-logon, highest-privilege Scheduled Task +/// so the NetDesk host runs with a full Administrator token — `run` commands +/// then execute as Administrator with no per-command UAC prompt, and the host +/// still captures the real interactive desktop. Self-elevates via one UAC +/// prompt, then drops the plain per-user autostart so only one host launches. +fn install_admin_task() -> anyhow::Result<()> { + #[cfg(windows)] + { + let exe = std::env::current_exe()?; + let exe_s = exe.display().to_string(); + if !netdesk_host::sys::is_elevated() { + println!("requesting elevation — accept the Windows UAC prompt to enable admin mode…"); + let ps = format!( + "Start-Process -FilePath '{}' -ArgumentList '--install-admin-task' -Verb RunAs", + exe_s.replace('\'', "''") + ); + std::process::Command::new("powershell") + .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]) + .status()?; + return Ok(()); + } + let tr = format!("\"{exe_s}\" --host"); + let ok = std::process::Command::new("schtasks") + .args(["/create", "/tn", "NetDesk", "/tr", &tr, "/sc", "onlogon", "/rl", "highest", "/f"]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if ok { + // Only the elevated task should start the host; drop the plain + // HKCU autostart so two hosts never fight over the screen/id. + let _ = std::process::Command::new("reg") + .args(["delete", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "NetDesk", "/f"]) + .status(); + // Make UAC prompts approvable over a remote session, and allow a + // software-generated Ctrl+Alt+Del — the rest of "full admin". + let sys = "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System"; + let _ = std::process::Command::new("reg") + .args(["add", sys, "/v", "PromptOnSecureDesktop", "/t", "REG_DWORD", "/d", "0", "/f"]) + .status(); + let _ = std::process::Command::new("reg") + .args(["add", sys, "/v", "SoftwareSASGeneration", "/t", "REG_DWORD", "/d", "1", "/f"]) + .status(); + // Swap the running (standard) host for the elevated task NOW: stop + // every other NetDesk host (not this installer process), then start + // the task so capture resumes elevated without a sign-out. + let self_pid = std::process::id(); + let kill = format!( + "Get-Process NetDesk -ErrorAction SilentlyContinue | Where-Object {{ $_.Id -ne {self_pid} }} | Stop-Process -Force -ErrorAction SilentlyContinue", + ); + let _ = std::process::Command::new("powershell") + .args(["-NoProfile", "-Command", &kill]) + .status(); + let _ = std::process::Command::new("schtasks") + .args(["/run", "/tn", "NetDesk"]) + .status(); + println!("admin mode ENABLED — NetDesk now runs as Administrator:"); + println!(" • run CMD/PowerShell execute elevated (no per-command UAC)"); + println!(" • UAC prompts appear on the desktop, approvable over a remote session"); + println!(" • Ctrl+Alt+Del available; starts elevated at every logon"); + } else { + println!("admin task registration FAILED — run this elevated."); + } + } + #[cfg(not(windows))] + { + eprintln!("admin task is Windows-only"); + } + Ok(()) +} + +/// Disable admin mode: remove the elevated task and restore the plain autostart. +fn uninstall_admin_task() -> anyhow::Result<()> { + #[cfg(windows)] + { + let exe = std::env::current_exe()?; + let exe_s = exe.display().to_string(); + if !netdesk_host::sys::is_elevated() { + let ps = format!( + "Start-Process -FilePath '{}' -ArgumentList '--uninstall-admin-task' -Verb RunAs", + exe_s.replace('\'', "''") + ); + std::process::Command::new("powershell") + .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]) + .status()?; + return Ok(()); + } + let _ = std::process::Command::new("schtasks") + .args(["/delete", "/tn", "NetDesk", "/f"]) + .status(); + let run = format!("\"{exe_s}\" --host"); + let _ = std::process::Command::new("reg") + .args(["add", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "NetDesk", "/t", "REG_SZ", "/d", &run, "/f"]) + .status(); + println!("admin mode disabled; restored standard autostart."); + } + #[cfg(not(windows))] + { + eprintln!("admin task is Windows-only"); + } + Ok(()) +} + +/// Install UNATTENDED access: a LocalSystem service that captures the lock / +/// sign-in screen so the machine can be signed in and unlocked remotely with +/// nobody present. Self-elevates (one UAC), supersedes the user-session host + +/// admin task (the service owns the host), makes UAC remotely approvable, allows +/// software Ctrl+Alt+Del, and keeps the PC from sleeping on AC power. +/// Check the server's release manifest and, with `--apply`, download + verify + +/// launch the installer. Verified (sha256) before anything runs — RustDesk-style +/// safe self-update. Headless + bounded; reports clearly and never installs +/// unverified bytes. +fn check_update(apply: bool) -> anyhow::Result<()> { + use netdesk_core::update; + use std::io::Read as _; + let base = { + // Derive the https base from the configured server (fall back to default). + let cfg = host_ctl::read_config().unwrap_or_default(); + cfg.api_base() + }; + let manifest_url = format!("{base}/download/version.json"); + println!("[update] current v{}; checking {manifest_url}", netdesk_core::VERSION); + let body = ureq::get(&manifest_url) + .timeout(std::time::Duration::from_secs(15)) + .call() + .map_err(|e| anyhow::anyhow!("fetch manifest: {e}"))? + .into_string()?; + let m = update::parse_manifest(&body)?; + if !update::update_available(netdesk_core::VERSION, &m) { + println!("[update] up to date (latest v{}).", m.version); + return Ok(()); + } + println!("[update] NEW v{} available: {}", m.version, m.notes); + if !apply { + println!("[update] run with --apply to download + verify + install."); + return Ok(()); + } + println!("[update] downloading {} …", m.url); + let mut buf = Vec::new(); + ureq::get(&m.url) + .timeout(std::time::Duration::from_secs(600)) + .call() + .map_err(|e| anyhow::anyhow!("download: {e}"))? + .into_reader() + .read_to_end(&mut buf)?; + if !update::verify_sha256(&buf, &m.sha256) { + return Err(anyhow::anyhow!( + "sha256 mismatch — refusing to install ({} bytes)", + buf.len() + )); + } + println!("[update] sha256 verified ({} bytes).", buf.len()); + // Write to a temp file and launch the installer (NSIS). Silent /S. + let tmp = std::env::temp_dir().join("NetDesk-Setup-update.exe"); + std::fs::write(&tmp, &buf)?; + #[cfg(windows)] + { + match std::process::Command::new(&tmp).arg("/S").spawn() { + Ok(_) => println!("[update] launched installer ({}). It will update NetDesk.", tmp.display()), + Err(e) => println!("[update] saved to {} but could not launch ({e}); run it manually.", tmp.display()), + } + } + #[cfg(not(windows))] + println!("[update] saved verified artifact to {}", tmp.display()); + Ok(()) +} + +fn install_unattended() -> anyhow::Result<()> { + #[cfg(windows)] + { + let exe = std::env::current_exe()?; + let exe_s = exe.display().to_string(); + if !netdesk_host::sys::is_elevated() { + println!("requesting elevation — accept the Windows UAC prompt to install unattended access…"); + let ps = format!( + "Start-Process -FilePath '{}' -ArgumentList '--install-service' -Verb RunAs", + exe_s.replace('\'', "''") + ); + std::process::Command::new("powershell") + .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]) + .status()?; + return Ok(()); + } + // The service owns the host now: stop the standard host + its autostarts. + let self_pid = std::process::id(); + let kill = format!( + "Get-Process NetDesk -ErrorAction SilentlyContinue | Where-Object {{ $_.Id -ne {self_pid} }} | Stop-Process -Force -ErrorAction SilentlyContinue", + ); + let _ = std::process::Command::new("powershell").args(["-NoProfile", "-Command", &kill]).status(); + let _ = std::process::Command::new("reg") + .args(["delete", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "NetDesk", "/f"]).status(); + let _ = std::process::Command::new("schtasks").args(["/delete", "/tn", "NetDesk", "/f"]).status(); + // Make UAC remotely approvable + allow software Ctrl+Alt+Del. + let sys = "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System"; + let _ = std::process::Command::new("reg").args(["add", sys, "/v", "PromptOnSecureDesktop", "/t", "REG_DWORD", "/d", "0", "/f"]).status(); + let _ = std::process::Command::new("reg").args(["add", sys, "/v", "SoftwareSASGeneration", "/t", "REG_DWORD", "/d", "1", "/f"]).status(); + // Keep the PC reachable: never sleep/hibernate on AC. + let _ = std::process::Command::new("powercfg").args(["/change", "standby-timeout-ac", "0"]).status(); + let _ = std::process::Command::new("powercfg").args(["/change", "hibernate-timeout-ac", "0"]).status(); + match netdesk_host::service::install_service(&exe_s) { + Ok(()) => { + println!("UNATTENDED ACCESS installed — the NetDesk service runs as SYSTEM and starts at boot."); + println!("It captures the lock + sign-in screen, so you can sign in and unlock this PC remotely"); + println!("with nobody present. Test it: lock the screen, then reconnect from your viewer."); + } + Err(e) => println!("service install FAILED: {e}"), + } + } + #[cfg(not(windows))] + { + eprintln!("unattended service is Windows-only"); + } + Ok(()) +} + +/// Remove the unattended service and restore the standard user-session autostart. +fn uninstall_unattended() -> anyhow::Result<()> { + #[cfg(windows)] + { + let exe = std::env::current_exe()?; + let exe_s = exe.display().to_string(); + if !netdesk_host::sys::is_elevated() { + let ps = format!( + "Start-Process -FilePath '{}' -ArgumentList '--uninstall-service' -Verb RunAs", + exe_s.replace('\'', "''") + ); + std::process::Command::new("powershell") + .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]) + .status()?; + return Ok(()); + } + let _ = netdesk_host::service::uninstall_service(); + // Restore the plain per-user autostart. + let run = format!("\"{exe_s}\" --host"); + let _ = std::process::Command::new("reg") + .args(["add", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "NetDesk", "/t", "REG_SZ", "/d", &run, "/f"]).status(); + println!("unattended service removed; standard autostart restored."); + } + #[cfg(not(windows))] + { + eprintln!("unattended service is Windows-only"); + } + Ok(()) +} + +/// Value following `flag` in the argument list (`--server wss://…` -> the url). +fn flag_val(args: &[String], flag: &str) -> Option { + args.iter() + .position(|a| a == flag) + .and_then(|i| args.get(i + 1)) + .cloned() +} + +/// Unattended config injection: write `config.json` non-interactively from +/// command-line flags, for MSI / GPO / scripted enterprise deployment. An admin +/// bakes in the relay, registration key and unattended password without ever +/// opening the UI. `read_config_at` guarantees a valid id + verifier exist; each +/// flag overrides a specific field. `--password` is hashed (PBKDF2), never stored +/// in plaintext. Usage: +/// NetDesk.exe --config [--path ] [--server ] [--id ] +/// [--name ] [--reg-key ] [--allow-ips ] [--totp ] +/// [--password ] [--run] +#[cfg(windows)] +fn apply_cli_config(args: &[String]) -> anyhow::Result<()> { + let path = flag_val(args, "--path") + .map(std::path::PathBuf::from) + .unwrap_or_else(host_ctl::config_path); + let mut cfg = host_ctl::read_config_at(path.clone()) + .map_err(|e| anyhow::anyhow!("read config {}: {e}", path.display()))?; + let mut changed: Vec<&str> = Vec::new(); + if let Some(s) = flag_val(args, "--server") { + cfg.server = s; + cfg.server_locked_by_user = true; + changed.push("server"); + } + if let Some(id) = flag_val(args, "--id") { + cfg.id = id; + changed.push("id"); + } + if let Some(n) = flag_val(args, "--name") { + cfg.name = n; + changed.push("name"); + } + if let Some(k) = flag_val(args, "--reg-key") { + cfg.reg_key = k; + changed.push("reg_key"); + } + if let Some(ips) = flag_val(args, "--allow-ips") { + cfg.allow_ips = ips; + changed.push("allow_ips"); + } + if let Some(t) = flag_val(args, "--totp") { + cfg.totp = t; + changed.push("totp"); + } + if let Some(pw) = flag_val(args, "--password") { + host_ctl::set_unattended_password(&mut cfg, &pw) + .map_err(|e| anyhow::anyhow!("set password: {e}"))?; + changed.push("password"); + } + host_ctl::write_config(&cfg).map_err(|e| anyhow::anyhow!("write config: {e}"))?; + println!( + "NetDesk: wrote {} (id={}, server={}, set: [{}])", + cfg.path.display(), + cfg.id, + if cfg.server.is_empty() { + "" + } else { + cfg.server.as_str() + }, + changed.join(", ") + ); + Ok(()) +} + +/// The white-label self-config encoded in THIS executable's own filename +/// (`NetDesk-server=...,key=....exe`). Empty if the name carries nothing. +#[cfg(windows)] +fn exe_filename_config() -> netdesk_core::brand::FilenameConfig { + std::env::current_exe() + .ok() + .and_then(|p| { + p.file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + }) + .map(|stem| netdesk_core::brand::parse_filename_config(&stem)) + .unwrap_or_default() +} + +/// The optional `brand.json` manifest dropped next to the exe (full white-label: +/// name/vendor/accent/logo/server). Loaded + cached once. +#[cfg(windows)] +fn brand_manifest() -> Option { + use std::sync::OnceLock; + static M: OnceLock> = OnceLock::new(); + M.get_or_init(|| { + let exe = std::env::current_exe().ok()?; + let s = std::fs::read_to_string(exe.parent()?.join("brand.json")).ok()?; + Some(netdesk_core::brand::parse_brand_manifest(&s)) + }) + .clone() +} + +/// The brand logo bytes from the manifest's `logo` PNG (next to the exe), if set. +#[cfg(windows)] +fn brand_logo_bytes() -> Option> { + let m = brand_manifest()?; + if m.logo.is_empty() { + return None; + } + let exe = std::env::current_exe().ok()?; + std::fs::read(exe.parent()?.join(&m.logo)).ok() +} + +/// Apply the filename self-config to config.json (default location, or +/// `path_override` for testing): a reseller / MSP ships a pre-pointed client by +/// renaming the exe — no rebuild. The server is locked (the reseller's choice); +/// key/id/name fill in. Returns whether anything changed. Runs on every launch. +#[cfg(windows)] +fn apply_filename_selfconfig(path_override: Option) -> anyhow::Result { + let fc = exe_filename_config(); + if !fc.has_config() { + return Ok(false); + } + let path = path_override.unwrap_or_else(host_ctl::config_path); + let mut cfg = host_ctl::read_config_at(path.clone()) + .map_err(|e| anyhow::anyhow!("read config {}: {e}", path.display()))?; + let mut changed = false; + if let Some(s) = fc.server { + if cfg.server != s { + cfg.server = s; + cfg.server_locked_by_user = true; + changed = true; + } + } + if let Some(k) = fc.key { + if cfg.reg_key != k { + cfg.reg_key = k; + changed = true; + } + } + // id identifies this host — only set it when none exists yet (never clobber + // an established identity). The reseller-chosen display name overrides. + if let Some(id) = fc.id { + if cfg.id.is_empty() { + cfg.id = id; + changed = true; + } + } + if let Some(n) = fc.name { + if cfg.name != n { + cfg.name = n; + changed = true; + } + } + if changed { + host_ctl::write_config(&cfg).map_err(|e| anyhow::anyhow!("write config: {e}"))?; + } + Ok(changed) +} + +/// The resolved product name shown in the UI. A white-labeled client (renamed +/// `AcmeRemote-server=....exe`) shows its brand; otherwise "NetDesk". Set once +/// at startup; task/registry/app-id identities stay "NetDesk". +#[cfg(windows)] +static BRAND_NAME: std::sync::OnceLock = std::sync::OnceLock::new(); + +fn brand_name() -> String { + #[cfg(windows)] + { + BRAND_NAME + .get() + .cloned() + .unwrap_or_else(|| netdesk_core::BRAND.to_string()) + } + #[cfg(not(windows))] + { + netdesk_core::BRAND.to_string() + } +} + +/// Nudge a colour brighter (+) or darker (-) by `d` per channel (clamped). +fn shade(c: Color32, d: i16) -> Color32 { + let a = |v: u8| (v as i16 + d).clamp(0, 255) as u8; + Color32::from_rgb(a(c.r()), a(c.g()), a(c.b())) +} + +/// The brand accent — from a brand.json `accent`, else the default NetDesk +/// green. Runtime (read per use) so a white-labeled client recolours without a +/// rebuild. `_hi`/`_lo` are the brighter/darker variants (hover, gradient ends). +fn c_accent() -> Color32 { + #[cfg(windows)] + if let Some((r, g, b)) = brand_manifest().and_then(|m| m.accent_rgb()) { + return Color32::from_rgb(r, g, b); + } + Color32::from_rgb(34, 197, 94) // #22C55E +} +fn c_accent_hi() -> Color32 { + #[cfg(windows)] + if let Some(m) = brand_manifest() { + if let Some((r, g, b)) = m.accent_hi_rgb() { + return Color32::from_rgb(r, g, b); + } + if let Some((r, g, b)) = m.accent_rgb() { + return shade(Color32::from_rgb(r, g, b), 14); + } + } + Color32::from_rgb(45, 212, 110) +} +fn c_accent_lo() -> Color32 { + #[cfg(windows)] + if let Some((r, g, b)) = brand_manifest().and_then(|m| m.accent_rgb()) { + return shade(Color32::from_rgb(r, g, b), -14); + } + Color32::from_rgb(22, 167, 101) +} + +fn main() -> anyhow::Result<()> { + let args: Vec = std::env::args().collect(); + + // White-label: a renamed exe self-configures from its filename. The + // `--selfconfig ` hook applies it to a given file + exits (headless + // test); the normal launch applies it to config.json below. + #[cfg(windows)] + if args.iter().any(|a| a == "--selfconfig") { + let path = flag_val(&args, "--selfconfig").map(std::path::PathBuf::from); + let fc = exe_filename_config(); + let changed = apply_filename_selfconfig(path)?; + println!( + "selfconfig: brand={} server={} key={} changed={}", + fc.brand.as_deref().unwrap_or(""), + fc.server.as_deref().unwrap_or(""), + fc.key.as_deref().unwrap_or(""), + changed + ); + return Ok(()); + } + // Normal launch: point a renamed client at the reseller's relay (no-op when + // the exe isn't renamed with encoded settings). + #[cfg(windows)] + { + let _ = apply_filename_selfconfig(None); + // Product name: brand.json manifest > filename brand prefix > "NetDesk". + let _ = BRAND_NAME.set( + brand_manifest() + .map(|m| m.name) + .filter(|s| !s.is_empty()) + .or_else(|| exe_filename_config().brand) + .unwrap_or_else(|| netdesk_core::BRAND.to_string()), + ); + } + + // Admin-mode activation (register/remove the elevated host task). + if args.iter().any(|a| a == "--install-admin-task") { + return install_admin_task(); + } + if args.iter().any(|a| a == "--uninstall-admin-task") { + return uninstall_admin_task(); + } + + // Unattended SYSTEM service (lock/sign-in-screen capture for remote unlock). + #[cfg(windows)] + { + if args.iter().any(|a| a == "--service-run") { + return netdesk_host::service::run_service(); + } + if args.iter().any(|a| a == "--session-agent") { + // The per-session host the service spawns as SYSTEM: follow the input + // desktop so the secure (lock/sign-in) desktop is captured. + std::env::set_var("NETDESK_FOLLOW_DESKTOP", "1"); + return run_headless_host(); + } + } + if args.iter().any(|a| a == "--check-update") { + return check_update(args.iter().any(|a| a == "--apply")); + } + if args.iter().any(|a| a == "--install-service") { + return install_unattended(); + } + if args.iter().any(|a| a == "--uninstall-service") { + return uninstall_unattended(); + } + + // Unattended config injection (MSI / scripted deploy): write config.json + // non-interactively from flags, then optionally start the host (--run). + #[cfg(windows)] + if args.iter().any(|a| a == "--config") { + apply_cli_config(&args)?; + if args.iter().any(|a| a == "--run") { + return run_headless_host(); + } + return Ok(()); + } + + // Headless host/service mode (no GUI) — unattended hosting. + if args.iter().any(|a| a == "--host" || a == "--service") { + return run_headless_host(); + } + + // With args -> auto-connect (CLI compatible). Without -> show connect screen. + let auto: Option = if args.len() >= 3 { + Some(ConnectConfig { + host_id: args[1].clone(), + password: args[2].clone(), + ws_url: args + .get(3) + .cloned() + .unwrap_or_else(|| "wss://netdesk.space/ws".to_string()), + totp: args.get(4).cloned().unwrap_or_default(), + takeover: false, + }) + } else { + None + }; + + println!( + "NetDesk native viewer v{} (by {})", + netdesk_core::VERSION, + netdesk_core::VENDOR + ); + if let Some(cfg) = &auto { + println!("connecting to host {} via {} ...", cfg.host_id, cfg.ws_url); + } + + // Decode the embedded logo once for the window + taskbar + Alt-Tab icon. + let (icon_rgba, iw, ih) = decode_logo(); + // Standard, compact window like the previous desktop app (~1060x720). NOT + // maximized. `persist_window: false` below stops eframe from restoring a + // large/maximized size saved from an earlier run. + // NETDESK_WIN="WxH" (e.g. 900x600) overrides the initial inner size — used + // by the responsive-layout test matrix; harmless when unset. + let mut win_w = 1060.0f32; + let mut win_h = 720.0f32; + if let Ok(v) = std::env::var("NETDESK_WIN") { + if let Some((w, h)) = v.trim().to_ascii_lowercase().split_once('x') { + if let (Ok(w), Ok(h)) = (w.trim().parse::(), h.trim().parse::()) { + if (320.0..=7680.0).contains(&w) && (240.0..=4320.0).contains(&h) { + win_w = w; + win_h = h; + } + } + } + } + let mut viewport = egui::ViewportBuilder::default() + .with_title(brand_name()) + .with_app_id("com.akronlogic.netdesk") + .with_inner_size([win_w, win_h]) + .with_min_inner_size([win_w.min(900.0), win_h.min(600.0)]) + .with_maximized(false) + .with_resizable(true); + if iw > 0 { + viewport = viewport.with_icon(egui::IconData { + rgba: icon_rgba, + width: iw, + height: ih, + }); + } + + let native_options = eframe::NativeOptions { + viewport, + vsync: true, + persist_window: false, // always open at the standard size, don't restore a huge one + ..Default::default() + }; + + eframe::run_native( + "NetDesk", + native_options, + Box::new(move |cc| Ok(Box::new(NetDeskApp::new(cc, auto)))), + ) + .map_err(|e| anyhow::anyhow!("eframe: {e}"))?; + + Ok(()) +} + +/// Decode `LOGO_PNG` -> (rgba8, w, h). Returns an empty vec on failure. +fn decode_logo() -> (Vec, u32, u32) { + // White-label: a brand.json "logo" PNG next to the exe overrides the mark. + #[cfg(windows)] + if let Some(bytes) = brand_logo_bytes() { + if let Ok(img) = image::load_from_memory(&bytes) { + let rgba = img.to_rgba8(); + let (w, h) = rgba.dimensions(); + return (rgba.into_raw(), w, h); + } + } + match image::load_from_memory(LOGO_PNG) { + Ok(img) => { + let rgba = img.to_rgba8(); + let (w, h) = rgba.dimensions(); + (rgba.into_raw(), w, h) + } + Err(_) => (Vec::new(), 0, 0), + } +} + +struct NetDeskApp { + shared: Arc, + session: Arc>>, + route: Route, + /// The host panel (Home). Owns config.json, engine control, account, etc. + home: home::HomeState, + /// True once the net thread has been spawned for the current connect attempt + /// (distinguishes the connect FORM from the live "connecting…" progress). + connecting_active: bool, + theme_set: bool, + /// First-frame window sizing done (original-app formula). Pre-set to true + /// when the NETDESK_WIN test override is in use so tests keep their size. + sized: bool, + + // logo + logo_rgba: Vec, + logo_dim: (u32, u32), + logo_tex: Option, + + // connect form + form_id: String, + form_pw: String, + form_server: String, + form_totp: String, + form_view_only: bool, + /// Last view-only value pushed to the host this connection (None = not yet + /// sent / disconnected); the per-frame sync re-sends on change + reconnect. + sent_view_only: Option, + show_pw: bool, + show_advanced: bool, + connect_error: Option, + + // video texture + texture: Option, + tex_size: (u32, u32), + /// Shadow copy of the most recent decoded frame (w, h, rgb) kept for the + /// "Save screenshot" action, since the live frame slot is usually already + /// drained into the GPU texture by the time the user clicks. + last_frame_rgb: Option<(u32, u32, Vec)>, + /// Remote cursor bitmap texture (None = draw the arrow fallback overlay). + cursor_shape: Option, + + // session UI state + inited: bool, + view_only: bool, + /// Relative / game mouse (pointer-lock-style): send raw frame deltas + + /// hide the local cursor, for FPS games / 3D apps that want raw motion. + game_mouse: bool, + /// Translate / international keyboard: send typed characters as Unicode + /// (Event::Text) so AltGr / dead-key / non-US layouts type correctly. + translate_kbd: bool, + /// Collaborative annotation: draw ink on the host's screen overlay. Strokes + /// are kept in REMOTE pixel coords (resize-safe) and rendered over the frame. + annotate: bool, + annot_drawing: bool, + annot_cur: Vec<(i32, i32)>, + annot_strokes: Vec>, + quality: Quality, + // Custom image-quality (overrides the preset when active). The `scale` lever + // (host-side resolution downscale) is otherwise unused by the presets. + qc_custom: bool, + qc_fps: u32, + qc_mbps: f32, + qc_scale: f32, + fullscreen: bool, + monitors: Vec, + cur_mon: i32, + scale_mode: ScaleMode, + + // toggles / live info + info: Option, + privacy_on: bool, + block_input_on: bool, + wallpaper_hidden: bool, + lock_on_end: bool, + sound_on: bool, + clip_sync: bool, + + // resolution modes + modes: Vec<(u32, u32)>, + cur_mode: Option<(u32, u32)>, + modes_loaded: bool, + + // processes + processes: Vec, + + // chat + chat_log: Vec, + chat_input: String, + chat_unread: u32, + + // run-command box + run_cmd: String, + + // recording stub state + recording: bool, + + // panels / dialogs + show_chat: bool, + show_sys: bool, + show_files: bool, + show_about: bool, + show_info: bool, + pending_confirm: Option, + + // file transfer (remote browser + active transfers) + file_path: String, + file_entries: Vec, + file_status: String, + file_status_err: bool, + file_loading: bool, + file_opened: bool, + transfers: Vec, + file_prompt: Option, + file_prompt_focus: bool, + + // toolbar + bar_last_active: Instant, + bar_collapsed: bool, + + // transient banner (text, expiry, ok?) + banner: Option<(String, Instant, bool)>, + + // clipboard sync bookkeeping + last_clip_poll: Instant, + last_local_clip: String, + last_remote_clip: String, + + // input tracking + last_sent: Option<(i32, i32, u32)>, + prev_mods: Modifiers, + lat_ms: f32, + + // session timer + session_start: Option, +} + +impl NetDeskApp { + fn new(cc: &eframe::CreationContext<'_>, auto: Option) -> Self { + let (logo_rgba, lw, lh) = decode_logo(); + let shared = Shared::new(); + let session: Arc>> = Arc::new(Mutex::new(None)); + + // System-key grab: install the WH_KEYBOARD_LL hook once on the UI thread + // (which pumps messages). It stays inert until a session is focused. + #[cfg(windows)] + let _ = netdesk_host::keyhook::install(); + + let home = home::HomeState::new(&cc.egui_ctx); + + let auto_id = auto.as_ref().map(|c| c.host_id.clone()).unwrap_or_default(); + let (route, connecting_active) = if let Some(cfg) = auto { + spawn_net_thread(cfg, shared.clone(), session.clone(), cc.egui_ctx.clone()); + (Route::Connecting, true) + } else { + (Route::Home, false) + }; + + Self { + shared, + session, + route, + home, + connecting_active, + theme_set: false, + sized: std::env::var("NETDESK_WIN").is_ok(), + logo_rgba, + logo_dim: (lw, lh), + logo_tex: None, + form_id: auto_id, + form_pw: String::new(), + form_server: "wss://netdesk.space/ws".to_string(), + form_totp: String::new(), + form_view_only: false, + sent_view_only: None, + show_pw: false, + show_advanced: false, + connect_error: None, + texture: None, + tex_size: (0, 0), + last_frame_rgb: None, + cursor_shape: None, + inited: false, + view_only: false, + game_mouse: false, + translate_kbd: false, + annotate: false, + annot_drawing: false, + annot_cur: Vec::new(), + annot_strokes: Vec::new(), + quality: Quality::High, + qc_custom: false, + qc_fps: 30, + qc_mbps: 8.0, + qc_scale: 1.0, + fullscreen: false, + monitors: Vec::new(), + cur_mon: 0, + scale_mode: ScaleMode::Fit, + info: None, + privacy_on: false, + block_input_on: false, + wallpaper_hidden: false, + lock_on_end: false, + sound_on: true, + clip_sync: true, + modes: Vec::new(), + cur_mode: None, + modes_loaded: false, + processes: Vec::new(), + chat_log: Vec::new(), + chat_input: String::new(), + chat_unread: 0, + run_cmd: String::new(), + recording: false, + show_chat: false, + show_sys: false, + show_files: false, + show_about: false, + show_info: false, + pending_confirm: None, + file_path: String::new(), + file_entries: Vec::new(), + file_status: String::new(), + file_status_err: false, + file_loading: false, + file_opened: false, + transfers: Vec::new(), + file_prompt: None, + file_prompt_focus: false, + bar_last_active: Instant::now(), + bar_collapsed: false, + banner: None, + last_clip_poll: Instant::now(), + last_local_clip: String::new(), + last_remote_clip: String::new(), + last_sent: None, + prev_mods: Modifiers::default(), + lat_ms: 0.0, + session_start: None, + } + } + + /// Run `f` against the live session, if any. + fn with_session(&self, f: F) { + if let Some(s) = self.session.lock().unwrap().as_ref() { + f(s); + } + } + + /// Toggle the two-way voice call: start/stop capturing THIS PC's microphone + /// and streaming it to the host (Opus over CHAN_VOICE). Reuses the host + /// crate's WASAPI capture engine with source=microphone. + fn toggle_voice(&mut self) { + let was_on = { + let mut g = self.shared.voice_stop.lock().unwrap(); + if let Some(stop) = g.take() { + stop.store(true, std::sync::atomic::Ordering::Relaxed); + true + } else { + false + } + }; + if was_on { + self.banner("Voice call ended", true, 2); + return; + } + #[cfg(windows)] + { + let stop = Arc::new(AtomicBool::new(false)); + *self.shared.voice_stop.lock().unwrap() = Some(stop.clone()); + let sess = self.session.clone(); + std::thread::spawn(move || { + let src = Arc::new(AtomicU8::new(2)); // 2 = microphone + netdesk_host::audio::run_audio(stop, || true, src, move |opus| { + if let Some(s) = sess.lock().unwrap().as_ref() { + s.voice(&opus); + } + }); + }); + self.banner("Voice call started — speak into your mic", true, 2); + } + #[cfg(not(windows))] + { + self.banner("Voice call needs Windows (microphone capture)", true, 2); + } + } + + fn apply_quality(&mut self, q: Quality) { + self.qc_custom = false; + self.quality = q; + let (fps, br, sc) = q.params(); + self.with_session(|s| { + s.set_quality(fps, br, sc); + s.request_keyframe(); + }); + } + + /// Apply the custom FPS / bitrate / scale sliders. `scale` downscales the + /// host's capture before encoding (sharp text at 1.0, much less bandwidth at + /// 0.5) — a lever the fixed presets never expose. + fn apply_custom_quality(&mut self) { + self.qc_custom = true; + let fps = self.qc_fps.clamp(1, 60); + let br = ((self.qc_mbps * 1000.0) as u32).clamp(200, 40000); + let sc = self.qc_scale.clamp(0.2, 1.0); + self.with_session(|s| { + s.set_quality(fps, br, sc); + s.request_keyframe(); + }); + self.banner( + format!("Custom: {fps} fps · {:.1} Mbps · scale {:.2}", self.qc_mbps, sc), + true, + 2, + ); + } + + /// Where a new recording is written: the user's Videos folder (created if + /// needed), falling back to the temp dir. + fn recording_path(&self, w: u16, h: u16) -> PathBuf { + let base = std::env::var_os("USERPROFILE") + .map(|p| PathBuf::from(p).join("Videos")) + .unwrap_or_else(std::env::temp_dir); + let _ = std::fs::create_dir_all(&base); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + base.join(format!("NetDesk-{ts}-{w}x{h}")) // extension added per-codec (.mp4/.webm) + } + + /// Start teeing the live H.264 into a .mp4 (no re-encode). The net thread + /// feeds the recorder each access unit; a keyframe is requested so the file + /// gets its SPS/PPS and a clean start promptly. + fn start_recording(&mut self) { + let (w, h) = { + let s = self.shared.stats.lock().unwrap(); + (s.res_w as u16, s.res_h as u16) + }; + if w < 2 || h < 2 { + self.banner("Connect to a host first, then record", false, 2); + return; + } + let stem = self.recording_path(w, h); + let want_audio = self.shared.sound_on.load(std::sync::atomic::Ordering::Relaxed); + *self.shared.rec_pending.lock().unwrap() = Some((stem, w, h, want_audio)); + self.shared.recording.store(true, std::sync::atomic::Ordering::Relaxed); + self.recording = true; + // No forced codec: the net thread records whatever codec is live (VP9 -> + // .webm with an Opus track, H.264 -> .mp4) and pins THAT for the duration + // so the container codec stays consistent. A keyframe starts a clean file. + self.with_session(|s| s.request_keyframe()); + self.banner("● Recording…", true, 3); + } + + /// Finalize the recording and report where it was saved. + fn stop_recording(&mut self) { + self.recording = false; + self.shared.recording.store(false, std::sync::atomic::Ordering::Relaxed); + self.shared.rec_pending.lock().unwrap().take(); + // Release the record codec pin so the host resumes adaptive selection. + self.with_session(|s| s.pin_codec(-1)); + let rec = self.shared.recorder.lock().unwrap().take(); + if let Some(rec) = rec { + match rec.finish() { + Ok((p, n)) => self.banner(format!("Saved {} ({n} frames)", p.display()), true, 5), + Err(e) => self.banner(format!("Recording failed: {e}"), false, 4), + } + } + } + + fn banner(&mut self, text: impl Into, ok: bool, secs: u64) { + self.banner = Some((text.into(), Instant::now() + Duration::from_secs(secs), ok)); + } + + fn logo_texture(&mut self, ctx: &egui::Context) -> Option { + if self.logo_tex.is_none() && self.logo_dim.0 > 0 { + let (w, h) = self.logo_dim; + let img = + egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &self.logo_rgba); + self.logo_tex = Some(ctx.load_texture("nd-logo", img, TextureOptions::LINEAR)); + } + self.logo_tex.clone() + } + + /// Start a connection from the connect-screen form. + fn start_from_form(&mut self, ctx: &egui::Context) { + let id: String = self.form_id.chars().filter(|c| c.is_ascii_digit()).collect(); + if id.len() < 6 { + self.connect_error = Some("Enter the 9-digit Partner ID.".into()); + return; + } + if self.form_pw.is_empty() { + self.connect_error = Some("Enter the password.".into()); + return; + } + let ws = if self.form_server.trim().is_empty() { + "wss://netdesk.space/ws".to_string() + } else { + self.form_server.trim().to_string() + }; + let cfg = ConnectConfig { + host_id: id, + password: std::mem::take(&mut self.form_pw), + ws_url: ws, + totp: self.form_totp.trim().to_string(), + takeover: false, + }; + self.view_only = self.form_view_only; + self.start_session(cfg, ctx); + } + + /// Start a session from a fully-built config: fresh `Shared` (so a prior + /// attempt's net thread can wind down without touching this one), reset the + /// session UI, spawn the net thread, and route to `Connecting`. + fn start_session(&mut self, cfg: ConnectConfig, ctx: &egui::Context) { + // Tell any previous net thread to stop (don't let it auto-reconnect the + // old host) before we swap in fresh shared state. + self.shared.stop.store(true, Ordering::Relaxed); + let shared = Shared::new(); + self.shared = shared.clone(); + *self.session.lock().unwrap() = None; + self.inited = false; + self.texture = None; + self.tex_size = (0, 0); + self.monitors.clear(); + self.cur_mon = 0; + self.modes.clear(); + self.modes_loaded = false; + self.connect_error = None; + spawn_net_thread(cfg, shared, self.session.clone(), ctx.clone()); + self.connecting_active = true; + self.route = Route::Connecting; + } + + /// Tear the current session down and return to the Home host panel. + fn go_home(&mut self) { + // Finalize any in-progress recording before the shared state is replaced + // (dropping the writer without write_end() would truncate the MP4). + if self.recording { + self.stop_recording(); + } + // Stop the net thread's reconnect loop — this is a deliberate exit. + self.shared.stop.store(true, Ordering::Relaxed); + self.with_session(|s| s.close()); + *self.session.lock().unwrap() = None; + self.shared = Shared::new(); + self.inited = false; + self.texture = None; + self.tex_size = (0, 0); + self.monitors.clear(); + self.cur_mon = 0; + self.processes.clear(); + self.chat_log.clear(); + self.chat_unread = 0; + self.show_chat = false; + self.show_sys = false; + self.show_files = false; + self.show_info = false; + self.show_about = false; + self.file_opened = false; + self.file_entries.clear(); + self.transfers.clear(); + self.file_prompt = None; + self.session_start = None; + self.banner = None; + self.connecting_active = false; + self.connect_error = None; + self.form_pw.clear(); + self.route = Route::Home; + self.home.refresh(); + } + + fn push_local_clipboard(&mut self) { + match arboard::Clipboard::new().and_then(|mut c| c.get_text()) { + Ok(text) if !text.is_empty() => { + self.last_remote_clip = text.clone(); + self.last_local_clip = text.clone(); + self.with_session(|s| s.clipboard(&text)); + self.banner("Clipboard sent to remote", true, 2); + } + _ => self.banner("Local clipboard is empty", false, 2), + } + } + + fn set_local_clipboard(&mut self, text: &str) { + if let Ok(mut c) = arboard::Clipboard::new() { + let _ = c.set_text(text.to_string()); + } + } + + // ---- file transfer ---------------------------------------------------- + /// Open the Files panel. The first time it is opened on a live session we + /// request the drives/home listing (the web viewer's `send("file.roots")`). + fn open_files(&mut self) { + self.show_files = true; + if !self.file_opened && self.session.lock().unwrap().is_some() { + self.file_opened = true; + self.file_loading = true; + self.file_status.clear(); + self.file_status_err = false; + self.with_session(|s| s.list_roots()); + } + } + + /// Navigate to a remote directory (`file.list`). An empty path asks the host + /// for the drives/home listing. + fn list_dir_ui(&mut self, path: String) { + self.file_loading = true; + self.file_status.clear(); + self.file_status_err = false; + self.with_session(|s| s.list_dir(&path)); + } + + /// Re-list the folder currently shown (used after mkdir/delete/rename/upload + /// when the host asks for a refresh). + fn refresh_files(&mut self) { + let p = self.file_path.clone(); + self.with_session(|s| s.list_dir(&p)); + } + + /// Start a download of a remote file and register a transfer row. The core + /// assembles the bytes and the net thread writes them to the Downloads + /// folder, reporting progress via `FileProgress` / `FileSaved`. + fn download_entry(&mut self, full: String, name: String, size: u64) { + let fid = { + let g = self.session.lock().unwrap(); + g.as_ref().map(|s| s.download(&full)) + }; + match fid { + Some(fid) => { + self.transfers.push(Transfer { + fid, + name: name.clone(), + total: size, + done: 0, + up: false, + state: TransferState::Active, + detail: String::new(), + }); + self.file_status = format!("Downloading {name}…"); + self.file_status_err = false; + } + None => { + self.file_status = "No active session".into(); + self.file_status_err = true; + } + } + } + + /// Pick one or more local files with the native dialog and upload each to + /// the folder currently shown (the web viewer's "Upload…" button). An empty + /// current path makes the host drop the files on the remote Desktop. + fn pick_and_upload(&mut self, ctx: &egui::Context) { + let picked = rfd::FileDialog::new() + .set_title("Upload to remote") + .pick_files(); + if let Some(files) = picked { + let remote_dir = self.file_path.clone(); + for f in files { + self.start_upload(f, remote_dir.clone(), ctx.clone()); + } + } + } + + /// Spawn a background thread that streams one local file to the host in + /// `UPLOAD_CHUNK` pieces, reporting progress through the shared inbox. + fn start_upload(&self, local: PathBuf, remote_dir: String, ctx: egui::Context) { + let session = self.session.clone(); + let shared = self.shared.clone(); + std::thread::spawn(move || upload_worker(session, shared, ctx, local, remote_dir)); + } + + /// TeamViewer-style drag & drop: files dropped onto the window (while + /// connected) upload to the remote folder currently shown in the File + /// panel, or the remote Desktop when no folder is open. A full-window hint + /// shows while files are being dragged over us. + fn handle_file_drops(&mut self, ctx: &egui::Context) { + // Dragging-over hint overlay. + if ctx.input(|i| !i.raw.hovered_files.is_empty()) { + let screen = ctx.screen_rect(); + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Foreground, + egui::Id::new("nd_drop_hint"), + )); + painter.rect_filled(screen, 0.0, Color32::from_rgba_unmultiplied(8, 14, 20, 170)); + painter.rect_stroke( + screen.shrink(10.0), + Rounding::same(12.0), + Stroke::new(2.0_f32, c_accent()), + ); + painter.text( + screen.center(), + egui::Align2::CENTER_CENTER, + "Drop files to send to the remote PC", + egui::FontId::proportional(22.0), + c_accent(), + ); + } + // Actual drops. + let dropped = ctx.input(|i| i.raw.dropped_files.clone()); + if dropped.is_empty() { + return; + } + let remote_dir = self.file_path.clone(); + let mut n = 0; + for f in dropped { + if let Some(path) = f.path { + if path.is_file() { + self.start_upload(path, remote_dir.clone(), ctx.clone()); + n += 1; + } + } + } + if n > 0 { + self.show_files = true; // reveal the transfer progress + let dest = if remote_dir.trim().is_empty() { + "remote Desktop".to_string() + } else { + remote_dir + }; + self.banner(format!("Uploading {n} file(s) → {dest}"), true, 3); + } + } +} + +impl eframe::App for NetDeskApp { + fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] { + [0.043, 0.059, 0.082, 1.0] // C_BG + } + + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + if !self.theme_set { + apply_theme(ctx); + self.theme_set = true; + } + if !self.sized { + // Size like the original desktop app: a fraction of the monitor, + // clamped to the same bounds, centered; slightly denser UI scale so + // the panel reads as compact as the previous one. + if let Some(mon) = ctx.input(|i| i.viewport().monitor_size) { + let w = (mon.x * 0.62).clamp(880.0, 1060.0).min(mon.x - 40.0); + let h = (mon.y * 0.75).clamp(600.0, 760.0).min(mon.y - 60.0); + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h))); + ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(egui::pos2( + ((mon.x - w) / 2.0).max(0.0), + ((mon.y - h) / 2.4).max(0.0), + ))); + } + ctx.set_zoom_factor(0.90); + self.sized = true; + } + + // NOTE: there is deliberately NO per-frame auto-fit zoom here. The old + // block re-derived the zoom from `inner_rect * zoom_factor()` every + // frame, but `zoom_factor()` updates one frame before `inner_rect` + // does, so the feedback loop never converged and the window oscillated + // between zoom levels (visible as the whole UI resizing every frame). + // The Home panel is instead laid out to fit ~1030x700 logical points + // with a fixed zoom (see home.rs LAYOUT METRICS); smaller windows fall + // back to the page ScrollArea. + + // System-key grab: while a session window is focused, forward the keys + // Windows normally eats (Win / PrtScn / Alt+Tab / Alt+Esc / Ctrl+Esc) to + // the host and swallow them locally, so they act on the REMOTE machine. + #[cfg(windows)] + { + let active = matches!(self.route, Route::Session) && ctx.input(|i| i.focused); + netdesk_host::keyhook::set_active(active); + let grabbed = netdesk_host::keyhook::poll(); + if active { + for (code, down) in grabbed { + self.with_session(|s| s.key(code, down)); + } + } + } + + match self.route { + Route::Home => self.update_home(ctx), + Route::Connecting => self.update_connecting(ctx), + Route::Session => self.update_session(ctx), + } + + // keep the UI lively (stats / auto-hide / clipboard poll / banner expiry) + ctx.request_repaint_after(Duration::from_millis(200)); + } +} + +// ============================ HOME / HOST PANEL ============================ +impl NetDeskApp { + fn update_home(&mut self, ctx: &egui::Context) { + let logo = self.logo_texture(ctx).map(|t| t.id()); + match self.home.ui(ctx, logo) { + home::HomeAction::Connect { id } => { + // fold the branded connect flow in: seed the form from the + // partner ID + the host's own relay, then route to Connecting. + self.form_id = id; + self.form_pw.clear(); + self.form_totp.clear(); + self.form_view_only = false; + self.form_server = self.home.server(); + self.connect_error = None; + self.connecting_active = false; + self.show_advanced = false; + self.route = Route::Connecting; + } + home::HomeAction::None => {} + } + } +} + +// ============================ CONNECTING FLOW ============================== +impl NetDeskApp { + /// The branded outbound-connect flow (reached only from Home). Shows a + /// credential card for the partner ID; once the user hits Connect it spawns + /// the net thread and shows live "Connecting…" progress, advancing to the + /// Session viewer when the stream is up or back to the form on failure. + fn update_connecting(&mut self, ctx: &egui::Context) { + if self.connecting_active { + let stats = self.shared.stats.lock().unwrap().clone(); + if stats.connected { + self.route = Route::Session; + self.update_session(ctx); + return; + } + if let Some(reason) = stats.ended.clone() { + self.connect_error = Some(reason); + self.connecting_active = false; + } + } + if self.connecting_active { + self.update_connecting_progress(ctx); + } else { + self.update_connect(ctx); + } + } + + /// Centered "Connecting to …" progress screen with a Cancel button. + fn update_connecting_progress(&mut self, ctx: &egui::Context) { + let logo = self.logo_texture(ctx); + let status = self.shared.stats.lock().unwrap().status.clone(); + let id_disp = { + let d: String = self.form_id.chars().filter(|c| c.is_ascii_digit()).collect(); + if d.len() == 9 { + format!("{} {} {}", &d[0..3], &d[3..6], &d[6..9]) + } else { + d + } + }; + egui::CentralPanel::default() + .frame(egui::Frame::none().fill(C_BG)) + .show(ctx, |ui| { + let avail = ui.available_size(); + ui.add_space((avail.y * 0.26).min(180.0)); + ui.vertical_centered(|ui| { + if let Some(tex) = &logo { + ui.add(egui::Image::new(egui::load::SizedTexture::new( + tex.id(), + egui::vec2(72.0, 72.0), + ))); + } + ui.add_space(14.0); + ui.add(egui::Spinner::new().size(26.0).color(c_accent())); + ui.add_space(12.0); + ui.label( + RichText::new(format!("Connecting to {id_disp}…")) + .size(20.0) + .strong() + .color(C_TEXT), + ); + ui.add_space(4.0); + ui.label( + RichText::new(if status.is_empty() { + "Establishing an encrypted tunnel to the relay".to_string() + } else { + status + }) + .size(13.0) + .color(C_MUTED), + ); + ui.add_space(18.0); + let cancel = egui::Button::new( + RichText::new("Cancel").color(C_TEXT).strong(), + ) + .fill(C_FIELD) + .min_size(egui::vec2(140.0, 38.0)) + .rounding(Rounding::same(9.0)); + if ui.add(cancel).clicked() { + self.go_home(); + } + }); + }); + ctx.request_repaint_after(Duration::from_millis(120)); + } +} + +// ============================ CONNECT SCREEN ================================ +impl NetDeskApp { + fn update_connect(&mut self, ctx: &egui::Context) { + let logo = self.logo_texture(ctx); + let enter = ctx.input(|i| i.key_pressed(Key::Enter)); + + let mut back = false; + egui::CentralPanel::default() + .frame(egui::Frame::none().fill(C_BG)) + .show(ctx, |ui| { + let avail = ui.available_size(); + // top-left: back to the host panel + ui.horizontal(|ui| { + ui.add_space(4.0); + // A clearly-bordered button (not faint text) so it reads as + // the obvious way back to the host panel. + if ui + .add( + egui::Button::new( + RichText::new(format!("{} Back to Home", ph::ARROW_LEFT)) + .size(13.5) + .color(C_TEXT), + ) + .fill(C_FIELD) + .stroke(Stroke::new(1.0_f32, C_STROKE)) + .rounding(8.0) + .min_size(egui::vec2(132.0, 32.0)), + ) + .on_hover_text("Return to the host panel") + .clicked() + { + back = true; + } + }); + ui.add_space((avail.y * 0.08).min(64.0)); + ui.vertical_centered(|ui| { + // logo mark + if let Some(tex) = &logo { + ui.add(egui::Image::new(egui::load::SizedTexture::new( + tex.id(), + egui::vec2(84.0, 84.0), + ))); + } + ui.add_space(10.0); + ui.label(RichText::new(brand_name()).size(38.0).strong().color(C_TEXT)); + ui.label(RichText::new("Developed by Akronlogic.com").size(15.0).color(c_accent())); + ui.add_space(4.0); + ui.label( + RichText::new("Connect out to a partner — fast, self-hosted, end-to-end encrypted") + .size(13.0) + .color(C_MUTED), + ); + ui.add_space(22.0); + + // the card + egui::Frame::none() + .fill(C_PANEL) + .stroke(Stroke::new(1.0_f32, C_STROKE)) + .rounding(Rounding::same(14.0)) + .inner_margin(Margin::symmetric(26.0, 24.0)) + .show(ui, |ui| { + // fluid card: 380px, but never wider than the window + ui.set_width(380.0_f32.min(ui.available_width() - 8.0).max(240.0)); + ui.spacing_mut().item_spacing.y = 8.0; + + ui.label(RichText::new("Connect to a computer").size(17.0).strong().color(C_TEXT)); + ui.label(RichText::new("Enter the ID shown by the NetDesk agent.").size(12.0).color(C_MUTED)); + ui.add_space(8.0); + + ui.label(RichText::new("Partner ID").size(12.0).color(C_MUTED)); + let id_resp = ui.add( + egui::TextEdit::singleline(&mut self.form_id) + .hint_text("123 456 789") + .desired_width(f32::INFINITY) + .font(egui::TextStyle::Heading), + ); + + ui.add_space(4.0); + ui.label(RichText::new("Password").size(12.0).color(C_MUTED)); + ui.horizontal(|ui| { + let pw = egui::TextEdit::singleline(&mut self.form_pw) + .hint_text("permanent password") + .password(!self.show_pw) + .desired_width(ui.available_width() - 46.0); + ui.add(pw); + let eye = if self.show_pw { "🙈" } else { "👁" }; + if ui.button(eye).on_hover_text("Show / hide password").clicked() { + self.show_pw = !self.show_pw; + } + }); + + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.checkbox(&mut self.form_view_only, "View only (no input)"); + }); + + // advanced: server + 2FA + let adv = self.show_advanced; + if ui + .selectable_label(adv, RichText::new("Advanced — server & 2FA").size(12.0).color(C_MUTED)) + .clicked() + { + self.show_advanced = !self.show_advanced; + } + if self.show_advanced { + ui.label(RichText::new("Server").size(12.0).color(C_MUTED)); + ui.add( + egui::TextEdit::singleline(&mut self.form_server) + .hint_text("wss://netdesk.space/ws · or ws://PC-IP:8901 (LAN)") + .desired_width(f32::INFINITY), + ); + ui.label(RichText::new("2FA code (if the host requires it)").size(12.0).color(C_MUTED)); + ui.add( + egui::TextEdit::singleline(&mut self.form_totp) + .hint_text("6-digit code") + .desired_width(f32::INFINITY), + ); + } + + ui.add_space(12.0); + let btn = egui::Button::new(RichText::new("Connect").strong().size(15.0).color(Color32::WHITE)) + .fill(c_accent()) + .min_size(egui::vec2(ui.available_width(), 42.0)) + .rounding(Rounding::same(10.0)); + let clicked = ui.add(btn).clicked(); + + if let Some(err) = &self.connect_error { + ui.add_space(6.0); + ui.label(RichText::new(err).color(C_DANGER).size(12.0)); + } + + // Enter anywhere in the form (except a multiline) connects. + let enter_in_field = id_resp.lost_focus() && enter; + if clicked || enter_in_field || (enter && !self.form_id.is_empty() && !self.form_pw.is_empty()) { + self.start_from_form(ui.ctx()); + } + }); + + ui.add_space(18.0); + ui.label( + RichText::new(format!( + "NetDesk viewer v{} · {}", + netdesk_core::VERSION, + netdesk_core::VENDOR + )) + .size(11.0) + .color(C_MUTED), + ); + }); + }); + if back { + self.go_home(); + } + } +} + +// ============================ SESSION SCREEN ================================ +impl NetDeskApp { + fn update_session(&mut self, ctx: &egui::Context) { + // ---- 1. pull the newest decoded frame and upload it to the GPU ------- + if let Some(fr) = self.shared.frame.lock().unwrap().take() { + let sample = fr.recv_at.elapsed().as_secs_f32() * 1000.0; + self.lat_ms = if self.lat_ms == 0.0 { + sample + } else { + self.lat_ms * 0.85 + sample * 0.15 + }; + let size = [fr.w as usize, fr.h as usize]; + let img = egui::ColorImage::from_rgb(size, &fr.rgb); + match self.texture { + Some(ref mut t) if self.tex_size == (fr.w, fr.h) => { + t.set(img, TextureOptions::LINEAR); + } + _ => { + self.texture = Some(ctx.load_texture("remote", img, TextureOptions::LINEAR)); + self.tex_size = (fr.w, fr.h); + } + } + // keep the pixels for "Save screenshot" (ColorImage copied above, so + // this is a free move of the original Vec, not an extra allocation). + self.last_frame_rgb = Some((fr.w, fr.h, fr.rgb)); + } + + // ---- 2. read stats snapshot + drain control-protocol inbox ---------- + let stats = self.shared.stats.lock().unwrap().clone(); + let events = std::mem::take(&mut *self.shared.inbox.lock().unwrap()); + for ev in events { + self.handle_ui_event(ctx, ev); + } + + // ---- 3. one-time setup once the session is live --------------------- + if stats.connected && !self.inited { + self.inited = true; + self.session_start = Some(Instant::now()); + let title = if stats.host_name.is_empty() { + "NetDesk".to_string() + } else { + format!("NetDesk — {}", stats.host_name) + }; + ctx.send_viewport_cmd(egui::ViewportCommand::Title(title)); + if let Some(s) = self.session.lock().unwrap().as_ref() { + if self.monitors.is_empty() { + self.monitors = s.monitors().to_vec(); + } + self.cur_mon = s.current_monitor(); + } + self.apply_quality(self.quality); + } + + // ---- 4. clipboard sync poll (local -> remote) ----------------------- + if stats.connected + && self.clip_sync + && self.last_clip_poll.elapsed() > Duration::from_millis(900) + { + self.last_clip_poll = Instant::now(); + if let Ok(text) = arboard::Clipboard::new().and_then(|mut c| c.get_text()) { + if !text.is_empty() && text != self.last_local_clip && text != self.last_remote_clip + { + self.last_local_clip = text.clone(); + self.with_session(|s| s.clipboard(&text)); + } + } + } + + // ---- 5. toolbar (menu bar + buttons), auto-hide like TeamViewer ----- + let pointer = ctx.input(|i| i.pointer.latest_pos()); + let near_top = pointer.map_or(false, |p| p.y < 96.0); + let pointer_moved = ctx.input(|i| i.pointer.delta() != Vec2::ZERO); + let focused = ctx.input(|i| i.focused); + if near_top || pointer_moved || focused || !stats.connected { + self.bar_last_active = Instant::now(); + } + let show_bar = !self.bar_collapsed + && (self.bar_last_active.elapsed() < Duration::from_secs(4) || !stats.connected); + + if show_bar { + self.draw_toolbar(ctx, &stats); + } else { + // collapsed / hidden: a tiny restore tab, top-center + egui::Area::new(egui::Id::new("tbShow")) + .anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 0.0)) + .show(ctx, |ui| { + egui::Frame::none() + .fill(C_BAR) + .rounding(Rounding { + nw: 0.0, + ne: 0.0, + sw: 8.0, + se: 8.0, + }) + .inner_margin(Margin::symmetric(12.0, 4.0)) + .show(ui, |ui| { + if ui + .add(egui::Button::new(RichText::new("▼ NetDesk").color(C_MUTED)).frame(false)) + .clicked() + { + self.bar_collapsed = false; + self.bar_last_active = Instant::now(); + } + }); + }); + } + + // ---- 6. central video area (letterboxed or 1:1, GPU-scaled) --------- + let mut img_rect: Option = None; + let mut go_home_now = false; + egui::CentralPanel::default() + .frame(egui::Frame::none().fill(Color32::BLACK)) + .show(ctx, |ui| { + let avail = ui.available_rect_before_wrap(); + ui.painter().rect_filled(avail, 0.0, Color32::BLACK); + + if let Some(tex) = &self.texture { + let (iw, ih) = (self.tex_size.0 as f32, self.tex_size.1 as f32); + if iw > 0.0 && ih > 0.0 { + let uv = Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)); + match self.scale_mode { + ScaleMode::Fit => { + let rect = fit_rect(avail, iw, ih); + ui.painter().image(tex.id(), rect, uv, Color32::WHITE); + img_rect = Some(rect); + } + ScaleMode::Orig => { + egui::ScrollArea::both() + .drag_to_scroll(false) + .show(ui, |ui| { + let (rect, _r) = ui.allocate_exact_size( + egui::vec2(iw, ih), + Sense::hover(), + ); + ui.painter().image(tex.id(), rect, uv, Color32::WHITE); + img_rect = Some(rect); + }); + } + } + } + } else { + let msg = if let Some(r) = &stats.ended { + format!("Session ended: {r}") + } else if stats.connected { + "Waiting for first frame…".to_string() + } else if !stats.status.is_empty() { + format!("{}…", stats.status) + } else { + "Connecting…".to_string() + }; + ui.painter().text( + avail.center(), + egui::Align2::CENTER_CENTER, + msg, + egui::FontId::proportional(18.0), + C_MUTED, + ); + if stats.ended.is_some() { + let br = Rect::from_center_size( + avail.center() + Vec2::new(0.0, 44.0), + Vec2::new(180.0, 40.0), + ); + let b = egui::Button::new( + RichText::new(format!("{} Return to Home", ph::ARROW_LEFT)).color(Color32::WHITE).strong(), + ) + .fill(c_accent()) + .rounding(Rounding::same(9.0)); + if ui.put(br, b).clicked() { + go_home_now = true; + } + } + } + + // ---- remote mouse cursor overlay (RustDesk-style) ----------- + // The host streams the cursor position + bitmap separately from + // the video, so we draw it ourselves over the letterboxed frame. + if let Some(rect) = img_rect { + let cur = self.shared.cursor.lock().map(|c| *c).unwrap_or_default(); + if cur.visible && self.tex_size.0 > 0 { + let (iw, ih) = (self.tex_size.0 as f32, self.tex_size.1 as f32); + let px = rect.left() + (cur.x / iw) * rect.width(); + let py = rect.top() + (cur.y / ih) * rect.height(); + let tip = egui::pos2(px, py); + if rect.contains(tip) { + match &self.cursor_shape { + Some(cs) => { + let sz = cs.tex.size_vec2(); + let tl = egui::pos2(px - cs.hx, py - cs.hy); + ui.painter().image( + cs.tex.id(), + Rect::from_min_size(tl, sz), + Rect::from_min_max( + egui::pos2(0.0, 0.0), + egui::pos2(1.0, 1.0), + ), + Color32::WHITE, + ); + } + None => draw_arrow_cursor(ui.painter(), tip), + } + } + } + // Annotation ink: strokes are in remote pixels; map to view. + let (iw, ih) = (self.tex_size.0 as f32, self.tex_size.1 as f32); + if iw > 0.0 && ih > 0.0 && (self.annotate || !self.annot_strokes.is_empty()) { + let map = |pt: &(i32, i32)| { + egui::pos2( + rect.left() + (pt.0 as f32 / iw) * rect.width(), + rect.top() + (pt.1 as f32 / ih) * rect.height(), + ) + }; + let stroke = egui::Stroke::new(3.0, Color32::from_rgb(0xFF, 0x33, 0x33)); + for st in self.annot_strokes.iter().chain(std::iter::once(&self.annot_cur)) { + if st.len() >= 2 { + let vpts: Vec = st.iter().map(map).collect(); + ui.painter().add(egui::Shape::line(vpts, stroke)); + } else if st.len() == 1 { + ui.painter().circle_filled(map(&st[0]), 2.0, stroke.color); + } + } + } + } + }); + if go_home_now { + self.go_home(); + } + + // ---- 7. banner overlay ---------------------------------------------- + if let Some((text, until, ok)) = self.banner.clone() { + if Instant::now() < until { + egui::Area::new(egui::Id::new("banner")) + .anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 104.0)) + .show(ctx, |ui| { + let bg = if ok { C_PANEL } else { Color32::from_rgb(60, 24, 24) }; + egui::Frame::none() + .fill(bg) + .stroke(Stroke::new(1.0_f32, if ok { c_accent() } else { C_DANGER })) + .rounding(Rounding::same(8.0)) + .inner_margin(Margin::symmetric(14.0, 8.0)) + .show(ui, |ui| { + ui.label(RichText::new(text).color(C_TEXT)); + }); + }); + } else { + self.banner = None; + } + } + + // ---- 8. floating panels + dialogs ----------------------------------- + self.draw_panels(ctx); + + // ---- 9. input forwarding -------------------------------------------- + // Keep the HOST-ENFORCED view-only flag in sync with the local toggle: + // sent once per change (and re-sent after a reconnect, when sent_view_only + // resets), so the engine drops input even from a modified client. + if stats.connected { + if self.sent_view_only != Some(self.view_only) { + let on = self.view_only; + self.with_session(|s| s.view_only(on)); + self.sent_view_only = Some(on); + } + } else { + self.sent_view_only = None; + } + // Keyboard/pointer are forwarded only when egui itself does not want them + // (so typing in chat / the run-command box, dragging a panel, or using a + // menu never leaks to the remote). Gating happens inside forward_input. + if stats.connected && !self.view_only { + self.forward_input(ctx, img_rect, stats.res_w, stats.res_h); + } else { + self.prev_mods = ctx.input(|i| i.modifiers); + } + + // ---- 10. drag & drop file upload (TeamViewer-style) ----------------- + if stats.connected { + self.handle_file_drops(ctx); + } + } + + fn handle_ui_event(&mut self, ctx: &egui::Context, ev: UiEvent) { + match ev { + UiEvent::CursorShape { rgba, w, h, hx, hy } => { + let img = + egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &rgba); + let tex = ctx.load_texture("remote-cursor", img, TextureOptions::NEAREST); + self.cursor_shape = Some(CursorTex { tex, hx, hy }); + } + UiEvent::Chat { from, text } => { + self.chat_log.push(ChatLine { who: from, text, mine: false }); + if !self.show_chat { + self.chat_unread += 1; + self.banner("New chat message", true, 3); + } + } + UiEvent::Clipboard(text) => { + if !text.is_empty() && text != self.last_remote_clip { + self.last_remote_clip = text.clone(); + self.last_local_clip = text.clone(); + self.set_local_clipboard(&text); + } + } + UiEvent::Info(info) => { + self.privacy_on = info.privacy_active; + self.lock_on_end = info.lock_on_end; + if !info.monitors.is_empty() { + self.monitors = info.monitors.clone(); + } + if info.mon != 0 || self.cur_mon == 0 { + self.cur_mon = info.mon; + } + if info.logon_screen { + self.banner("Host is at the Windows logon screen — you can type credentials.", true, 4); + } + self.info = Some(info); + } + UiEvent::Processes(list) => self.processes = list, + UiEvent::Modes { items, current } => { + self.modes = items; + self.cur_mode = current; + self.modes_loaded = true; + } + UiEvent::Toggle { kind, on, ok, msg } => { + match kind.as_str() { + "privacy" => self.privacy_on = on, + "block_input" => self.block_input_on = on, + "wallpaper" => self.wallpaper_hidden = on, + _ => {} + } + if !msg.is_empty() { + self.banner(msg, ok, 3); + } + } + UiEvent::Generic { kind, ok, msg } => { + if kind == "setmode" && ok { + self.modes_loaded = false; + self.with_session(|s| s.refresh_screen()); + } + if !msg.is_empty() { + self.banner( + if ok { msg } else { format!("Failed: {msg}") }, + ok, + 3, + ); + } + } + + // ---- file transfer ---- + UiEvent::FileList { path, entries } => { + self.file_path = path; + self.file_entries = entries; + self.file_loading = false; + } + UiEvent::FileProgress { fid, done } => { + if let Some(t) = self.transfers.iter_mut().find(|t| t.fid == fid) { + t.done = done; + if t.total < done { + t.total = done; + } + } + } + UiEvent::TransferStart { fid, name, total, up } => { + self.transfers.push(Transfer { + fid, + name, + total, + done: 0, + up, + state: TransferState::Active, + detail: String::new(), + }); + } + UiEvent::UploadSent { fid } => { + if let Some(t) = self.transfers.iter_mut().find(|t| t.fid == fid) { + t.done = t.total; + t.state = TransferState::Done; + } + } + UiEvent::FileSaved { fid, name, path, size } => { + if let Some(t) = self.transfers.iter_mut().find(|t| t.fid == fid) { + t.done = size; + if t.total < size { + t.total = size; + } + t.state = TransferState::Done; + t.detail = path.clone(); + } + self.file_status = format!("Saved {name}"); + self.file_status_err = false; + self.banner(format!("Saved to {path}"), true, 5); + } + UiEvent::FileFail { fid, msg } => { + if let Some(t) = self.transfers.iter_mut().find(|t| t.fid == fid) { + t.state = TransferState::Failed(msg.clone()); + } + self.file_status = msg; + self.file_status_err = true; + } + UiEvent::FileOk { msg, refresh } => { + self.file_status = if msg.is_empty() { "OK".into() } else { msg }; + self.file_status_err = false; + if refresh { + self.refresh_files(); + } + } + UiEvent::FileErr { msg } => { + self.file_status = format!("Error: {msg}"); + self.file_status_err = true; + self.banner(format!("File error: {msg}"), false, 4); + } + } + } +} + +// ----------------------------- toolbar -------------------------------------- +impl NetDeskApp { + fn draw_toolbar(&mut self, ctx: &egui::Context, stats: &NetStats) { + let logo = self.logo_texture(ctx); + egui::TopBottomPanel::top("toolbar") + .frame( + egui::Frame::none() + .fill(C_BAR) + .stroke(Stroke::new(1.0_f32, C_STROKE)) + .inner_margin(Margin::symmetric(8.0, 5.0)), + ) + .show(ctx, |ui| { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + + // --- brand --- + if let Some(tex) = &logo { + ui.add(egui::Image::new(egui::load::SizedTexture::new( + tex.id(), + egui::vec2(20.0, 20.0), + ))); + } + ui.label(RichText::new(brand_name()).strong().color(C_TEXT)); + ui.separator(); + + // ===================== MENU BAR ===================== + self.menu_actions(ui); + self.menu_view(ui); + self.menu_communicate(ui); + self.menu_extras(ui); + self.menu_help(ui); + ui.separator(); + + // ===================== SELECTORS ===================== + // monitor + if !self.monitors.is_empty() { + let cur = self + .monitors + .iter() + .find(|m| m.i == self.cur_mon) + .map(|m| if m.i < 0 { + format!("🖥 All displays ({}×{})", m.w, m.h) + } else { + format!("🖥 {} ({}×{})", m.i, m.w, m.h) + }) + .unwrap_or_else(|| "🖥 Display".to_string()); + let mut switch_to = None; + egui::ComboBox::from_id_salt("mon") + .selected_text(cur) + .show_ui(ui, |ui| { + for m in &self.monitors { + let txt = if m.i < 0 { + format!("All displays ({}×{})", m.w, m.h) + } else { + format!("Display {} ({}×{})", m.i, m.w, m.h) + }; + if ui.selectable_label(self.cur_mon == m.i, txt).clicked() { + switch_to = Some(m.i); + } + } + }); + if let Some(i) = switch_to { + self.switch_monitor(i); + } + } + + // quality (presets + a Custom fps/bitrate/scale section) + let mut new_q = None; + let mut apply_custom = false; + let sel_txt = if self.qc_custom { + "⚙ Custom".to_string() + } else { + format!("⚙ {}", self.quality.short()) + }; + egui::ComboBox::from_id_salt("quality") + .selected_text(sel_txt) + .show_ui(ui, |ui| { + for q in Quality::ALL { + if ui.selectable_label(!self.qc_custom && self.quality == q, q.label()).clicked() { + new_q = Some(q); + } + } + ui.separator(); + ui.label(RichText::new("Custom").size(11.0).color(C_MUTED)); + ui.add(egui::Slider::new(&mut self.qc_fps, 1..=60).text("fps")); + ui.add( + egui::Slider::new(&mut self.qc_mbps, 0.3..=30.0) + .logarithmic(true) + .text("Mbps"), + ); + ui.add( + egui::Slider::new(&mut self.qc_scale, 0.2..=1.0) + .text("scale") + .custom_formatter(|v, _| format!("{:.0}%", v * 100.0)), + ); + if ui.button("Apply custom").clicked() { + apply_custom = true; + } + }); + if let Some(q) = new_q { + self.apply_quality(q); + self.banner(format!("Quality: {}", q.short()), true, 2); + } + if apply_custom { + self.apply_custom_quality(); + } + ui.separator(); + + // ===================== ICON BUTTONS ===================== + // view-only + if self.tb_toggle(ui, "👁", "Input", !self.view_only, "Toggle remote input (view-only)").clicked() { + self.view_only = !self.view_only; + // (The per-frame sync in update() pushes this to the host, + // which enforces it engine-side — defense in depth.) + if self.view_only { + // release any buttons currently held on the host + if let Some((x, y, _)) = self.last_sent { + self.with_session(|s| s.mouse(x, y, 0, 0.0, 0.0)); + } + self.last_sent = None; + } + self.banner( + if self.view_only { "View-only: input disabled" } else { "Input enabled" }, + true, + 2, + ); + } + + // clipboard sync (push local -> remote, and toggle auto-sync via right not available; left pushes) + if self.tb_toggle(ui, "📋", "Clip", self.clip_sync, "Clipboard sync — click to push local clipboard to remote").clicked() { + self.push_local_clipboard(); + } + + // chat + let chat_lbl = if self.chat_unread > 0 { format!("Chat({})", self.chat_unread) } else { "Chat".into() }; + if self.tb_toggle(ui, "💬", &chat_lbl, self.show_chat, "Chat").clicked() { + self.show_chat = !self.show_chat; + if self.show_chat { + self.chat_unread = 0; + } + } + + // power / processes + if self.tb_toggle(ui, "⏻", "Power", self.show_sys, "Power / processes").clicked() { + self.show_sys = !self.show_sys; + if self.show_sys { + self.with_session(|s| s.list_processes()); + } + } + + // files + if self.tb_toggle(ui, "🗁", "Files", self.show_files, "File transfer").clicked() { + if self.show_files { + self.show_files = false; + } else { + self.open_files(); + } + } + + // privacy + if self.tb_toggle(ui, "🛡", "Privacy", self.privacy_on, "Privacy mode (host monitor off)").clicked() { + self.with_session(|s| s.privacy()); + } + + // record → real .mp4 (H.264 remux, no re-encode) + if self.tb_toggle(ui, "⏺", "Rec", self.recording, "Record this session (VP9->WebM+audio / H.264->MP4, no re-encode)").clicked() { + if self.recording { + self.stop_recording(); + } else { + self.start_recording(); + } + } + + // relative / game mouse (raw deltas, hidden cursor) + if self + .tb_toggle(ui, "🎮", "Game", self.game_mouse, "Relative / game mouse (raw deltas, hidden cursor)") + .clicked() + { + self.game_mouse = !self.game_mouse; + } + + // translate / international keyboard (Unicode text) + if self + .tb_toggle(ui, "🌐", "Intl", self.translate_kbd, "Translate keyboard: type international / AltGr / dead-key characters as Unicode") + .clicked() + { + self.translate_kbd = !self.translate_kbd; + } + + // collaborative annotation (draw on the host's screen) + if self + .tb_toggle(ui, "✏", "Draw", self.annotate, "Annotate: draw on the host's screen (collaborative overlay)") + .clicked() + { + self.annotate = !self.annotate; + if !self.annotate { + self.annot_strokes.clear(); + self.annot_cur.clear(); + self.annot_drawing = false; + self.with_session(|s| s.annotate_clear()); + } + } + + // monitor cycle (only with 2+ displays) + if self.monitors.len() >= 2 { + let n = self.monitors.len(); + let idx = self + .monitors + .iter() + .position(|m| m.i == self.cur_mon) + .unwrap_or(0); + if self.tb_toggle(ui, "🔁", &format!("{}/{}", idx + 1, n), false, "Switch monitor").clicked() { + let next = self.monitors[(idx + 1) % n].i; + self.switch_monitor(next); + } + } + + // fullscreen + let fs_lbl = if self.fullscreen { "Window" } else { "Full" }; + if self.tb_toggle(ui, "⛶", fs_lbl, self.fullscreen, "Fullscreen").clicked() { + self.fullscreen = !self.fullscreen; + ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(self.fullscreen)); + } + + // ===================== RIGHT: stats + disconnect + collapse === + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + // collapse tab + if ui.add(egui::Button::new("▲").frame(false)).on_hover_text("Hide the toolbar").clicked() { + self.bar_collapsed = true; + } + + // disconnect + let disc = egui::Button::new(RichText::new(format!("{} Disconnect", ph::SIGN_OUT)).color(Color32::WHITE).strong()) + .fill(C_DANGER) + .rounding(Rounding::same(8.0)); + if ui.add(disc).clicked() { + self.go_home(); + } + + ui.separator(); + + // live stats + let e2e = if stats.e2e { "🔒E2E" } else { "TLS" }; + let path = if stats.p2p { " · ⚡Direct P2P" } else { "" }; + let line = if stats.connected { + format!( + "{}×{} · {:.0} fps · {:.1} Mbps · {:.0} ms · {}{}", + stats.res_w, stats.res_h, stats.fps, stats.mbps, self.lat_ms, e2e, path + ) + } else if let Some(reason) = &stats.ended { + format!("ended: {reason}") + } else if !stats.status.is_empty() { + stats.status.clone() + } else { + "connecting…".to_string() + }; + let col = if self.lat_ms < 80.0 { + c_accent() + } else if self.lat_ms < 180.0 { + Color32::from_rgb(220, 190, 90) + } else { + Color32::from_rgb(220, 120, 90) + }; + ui.label(RichText::new(line).color(col).monospace()); + }); + }); + }); + } + + /// A compact toolbar toggle button: emoji glyph + short label, highlighted + /// green when `on`. + fn tb_toggle(&self, ui: &mut egui::Ui, glyph: &str, label: &str, on: bool, tip: &str) -> egui::Response { + let txt = RichText::new(format!("{glyph} {label}")).color(if on { Color32::WHITE } else { C_TEXT }); + let mut btn = egui::Button::new(txt).rounding(Rounding::same(7.0)); + if on { + btn = btn.fill(c_accent().linear_multiply(0.85)).stroke(Stroke::new(1.0_f32, c_accent())); + } + ui.add(btn).on_hover_text(tip) + } + + fn switch_monitor(&mut self, i: i32) { + self.cur_mon = i; + self.with_session(|s| { + s.monitor_switch(i); + s.request_keyframe(); + }); + } + + // ------- menus ------- + fn menu_actions(&mut self, ui: &mut egui::Ui) { + ui.menu_button(format!("{} Actions ▾", ph::LIGHTNING), |ui| { + if ui.button(format!("{} Lock remote computer", ph::LOCK)).clicked() { + self.with_session(|s| s.lock()); + ui.close_menu(); + } + if ui.button(format!("{} Sign out on remote computer", ph::SIGN_OUT)).clicked() { + self.pending_confirm = Some(Confirm::Logoff); + ui.close_menu(); + } + if ui.button(format!("{} Remote reboot", ph::ARROW_CLOCKWISE)).clicked() { + self.pending_confirm = Some(Confirm::Reboot); + ui.close_menu(); + } + if ui.button(format!("{} Shut down", ph::POWER)).clicked() { + self.pending_confirm = Some(Confirm::Shutdown); + ui.close_menu(); + } + if ui.button(format!("{} Send Ctrl + Alt + Del", ph::KEYBOARD)).clicked() { + self.with_session(|s| s.ctrl_alt_del()); + ui.close_menu(); + } + ui.separator(); + let mut blk = self.block_input_on; + if ui.checkbox(&mut blk, "Block remote user's input").clicked() { + self.with_session(|s| s.block_input()); + ui.close_menu(); + } + let mut loe = self.lock_on_end; + if ui.checkbox(&mut loe, "Lock on session end").clicked() { + self.lock_on_end = loe; + self.with_session(|s| s.set_lock_on_end(loe)); + ui.close_menu(); + } + ui.separator(); + if ui.button(format!("{} Session information…", ph::INFO)).clicked() { + self.show_info = true; + ui.close_menu(); + } + }); + } + + fn menu_view(&mut self, ui: &mut egui::Ui) { + ui.menu_button(format!("{} View ▾", ph::MONITOR), |ui| { + ui.label(RichText::new("Monitor").color(C_MUTED).size(11.0)); + if self.monitors.is_empty() { + ui.label(RichText::new(" (single display)").color(C_MUTED)); + } else { + let mut switch_to = None; + for m in &self.monitors { + let on = self.cur_mon == m.i; + if ui + .selectable_label( + on, + if m.i < 0 { + format!("All displays ({}×{})", m.w, m.h) + } else { + format!("Display {} ({}×{})", m.i, m.w, m.h) + }, + ) + .clicked() + { + switch_to = Some(m.i); + } + } + if let Some(i) = switch_to { + self.switch_monitor(i); + ui.close_menu(); + } + } + + ui.separator(); + ui.label(RichText::new("Quality").color(C_MUTED).size(11.0)); + let mut new_q = None; + for q in Quality::ALL { + if ui.selectable_label(self.quality == q, q.label()).clicked() { + new_q = Some(q); + } + } + if let Some(q) = new_q { + self.apply_quality(q); + ui.close_menu(); + } + + ui.separator(); + ui.label(RichText::new("Scaling").color(C_MUTED).size(11.0)); + if ui.selectable_label(self.scale_mode == ScaleMode::Fit, "Best fit").clicked() { + self.scale_mode = ScaleMode::Fit; + ui.close_menu(); + } + if ui.selectable_label(self.scale_mode == ScaleMode::Orig, "Original (1:1, scroll)").clicked() { + self.scale_mode = ScaleMode::Orig; + ui.close_menu(); + } + + ui.separator(); + ui.label(RichText::new("Screen resolution").color(C_MUTED).size(11.0)); + if !self.modes_loaded { + if ui.button("Load resolutions…").clicked() { + self.with_session(|s| s.list_modes()); + self.banner("Loading resolutions…", true, 2); + ui.close_menu(); + } + } else if self.modes.is_empty() { + ui.label(RichText::new(" No modes reported").color(C_MUTED)); + } else { + let mut set_to = None; + for (w, h) in self.modes.clone().into_iter().take(18) { + let cur = self.cur_mode == Some((w, h)); + if ui.selectable_label(cur, format!("{w} × {h}")).clicked() { + set_to = Some((w, h)); + } + } + if let Some((w, h)) = set_to { + self.pending_confirm = Some(Confirm::SetMode(w, h)); + ui.close_menu(); + } + } + + ui.separator(); + let mut wp = self.wallpaper_hidden; + if ui.checkbox(&mut wp, "Hide wallpaper").clicked() { + self.with_session(|s| s.hide_wallpaper()); + ui.close_menu(); + } + if ui.button(format!("{} Refresh screen", ph::ARROWS_CLOCKWISE)).clicked() { + self.with_session(|s| s.refresh_screen()); + self.banner("Screen refreshed", true, 1); + ui.close_menu(); + } + }); + } + + fn menu_communicate(&mut self, ui: &mut egui::Ui) { + ui.menu_button(format!("{} Communicate ▾", ph::CHAT_CIRCLE), |ui| { + if ui.button(format!("{} Send message… (chat)", ph::CHAT_CIRCLE)).clicked() { + self.show_chat = true; + self.chat_unread = 0; + ui.close_menu(); + } + let mut snd = self.sound_on; + if ui.checkbox(&mut snd, "Play remote computer sound").clicked() { + self.sound_on = snd; + self.shared + .sound_on + .store(snd, std::sync::atomic::Ordering::Relaxed); + self.banner( + if snd { "Remote sound on" } else { "Remote sound muted" }, + true, + 2, + ); + ui.close_menu(); + } + ui.separator(); + let voice_on = self.shared.voice_stop.lock().unwrap().is_some(); + if ui + .button(if voice_on { "End voice call" } else { "Voice call (your mic)" }) + .clicked() + { + self.toggle_voice(); + ui.close_menu(); + } + }); + } + + fn menu_extras(&mut self, ui: &mut egui::Ui) { + ui.menu_button(format!("{} Extras ▾", ph::WRENCH), |ui| { + if ui.button(format!("{} File transfer", ph::FOLDER_OPEN)).clicked() { + self.open_files(); + ui.close_menu(); + } + if ui.button(format!("{} Chat", ph::CHAT_CIRCLE)).clicked() { + self.show_chat = true; + self.chat_unread = 0; + ui.close_menu(); + } + if ui.button(format!("{} Save screenshot", ph::CAMERA)).clicked() { + self.save_screenshot(); + ui.close_menu(); + } + if ui.button(format!("{} Record session", ph::RECORD)).clicked() { + self.recording = !self.recording; + self.banner( + if self.recording { "Recording — capture lands in a later wave" } else { "Recording stopped" }, + true, + 2, + ); + ui.close_menu(); + } + }); + } + + fn menu_help(&mut self, ui: &mut egui::Ui) { + ui.menu_button(format!("{} Help ▾", ph::QUESTION), |ui| { + if ui.button("About NetDesk…").clicked() { + self.show_about = true; + ui.close_menu(); + } + }); + } + + fn save_screenshot(&mut self) { + let (w, h) = self.tex_size; + if w == 0 || h == 0 { + self.banner("No frame yet", false, 2); + return; + } + // grab the newest decoded frame rgb (re-read the shared slot if present, + // otherwise we can't reconstruct from the GPU texture, so require a frame) + let rgb = { + let g = self.shared.frame.lock().unwrap(); + g.as_ref().map(|f| (f.w, f.h, f.rgb.clone())) + }; + // The UI usually consumes the frame before this runs, so keep a shadow + // copy of the last uploaded frame. + let (fw, fh, data) = match rgb.or_else(|| self.last_frame_rgb.clone()) { + Some(v) => v, + None => { + self.banner("No frame to save yet", false, 2); + return; + } + }; + let path = screenshot_path(); + match image::RgbImage::from_raw(fw, fh, data) { + Some(imgbuf) => match imgbuf.save(&path) { + Ok(_) => self.banner(format!("Screenshot saved: {}", path.display()), true, 4), + Err(e) => self.banner(format!("Save failed: {e}"), false, 4), + }, + None => self.banner("Screenshot failed (bad frame)", false, 3), + } + } +} + +// ----------------------------- panels / dialogs ----------------------------- +impl NetDeskApp { + fn draw_panels(&mut self, ctx: &egui::Context) { + // Panels clamp to the window so they can never open or resize wider / + // taller than the viewport (they stay movable within bounds — egui + // constrains windows to the screen rect). + let scr = ctx.screen_rect(); + let clamp_w = (scr.width() - 24.0).max(200.0); + let clamp_h = (scr.height() - 60.0).max(160.0); + // ---- Chat ---- + let mut show_chat = self.show_chat; + if show_chat { + egui::Window::new(format!("{} Chat", ph::CHAT_CIRCLE)) + .open(&mut show_chat) + .default_width(320.0_f32.min(clamp_w)) + .default_height(380.0_f32.min(clamp_h)) + .max_width(clamp_w) + .max_height(clamp_h) + .resizable(true) + .show(ctx, |ui| { + let send_h = 36.0; + egui::ScrollArea::vertical() + .max_height(ui.available_height() - send_h) + .stick_to_bottom(true) + .show(ui, |ui| { + for line in &self.chat_log { + let who_col = if line.mine { c_accent() } else { C_MUTED }; + ui.horizontal_wrapped(|ui| { + ui.label(RichText::new(format!("{}:", if line.mine { "You" } else { &line.who })).color(who_col).strong()); + ui.label(RichText::new(&line.text).color(C_TEXT)); + }); + } + if self.chat_log.is_empty() { + ui.label(RichText::new("No messages yet.").color(C_MUTED)); + } + }); + ui.separator(); + ui.horizontal(|ui| { + let resp = ui.add( + egui::TextEdit::singleline(&mut self.chat_input) + .hint_text("Type a message…") + .desired_width(ui.available_width() - 60.0), + ); + let send = ui.button("Send").clicked() + || (resp.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter))); + if send { + let txt = self.chat_input.trim().to_string(); + if !txt.is_empty() { + self.with_session(|s| s.chat(&txt)); + self.chat_log.push(ChatLine { + who: "You".into(), + text: txt, + mine: true, + }); + self.chat_input.clear(); + resp.request_focus(); + } + } + }); + }); + self.show_chat = show_chat; + } + + // ---- Remote power / processes ---- + let mut show_sys = self.show_sys; + if show_sys { + egui::Window::new(format!("{} Remote power", ph::POWER)) + .open(&mut show_sys) + .default_width(340.0_f32.min(clamp_w)) + .max_width(clamp_w) + .max_height(clamp_h) + .resizable(true) + .show(ctx, |ui| { + ui.label(RichText::new("Power actions").strong().color(C_TEXT)); + ui.horizontal_wrapped(|ui| { + if ui.button("Lock workstation").clicked() { + self.with_session(|s| s.lock()); + } + if ui.button("Sign out user").clicked() { + self.pending_confirm = Some(Confirm::Logoff); + } + if ui.button("Reboot").clicked() { + self.pending_confirm = Some(Confirm::Reboot); + } + let sd = egui::Button::new(RichText::new("Shut down").color(Color32::WHITE)).fill(C_DANGER); + if ui.add(sd).clicked() { + self.pending_confirm = Some(Confirm::Shutdown); + } + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(&mut self.run_cmd) + .hint_text("run a command on host (cmd /c …)") + .desired_width(ui.available_width() - 54.0), + ); + if ui.button("Run").clicked() { + let c = self.run_cmd.trim().to_string(); + if !c.is_empty() { + self.with_session(|s| s.run_command(&c)); + self.banner("Command sent", true, 2); + } + } + }); + + ui.separator(); + let mut loe = self.lock_on_end; + if ui.checkbox(&mut loe, "Lock PC when I disconnect").clicked() { + self.lock_on_end = loe; + self.with_session(|s| s.set_lock_on_end(loe)); + } + + ui.separator(); + ui.horizontal(|ui| { + ui.label(RichText::new("Processes").strong().color(C_TEXT)); + if ui.button(format!("{} Refresh", ph::ARROWS_CLOCKWISE)).clicked() { + self.with_session(|s| s.list_processes()); + } + }); + let mut kill_pid: Option<(u32, String)> = None; + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + if self.processes.is_empty() { + ui.label(RichText::new("No processes loaded.").color(C_MUTED)); + } + for p in &self.processes { + ui.horizontal(|ui| { + ui.label(RichText::new(&p.name).color(C_TEXT)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.small_button("⛔").on_hover_text("End process").clicked() { + kill_pid = Some((p.pid, p.name.clone())); + } + ui.label( + RichText::new(format!("{} · {} MB", p.pid, p.mem_kb / 1024)) + .color(C_MUTED) + .size(11.0), + ); + }); + }); + } + }); + if let Some((pid, name)) = kill_pid { + self.pending_confirm = Some(Confirm::Kill(pid, name)); + } + }); + self.show_sys = show_sys; + } + + // ---- File transfer (remote file browser) ---- + let mut show_files = self.show_files; + if show_files { + // actions are collected inside the closure and applied afterwards so + // the closure only needs disjoint field borrows (no &mut self calls). + let mut act_home = false; + let mut act_up = false; + let mut act_go = false; + let mut act_refresh = false; + let mut act_mkdir = false; + let mut act_upload = false; + let mut act_clear = false; + let mut nav: Option = None; + let mut download_req: Option<(String, String, u64)> = None; + let mut rename_req: Option<(String, String)> = None; + let mut delete_req: Option<(String, bool, String)> = None; + + egui::Window::new(format!("{} File transfer", ph::FOLDER_OPEN)) + .open(&mut show_files) + .default_width(460.0_f32.min(clamp_w)) + .default_height(520.0_f32.min(clamp_h)) + .max_width(clamp_w) + .max_height(clamp_h) + .resizable(true) + .show(ctx, |ui| { + // --- navigation row --- + ui.horizontal(|ui| { + if ui.button(format!("{} Drives", ph::HARD_DRIVES)).on_hover_text("List drives / home").clicked() { + act_home = true; + } + if ui.button(format!("{} Up", ph::ARROW_UP)).on_hover_text("Parent folder").clicked() { + act_up = true; + } + if ui.button(ph::ARROWS_CLOCKWISE).on_hover_text("Refresh this folder").clicked() { + act_refresh = true; + } + }); + // --- address bar --- + // Lay the "Go" button out first (right-to-left) and let the + // field fill the REMAINING width. The old form sized the field + // to `available_width() - 46` and then added the button after + // it, so the row overran the window by a few px each frame and + // the resizable window kept growing to the right. + ui.horizontal(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.button("Go").clicked() { + act_go = true; + } + let resp = ui.add( + egui::TextEdit::singleline(&mut self.file_path) + .hint_text("remote path, e.g. C:\\Users") + .desired_width((ui.available_width() - 6.0).max(80.0)), + ); + if resp.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) { + act_go = true; + } + }); + }); + ui.separator(); + + // --- file list --- + let list_h = (ui.available_height() - 150.0).max(100.0); + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .max_height(list_h) + .show(ui, |ui| { + if self.file_loading && self.file_entries.is_empty() { + ui.label(RichText::new("Loading…").color(C_MUTED)); + } else if self.file_entries.is_empty() { + ui.label(RichText::new("Empty folder.").color(C_MUTED)); + } + for e in &self.file_entries { + let full = join_path(&self.file_path, &e.name); + let icon = if e.is_dir { "📁" } else { "📄" }; + // Actions are laid out from the right so they always + // keep their space; the name fills the left and + // truncates. The whole row takes a double-click. + let row = ui.horizontal(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.small_button("🗑").on_hover_text("Delete").clicked() { + delete_req = Some((full.clone(), e.is_dir, e.name.clone())); + } + if ui.small_button("✏").on_hover_text("Rename").clicked() { + rename_req = Some((full.clone(), e.name.clone())); + } + if !e.is_dir { + if ui.small_button("⬇").on_hover_text("Download").clicked() { + download_req = Some((full.clone(), e.name.clone(), e.size)); + } + ui.label( + RichText::new(fmt_bytes(e.size)).color(C_MUTED).size(11.0), + ); + } + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + ui.add( + egui::Label::new( + RichText::new(format!("{icon} {}", e.name)) + .color(C_TEXT), + ) + .truncate(), + ); + }); + }); + }); + let tip = if e.is_dir { "Double-click to open" } else { "Double-click to download" }; + if row.response.interact(Sense::click()).on_hover_text(tip).double_clicked() { + if e.is_dir { + nav = Some(full.clone()); + } else { + download_req = Some((full.clone(), e.name.clone(), e.size)); + } + } + } + }); + ui.separator(); + + // --- footer actions --- + ui.horizontal(|ui| { + if ui.button(format!("{} New folder", ph::FOLDER_PLUS)).clicked() { + act_mkdir = true; + } + if ui.button(format!("{} Upload…", ph::UPLOAD_SIMPLE)).clicked() { + act_upload = true; + } + }); + + // --- transfers + progress --- + if !self.transfers.is_empty() { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label(RichText::new("Transfers").strong().color(C_TEXT)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.small_button("Clear done").clicked() { + act_clear = true; + } + }); + }); + // show the most recent transfers (newest last, capped) + let start = self.transfers.len().saturating_sub(6); + for t in &self.transfers[start..] { + let frac = if t.total > 0 { + (t.done as f32 / t.total as f32).clamp(0.0, 1.0) + } else if matches!(t.state, TransferState::Active) { + 0.0 + } else { + 1.0 + }; + let arrow = if t.up { "⬆" } else { "⬇" }; + let (status, col) = match &t.state { + TransferState::Active => { + (format!("{} / {}", fmt_bytes(t.done), fmt_bytes(t.total)), C_MUTED) + } + TransferState::Done => ("done".to_string(), c_accent()), + TransferState::Failed(m) => (format!("failed: {m}"), C_DANGER), + }; + ui.label( + RichText::new(format!("{arrow} {} · {status}", t.name)) + .color(col) + .size(12.0), + ); + ui.add( + egui::ProgressBar::new(frac) + .desired_height(8.0) + .fill(c_accent()), + ); + } + } + + // --- status line --- + if !self.file_status.is_empty() { + ui.add_space(3.0); + let col = if self.file_status_err { C_DANGER } else { C_MUTED }; + ui.label(RichText::new(&self.file_status).color(col).size(12.0)); + } + }); + self.show_files = show_files; + + // apply the collected actions (free to borrow &mut self here) + if act_home { + self.file_path.clear(); + self.file_loading = true; + self.file_status.clear(); + self.file_status_err = false; + self.with_session(|s| s.list_roots()); + } + if act_up { + let p = parent_path(&self.file_path); + self.list_dir_ui(p); + } + if act_go { + let p = self.file_path.trim().to_string(); + self.list_dir_ui(p); + } + if act_refresh { + self.refresh_files(); + } + if let Some(p) = nav { + self.list_dir_ui(p); + } + if let Some((full, name, size)) = download_req { + self.download_entry(full, name, size); + } + if act_mkdir { + self.file_prompt = Some(FilePrompt::Mkdir { name: String::new() }); + self.file_prompt_focus = true; + } + if let Some((src, name)) = rename_req { + self.file_prompt = Some(FilePrompt::Rename { src, name }); + self.file_prompt_focus = true; + } + if let Some((path, is_dir, name)) = delete_req { + self.pending_confirm = Some(Confirm::DeleteFile { path, is_dir, name }); + } + if act_clear { + self.transfers + .retain(|t| matches!(t.state, TransferState::Active)); + } + if act_upload { + self.pick_and_upload(ctx); + } + } + + // ---- About dialog ---- + let mut show_about = self.show_about; + if show_about { + let logo = self.logo_texture(ctx); + egui::Window::new("About NetDesk") + .open(&mut show_about) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) + .show(ctx, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(6.0); + if let Some(tex) = &logo { + ui.add(egui::Image::new(egui::load::SizedTexture::new( + tex.id(), + egui::vec2(72.0, 72.0), + ))); + } + ui.add_space(8.0); + ui.label(RichText::new(brand_name()).size(26.0).strong().color(C_TEXT)); + ui.label(RichText::new("Developed by Akronlogic.com").size(14.0).strong().color(c_accent())); + ui.add_space(6.0); + ui.label(RichText::new(format!("Native viewer · v{}", netdesk_core::VERSION)).color(C_MUTED)); + ui.label(RichText::new("Fast, self-hosted, end-to-end encrypted").color(C_MUTED).size(12.0)); + ui.add_space(6.0); + ui.hyperlink_to("akronlogic.com", "https://akronlogic.com"); + ui.add_space(6.0); + }); + }); + self.show_about = show_about; + } + + // ---- Session information ---- + let mut show_info = self.show_info; + if show_info { + egui::Window::new(format!("{} Session information", ph::INFO)) + .open(&mut show_info) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) + .show(ctx, |ui| { + let info = self.info.clone().unwrap_or_default(); + let stats = self.shared.stats.lock().unwrap().clone(); + let dur = self + .session_start + .map(|t| t.elapsed().as_secs()) + .unwrap_or(0); + // host-reported capture geometry (falls back to the live + // decoded resolution before the first session.info arrives) + let cap_w = if info.w > 0 { info.w } else { stats.res_w }; + let cap_h = if info.h > 0 { info.h } else { stats.res_h }; + let e2e = info.e2e || stats.e2e; + let mut enc_rate = String::new(); + if info.fps > 0 { + enc_rate = format!("{} fps", info.fps); + } + if info.bitrate > 0 { + if !enc_rate.is_empty() { + enc_rate.push_str(" · "); + } + enc_rate.push_str(&format!("{} kbps", info.bitrate)); + } + if enc_rate.is_empty() { + enc_rate = "—".into(); + } + let rows: Vec<(String, String)> = vec![ + ("Host".into(), if info.host_name.is_empty() { stats.host_name.clone() } else { info.host_name.clone() }), + ("Remote resolution".into(), format!("{} × {}", cap_w, cap_h)), + ("Monitor".into(), format!("{}", self.cur_mon)), + ("Encoder".into(), enc_rate), + ("Encryption".into(), if e2e { "ECDH P-256 + AES-256-GCM (end-to-end)".into() } else { "TLS 1.3 (transport)".into() }), + ("Remote audio".into(), if info.audio { "streaming".into() } else { "off".into() }), + ("Live".into(), format!("{:.0} fps · {:.1} Mbps · {:.0} ms", stats.fps, stats.mbps, self.lat_ms)), + ("Session time".into(), format!("{}m {}s", dur / 60, dur % 60)), + ("Lock on session end".into(), if self.lock_on_end { "on".into() } else { "off".into() }), + ("Clipboard allowed".into(), if info.clipboard_allowed { "yes".into() } else { "yes*".into() }), + ("File transfer allowed".into(), if info.files_allowed { "yes".into() } else { "yes*".into() }), + ]; + egui::Grid::new("info-grid").num_columns(2).spacing([18.0, 6.0]).show(ui, |ui| { + for (k, v) in rows { + ui.label(RichText::new(k).color(C_MUTED)); + ui.label(RichText::new(v).color(C_TEXT)); + ui.end_row(); + } + }); + }); + self.show_info = show_info; + } + + // ---- confirmation modal ---- + if let Some(confirm) = self.pending_confirm.clone() { + let (title, body) = match &confirm { + Confirm::Reboot => ("Reboot remote computer?", "The session will end when the host reboots.".to_string()), + Confirm::Shutdown => ("Shut down remote computer?", "The session will end and the host will power off.".to_string()), + Confirm::Logoff => ("Sign out the remote user?", "All of the remote user's apps will close.".to_string()), + Confirm::Kill(_, name) => ("End process?", format!("End \"{name}\" on the remote computer?")), + Confirm::SetMode(w, h) => ("Change resolution?", format!("Change the remote screen to {w} × {h}?")), + Confirm::DeleteFile { name, is_dir, .. } => ( + "Delete?", + format!( + "Delete \"{name}\"{} on the remote computer?", + if *is_dir { " and everything inside it" } else { "" } + ), + ), + }; + let mut open = true; + let mut decided: Option = None; + egui::Window::new(title) + .open(&mut open) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) + .show(ctx, |ui| { + ui.label(RichText::new(body).color(C_TEXT)); + ui.add_space(12.0); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + decided = Some(false); + } + let yes = egui::Button::new(RichText::new("Yes").color(Color32::WHITE).strong()).fill(C_DANGER); + if ui.add(yes).clicked() { + decided = Some(true); + } + }); + }); + if decided == Some(true) { + match &confirm { + Confirm::Reboot => self.with_session(|s| s.reboot()), + Confirm::Shutdown => self.with_session(|s| s.shutdown()), + Confirm::Logoff => self.with_session(|s| s.logoff()), + Confirm::Kill(pid, _) => { + let pid = *pid; + self.with_session(|s| s.kill_process(pid)); + let s2 = self.session.clone(); + // ask for a refreshed list shortly after + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(700)); + if let Some(s) = s2.lock().unwrap().as_ref() { + s.list_processes(); + } + }); + } + Confirm::SetMode(w, h) => { + let (w, h) = (*w, *h); + self.with_session(|s| s.set_mode(w, h)); + } + Confirm::DeleteFile { path, is_dir, .. } => { + let (p, d) = (path.clone(), *is_dir); + self.file_status = "Deleting…".into(); + self.file_status_err = false; + self.with_session(|s| s.delete(&p, d)); + } + } + self.pending_confirm = None; + } else if decided == Some(false) || !open { + self.pending_confirm = None; + } + } + + // ---- file name prompt (New folder / Rename) ---- + if let Some(mut prompt) = self.file_prompt.take() { + let (title, hint) = match &prompt { + FilePrompt::Mkdir { .. } => ("New folder", "Folder name"), + FilePrompt::Rename { .. } => ("Rename", "New name"), + }; + let focus = std::mem::take(&mut self.file_prompt_focus); + let mut open = true; + let mut submit = false; + let mut cancel = false; + egui::Window::new(title) + .open(&mut open) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) + .show(ctx, |ui| { + let name_ref: &mut String = match &mut prompt { + FilePrompt::Mkdir { name } => name, + FilePrompt::Rename { name, .. } => name, + }; + let resp = ui.add( + egui::TextEdit::singleline(name_ref) + .hint_text(hint) + .desired_width(260.0), + ); + if focus { + resp.request_focus(); + } + if resp.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) { + submit = true; + } + ui.add_space(10.0); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + cancel = true; + } + let ok = egui::Button::new(RichText::new("OK").color(Color32::WHITE).strong()) + .fill(c_accent()); + if ui.add(ok).clicked() { + submit = true; + } + }); + }); + if submit { + match &prompt { + FilePrompt::Mkdir { name } => { + let nm = name.trim().to_string(); + if !nm.is_empty() { + let p = join_path(&self.file_path, &nm); + self.file_status = format!("Creating {nm}…"); + self.file_status_err = false; + self.with_session(|s| s.make_dir(&p)); + } + } + FilePrompt::Rename { src, name } => { + let nm = name.trim().to_string(); + if !nm.is_empty() { + let dst = join_path(&self.file_path, &nm); + let src = src.clone(); + self.file_status = format!("Renaming to {nm}…"); + self.file_status_err = false; + self.with_session(|s| s.rename(&src, &dst)); + } + } + } + // consumed (prompt dropped) + } else if cancel || !open { + // consumed (prompt dropped) + } else { + // keep the modal open for further editing + self.file_prompt = Some(prompt); + } + } + } +} + +// ----------------------------- input forwarding ----------------------------- +impl NetDeskApp { + fn forward_input(&mut self, ctx: &egui::Context, img_rect: Option, rw: u32, rh: u32) { + // A modal confirmation blocks all forwarding. + if self.pending_confirm.is_some() { + self.prev_mods = ctx.input(|i| i.modifiers); + return; + } + // egui tells us when one of its own widgets (a focused text field, an + // open menu, a window being dragged) should receive the input instead. + let want_kb = ctx.wants_keyboard_input(); + let want_ptr = ctx.wants_pointer_input(); + + let rect = match img_rect { + Some(r) if rw > 0 && rh > 0 => r, + _ => { + self.prev_mods = ctx.input(|i| i.modifiers); + return; + } + }; + + // --- keyboard (only when no egui widget wants it) --- + let mods = ctx.input(|i| i.modifiers); + if !want_kb { + // modifier diffing (egui has no standalone Ctrl/Shift/Alt key events) + if mods.ctrl != self.prev_mods.ctrl { + self.with_session(|s| s.key("ControlLeft", mods.ctrl)); + } + if mods.shift != self.prev_mods.shift { + self.with_session(|s| s.key("ShiftLeft", mods.shift)); + } + if mods.alt != self.prev_mods.alt { + self.with_session(|s| s.key("AltLeft", mods.alt)); + } + let key_events: Vec<(Key, bool)> = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Key { key, pressed, .. } => Some((*key, *pressed)), + _ => None, + }) + .collect() + }); + // Translate / international-keyboard mode: printable characters go + // as literal Unicode text (so AltGr combos, dead keys and non-US + // layouts come through regardless of the host layout); their key + // events are suppressed to avoid double-typing, while control/nav + // keys and any modifier shortcut still go through as keys. + if self.translate_kbd { + let texts: Vec = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Text(t) => Some(t.clone()), + _ => None, + }) + .collect() + }); + for t in texts { + if !t.is_empty() { + self.with_session(|s| s.text(&t)); + } + } + } + let shortcut = mods.ctrl || mods.alt || mods.command; + for (key, pressed) in key_events { + if let Some(code) = key_to_dom(key) { + if self.translate_kbd && !shortcut && is_char_code(code) { + continue; // handled by Event::Text above + } + self.with_session(|s| s.key(code, pressed)); + } + } + } + self.prev_mods = mods; + + // --- pointer (only when no egui widget wants it) --- + if want_ptr { + return; + } + let (pos, primary, secondary, middle, scroll) = ctx.input(|i| { + ( + i.pointer.latest_pos(), + i.pointer.primary_down(), + i.pointer.secondary_down(), + i.pointer.middle_down(), + i.raw_scroll_delta, + ) + }); + + let mut buttons = 0u32; + if primary { + buttons |= BTN_LEFT; + } + if secondary { + buttons |= BTN_RIGHT; + } + if middle { + buttons |= BTN_MIDDLE; + } + + // Annotation mode: a drag draws ink (stored in remote coords + sent to + // the host overlay) instead of controlling the mouse. + if self.annotate { + let over = pos.map_or(false, |p| rect.contains(p)); + if primary && over { + if let Some(p) = pos { + self.annot_cur.push(map_to_remote(rect, p, rw, rh)); + } + self.annot_drawing = true; + } else if self.annot_drawing { + if !self.annot_cur.is_empty() { + let pts = std::mem::take(&mut self.annot_cur); + self.with_session(|s| s.annotate_stroke(0xFF_3333, 3, &pts)); + self.annot_strokes.push(pts); + } + self.annot_drawing = false; + } + return; + } + + // Relative / game mode: send raw frame-delta motion (scaled to remote + // pixels) instead of absolute coords, and hide the local cursor. + if self.game_mouse { + ctx.output_mut(|o| o.cursor_icon = egui::CursorIcon::None); + let delta = ctx.input(|i| i.pointer.delta()); + let sx = rw as f32 / rect.width().max(1.0); + let sy = rh as f32 / rect.height().max(1.0); + let dx = (delta.x * sx).round() as i32; + let dy = (delta.y * sy).round() as i32; + let b_changed = self.last_sent.map(|t| t.2) != Some(buttons); + if dx != 0 || dy != 0 || b_changed { + let wheel = if self.scale_mode == ScaleMode::Fit { scroll.y / 50.0 } else { 0.0 }; + self.with_session(|s| s.mouse_rel(dx, dy, buttons, wheel)); + self.last_sent = Some((0, 0, buttons)); + } + return; + } + + if let Some(p) = pos { + if rect.contains(p) { + let (x, y) = map_to_remote(rect, p, rw, rh); + let changed = self.last_sent != Some((x, y, buttons)); + // in 1:1 scroll mode the wheel pans locally, so don't also send it + let fit = self.scale_mode == ScaleMode::Fit; + let has_scroll = fit && (scroll.x.abs() > 0.1 || scroll.y.abs() > 0.1); + if changed || has_scroll { + let wheel = if fit { scroll.y / 50.0 } else { 0.0 }; + let hwheel = if fit { scroll.x / 50.0 } else { 0.0 }; + self.with_session(|s| s.mouse(x, y, buttons, wheel, hwheel)); + self.last_sent = Some((x, y, buttons)); + } + } + } + } +} + +// ----------------------------- net thread ----------------------------------- +fn spawn_net_thread( + cfg: ConnectConfig, + shared: Arc, + session_slot: Arc>>, + ctx: egui::Context, +) { + std::thread::Builder::new() + .name("netdesk-net".into()) + .spawn(move || { + let rt = match tokio::runtime::Builder::new_multi_thread().enable_all().build() { + Ok(rt) => rt, + Err(e) => { + set_ended(&shared, format!("runtime: {e}")); + ctx.request_repaint(); + return; + } + }; + rt.block_on(async move { + // Auto-reconnect loop: a transient drop AFTER a successful connect + // re-dials with exponential backoff reusing the same credentials + // (RustDesk-style); a user disconnect (shared.stop) or an auth/ + // availability denial is terminal. Mirrors the web viewer's + // scheduleReconnect so the native viewer no longer dumps the user + // back to the connect form on a brief network blip. + let mut backoff = 1u64; + let mut ever_connected = false; + 'reconnect: loop { + if shared.stop.load(Ordering::Relaxed) { + set_ended(&shared, "disconnected".into()); + ctx.request_repaint(); + break; + } + let (tx, mut rx) = mpsc::unbounded_channel::(); + let session = match connect(cfg.clone(), tx).await { + Ok(s) => s, + Err(e) => { + if ever_connected && !shared.stop.load(Ordering::Relaxed) { + set_status(&shared, &format!("reconnecting… ({e})")); + ctx.request_repaint(); + tokio::time::sleep(Duration::from_secs(backoff)).await; + backoff = (backoff * 2).min(5); + continue 'reconnect; + } + set_ended(&shared, format!("connect failed: {e}")); + ctx.request_repaint(); + break; + } + }; + session.request_keyframe(); + *session_slot.lock().unwrap() = Some(session); + let mut deny_reason: Option = None; + + let mut decoder = best_decoder(); + let mut cur_codec: u8 = 0; // 0 = H.264 (proto::VideoCodec) + // Lazily-opened remote-audio player (Opus decode + cpal output); + // created on the first audio packet while sound is on, dropped + // when the user mutes. Windows-only (vendored libopus). + #[cfg(windows)] + let mut audio_player: Option = None; + let mut frame_times: VecDeque = VecDeque::new(); + let mut byte_window: VecDeque<(Instant, usize)> = VecDeque::new(); + let rec_epoch = Instant::now(); // monotonic clock for recording timestamps + + while let Some(ev) = rx.recv().await { + match ev { + SessionEvent::Connecting => set_status(&shared, "connecting"), + SessionEvent::Challenged { e2e } => { + set_status(&shared, &format!("challenged (e2e={e2e})")) + } + SessionEvent::Connected { host_name, e2e, w, h } => { + // A successful connect arms auto-reconnect + resets backoff. + ever_connected = true; + backoff = 1; + { + let mut g = shared.stats.lock().unwrap(); + g.connected = true; + g.e2e = e2e; + g.host_name = host_name.clone(); + g.res_w = w; + g.res_h = h; + g.status = "connected".into(); + } + if let Some(s) = session_slot.lock().unwrap().as_ref() { + s.request_keyframe(); + } + println!(" CONNECTED to \"{host_name}\" {w}x{h} e2e={e2e}"); + ctx.request_repaint(); + } + SessionEvent::Video { au, keyframe, codec, .. } => { + // Follow the host's codec: it tags every frame and + // may switch H.264<->VP9 live by link quality. Swap + // the decoder only on a keyframe (a decoder can't + // start mid-GOP); drop non-key frames in a new codec + // and ask for a keyframe. + if codec != cur_codec { + if keyframe { + decoder = best_decoder_for(codec); + cur_codec = codec; + } else { + if let Some(s) = session_slot.lock().unwrap().as_ref() { + s.request_keyframe(); + } + continue; + } + } + let now = Instant::now(); + byte_window.push_back((now, au.len())); + // Tee into the session recording (Rec button), if active. + // The .mp4 muxer is H.264-only; feed it H.264 frames + // ONLY (we pin H.264 while recording, but guard here too + // so a stray VP9 frame can never corrupt the file). + if shared.recording.load(std::sync::atomic::Ordering::Relaxed) { + if let Ok(mut g) = shared.recorder.lock() { + // Create the recorder on the first keyframe for + // whatever codec is live, then pin that codec so + // the container stays consistent (no re-encode). + if g.is_none() && keyframe { + if let Some((stem, rw, rh, want_audio)) = + shared.rec_pending.lock().unwrap().take() + { + *g = Some(if codec == 1 { + SessionRec::Webm( + netdesk_core::webm::WebmRecorder::new( + stem.with_extension("webm"), + rw, + rh, + if want_audio { 2 } else { 0 }, + ), + ) + } else { + SessionRec::Mp4( + netdesk_core::recorder::Mp4Recorder::new( + stem.with_extension("mp4"), + rw, + rh, + ), + ) + }); + if let Some(s) = + session_slot.lock().unwrap().as_ref() + { + s.pin_codec(codec as i32); + } + } + } + if let Some(r) = g.as_mut() { + r.push_video( + &au, + keyframe, + rec_epoch.elapsed().as_millis() as u64, + ); + } + } + } + if let Ok(Some(frame)) = decoder.decode(&au) { + let fw = frame.w as u32; + let fh = frame.h as u32; + frame_times.push_back(now); + let cutoff = now - Duration::from_secs(1); + while frame_times.front().map_or(false, |t| *t < cutoff) { + frame_times.pop_front(); + } + while byte_window.front().map_or(false, |(t, _)| *t < cutoff) { + byte_window.pop_front(); + } + let fps = frame_times.len() as f32; + let bytes: usize = byte_window.iter().map(|(_, n)| *n).sum(); + let mbps = (bytes as f32 * 8.0) / 1_000_000.0; + { + let mut g = shared.stats.lock().unwrap(); + g.res_w = fw; + g.res_h = fh; + g.fps = fps; + g.mbps = mbps; + } + *shared.frame.lock().unwrap() = Some(Frame { + w: fw, + h: fh, + rgb: frame.rgb, + recv_at: now, + }); + ctx.request_repaint(); + } + } + // Remote system audio (Opus). Decode + play while the + // "Play remote computer sound" toggle is on; muting drops + // the player (stops the cpal stream). Windows-only. + #[cfg(windows)] + SessionEvent::Audio { data } => { + if shared.sound_on.load(std::sync::atomic::Ordering::Relaxed) { + if audio_player.is_none() { + match audio::AudioPlayer::new() { + Ok(p) => audio_player = Some(p), + Err(e) => { + eprintln!("[audio] cannot open output: {e}"); + } + } + } + if let Some(p) = audio_player.as_mut() { + p.push_opus(&data); + } + } else if audio_player.is_some() { + audio_player = None; // muted -> stop playback + } + // Tee the Opus into a live WebM recording's audio track + // (a no-op for an MP4 or video-only recording). + if shared.recording.load(std::sync::atomic::Ordering::Relaxed) { + if let Ok(mut g) = shared.recorder.lock() { + if let Some(r) = g.as_mut() { + r.push_audio( + &data, + rec_epoch.elapsed().as_millis() as u64, + ); + } + } + } + } + #[cfg(not(windows))] + SessionEvent::Audio { .. } => {} + SessionEvent::Cursor { x, y, hidden, .. } => { + if let Ok(mut c) = shared.cursor.lock() { + if hidden { + c.visible = false; + } else { + c.x = x as f32; + c.y = y as f32; + c.visible = true; + } + } + ctx.request_repaint(); + } + SessionEvent::CursorShape { png, hx, hy, .. } => { + if !png.is_empty() { + if let Ok(img) = image::load_from_memory(&png) { + let rgba = img.to_rgba8(); + let (w, h) = (rgba.width(), rgba.height()); + shared.push(UiEvent::CursorShape { + rgba: rgba.into_raw(), + w, + h, + hx: hx as f32, + hy: hy as f32, + }); + ctx.request_repaint(); + } + } + } + SessionEvent::ChatMessage { from, text } => { + shared.push(UiEvent::Chat { from, text }); + ctx.request_repaint(); + } + SessionEvent::Clipboard { text } => { + shared.push(UiEvent::Clipboard(text)); + ctx.request_repaint(); + } + // Folder-manifest replies are consumed by the netdesk-getdir + // CLI, not the GUI viewer. + SessionEvent::FileWalk { .. } => {} + SessionEvent::ClipboardImage { png } => { + // Put the host's clipboard image into THIS machine's + // clipboard (decode PNG -> RGBA -> arboard set_image). + if let Ok(img) = image::load_from_memory(&png) { + let rgba = img.to_rgba8(); + let (w, h) = (rgba.width() as usize, rgba.height() as usize); + if let Ok(mut cb) = arboard::Clipboard::new() { + let _ = cb.set_image(arboard::ImageData { + width: w, + height: h, + bytes: std::borrow::Cow::Owned(rgba.into_raw()), + }); + } + } + } + SessionEvent::Info { json } => { + if let Some(info) = parse_info(&json) { + shared.push(UiEvent::Info(info)); + ctx.request_repaint(); + } + } + SessionEvent::SysResult { json } => { + if let Some(ev) = parse_sysres(&json) { + shared.push(ev); + ctx.request_repaint(); + } + } + // ---- file transfer ---- + SessionEvent::FileList { path, entries } => { + shared.push(UiEvent::FileList { path, entries }); + ctx.request_repaint(); + } + SessionEvent::FileDownloadProgress { fid, received } => { + shared.push(UiEvent::FileProgress { fid, done: received }); + ctx.request_repaint(); + } + SessionEvent::FileDownloadDone { fid, name, size, path } => { + match move_download(&name, &path) { + Ok(dest) => shared.push(UiEvent::FileSaved { + fid, + name, + path: dest.display().to_string(), + size, + }), + Err(e) => { + let _ = std::fs::remove_file(&path); + shared.push(UiEvent::FileFail { + fid, + msg: format!("save failed: {e}"), + }); + } + } + ctx.request_repaint(); + } + SessionEvent::FileUploadAck { msg, refresh } => { + shared.push(UiEvent::FileOk { msg, refresh }); + ctx.request_repaint(); + } + SessionEvent::FileError { msg } => { + shared.push(UiEvent::FileErr { msg }); + ctx.request_repaint(); + } + // Interactive terminal (cmd) output. The egui terminal + // panel is a pending feature; the protocol is wired in + // core, so accept + drop the events here for now. + SessionEvent::TermData { .. } | SessionEvent::TermExit { .. } => {} + + SessionEvent::P2pState { active, direct } => { + if let Ok(mut st) = shared.stats.lock() { + st.p2p = active && direct; + } + ctx.request_repaint(); + } + SessionEvent::Denied { reason } => { + // Auth / availability decision — terminal, no retry. + deny_reason = Some(reason); + ctx.request_repaint(); + break; + } + SessionEvent::Ended { reason } => { + // Record it, but let the outer loop decide retry vs. stop. + set_status(&shared, &format!("link ended: {reason}")); + ctx.request_repaint(); + break; + } + } + } + // ---- inner event loop exited: reconnect or stop? ---- + if let ReconnectAction::Stop(kind) = reconnect_action( + shared.stop.load(Ordering::Relaxed), + deny_reason.is_some(), + ever_connected, + ) { + let msg = match (kind, &deny_reason) { + ("denied", Some(r)) => format!("denied: {r}"), + ("disconnected", _) => "disconnected".into(), + _ => "connection closed".into(), + }; + set_ended(&shared, msg); + ctx.request_repaint(); + break; + } + // Transient drop after a good session → reconnect with backoff. + // Keep the last frame on screen; clear `ended` so the UI shows + // "reconnecting…" instead of the Session-ended card. + *session_slot.lock().unwrap() = None; + { + let mut g = shared.stats.lock().unwrap(); + g.connected = false; + g.ended = None; + } + set_status(&shared, "connection lost — reconnecting…"); + ctx.request_repaint(); + tokio::time::sleep(Duration::from_secs(backoff)).await; + backoff = (backoff * 2).min(5); + } // 'reconnect loop + }); + }) + .expect("spawn net thread"); +} + +fn parse_info(json: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(json).ok()?; + let mut info = SessionInfo { + w: v.get("w").and_then(|x| x.as_u64()).unwrap_or(0) as u32, + h: v.get("h").and_then(|x| x.as_u64()).unwrap_or(0) as u32, + mon: v.get("mon").and_then(|x| x.as_i64()).unwrap_or(0) as i32, + fps: v.get("fps").and_then(|x| x.as_u64()).unwrap_or(0) as u32, + bitrate: v.get("bitrate").and_then(|x| x.as_u64()).unwrap_or(0) as u32, + host_name: v.get("host_name").and_then(|x| x.as_str()).unwrap_or("").to_string(), + e2e: v.get("e2e").and_then(|x| x.as_bool()).unwrap_or(false), + audio: v.get("audio").and_then(|x| x.as_bool()).unwrap_or(false), + lock_on_end: v.get("lock_on_end").and_then(|x| x.as_bool()).unwrap_or(false), + privacy_active: v.get("privacy_active").and_then(|x| x.as_bool()).unwrap_or(false), + logon_screen: v.get("logon_screen").and_then(|x| x.as_bool()).unwrap_or(false), + clipboard_allowed: true, + files_allowed: true, + monitors: Vec::new(), + }; + if let Some(perms) = v.get("perms") { + if perms.get("clipboard").and_then(|x| x.as_bool()) == Some(false) { + info.clipboard_allowed = false; + } + if perms.get("files").and_then(|x| x.as_bool()) == Some(false) { + info.files_allowed = false; + } + } + if let Some(arr) = v.get("monitors").and_then(|m| m.as_array()) { + for m in arr { + info.monitors.push(MonitorInfo { + i: m.get("i").and_then(|x| x.as_i64()).unwrap_or(0) as i32, + w: m.get("w").and_then(|x| x.as_u64()).unwrap_or(0) as u32, + h: m.get("h").and_then(|x| x.as_u64()).unwrap_or(0) as u32, + }); + } + } + Some(info) +} + +fn parse_sysres(json: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(json).ok()?; + let kind = v.get("kind").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let ok = v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false); + let msg = v.get("msg").and_then(|x| x.as_str()).unwrap_or("").to_string(); + match kind.as_str() { + "processes" => { + let mut items = Vec::new(); + if let Some(arr) = v.get("items").and_then(|x| x.as_array()) { + for p in arr { + items.push(ProcInfo { + pid: p.get("pid").and_then(|x| x.as_u64()).unwrap_or(0) as u32, + name: p.get("name").and_then(|x| x.as_str()).unwrap_or("?").to_string(), + mem_kb: p.get("mem").and_then(|x| x.as_u64()).unwrap_or(0), + }); + } + } + Some(UiEvent::Processes(items)) + } + "modes" => { + let mut items = Vec::new(); + if let Some(arr) = v.get("items").and_then(|x| x.as_array()) { + for wh in arr { + if let Some(pair) = wh.as_array() { + let w = pair.get(0).and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let h = pair.get(1).and_then(|x| x.as_u64()).unwrap_or(0) as u32; + if w > 0 && h > 0 { + items.push((w, h)); + } + } + } + } + let current = v.get("current").and_then(|c| c.as_array()).and_then(|p| { + let w = p.get(0)?.as_u64()? as u32; + let h = p.get(1)?.as_u64()? as u32; + Some((w, h)) + }); + Some(UiEvent::Modes { items, current }) + } + "privacy" | "block_input" | "wallpaper" => { + let on = v.get("on").and_then(|x| x.as_bool()).unwrap_or(false); + Some(UiEvent::Toggle { kind, on, ok, msg }) + } + _ => Some(UiEvent::Generic { kind, ok, msg }), + } +} + +fn set_status(shared: &Arc, s: &str) { + shared.stats.lock().unwrap().status = s.to_string(); +} + +/// What the net thread does when its event loop exits. Pure so it's unit-tested. +#[derive(PartialEq, Debug, Clone, Copy)] +enum ReconnectAction { + /// Terminal: stop the net thread (carrying the reason kind for the message). + Stop(&'static str), + /// Transient drop after a good session: re-dial with backoff. + Retry, +} + +/// Decide whether to auto-reconnect after the session event loop ended. +/// Precedence: an auth/availability denial and a user-initiated disconnect are +/// both terminal; a drop before we ever connected is terminal (initial-connect +/// failure, surfaced on the form); otherwise a drop after a good session retries. +fn reconnect_action(user_stopped: bool, denied: bool, ever_connected: bool) -> ReconnectAction { + if denied { + ReconnectAction::Stop("denied") + } else if user_stopped { + ReconnectAction::Stop("disconnected") + } else if !ever_connected { + ReconnectAction::Stop("closed") + } else { + ReconnectAction::Retry + } +} + +fn set_ended(shared: &Arc, reason: String) { + let mut g = shared.stats.lock().unwrap(); + g.connected = false; + g.ended = Some(reason); +} + +// ----------------------------- file transfer helpers ------------------------ + +/// Join a remote directory and an entry name exactly like the web viewer's +/// `joinPath(dir, name)` (server/web/app.js): an empty dir yields just the +/// name, a dir already ending in `/` or `\` is concatenated directly, otherwise +/// a forward slash is inserted (the host accepts mixed separators on Windows). +fn join_path(dir: &str, name: &str) -> String { + if dir.is_empty() { + name.to_string() + } else if dir.ends_with('/') || dir.ends_with('\\') { + format!("{dir}{name}") + } else { + format!("{dir}/{name}") + } +} + +/// Parent of a remote path — strips the last path component, mirroring the web +/// viewer's `#btnFileUp` regex `replace(/[\\/][^\\/]*[\\/]?$/, "")`. From a +/// drive root (`C:\`) this returns `""`, which lists the drives again. +fn parent_path(p: &str) -> String { + if p.is_empty() { + return String::new(); + } + let end = if p.ends_with('/') || p.ends_with('\\') { + p.len() - 1 + } else { + p.len() + }; + let head = &p[..end]; + match head.rfind(|c| c == '/' || c == '\\') { + Some(i) => head[..i].to_string(), + None => String::new(), + } +} + +/// Human-readable byte count, matching the web viewer's `fmtBytes`. +fn fmt_bytes(n: u64) -> String { + let f = n as f64; + if f > 1e9 { + format!("{:.2} GB", f / 1e9) + } else if f > 1e6 { + format!("{:.1} MB", f / 1e6) + } else if f > 1e3 { + format!("{:.1} KB", f / 1e3) + } else { + format!("{n} B") + } +} + +/// The local folder completed downloads are written to: `%USERPROFILE%\Downloads` +/// (falling back to the profile root, then the working directory). +fn downloads_dir() -> PathBuf { + if let Ok(up) = std::env::var("USERPROFILE") { + let d = Path::new(&up).join("Downloads"); + if d.is_dir() { + return d; + } + return PathBuf::from(up); + } + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) +} + +/// A non-clobbering path in `dir` for `name`: if `name` exists, insert +/// ` (1)`, ` (2)`, … before the extension (Explorer-style). +fn unique_path(dir: &Path, name: &str) -> PathBuf { + let candidate = dir.join(name); + if !candidate.exists() { + return candidate; + } + let p = Path::new(name); + let stem = p + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| name.to_string()); + let ext = p.extension().map(|s| s.to_string_lossy().to_string()); + for i in 1..100_000u32 { + let fname = match &ext { + Some(e) => format!("{stem} ({i}).{e}"), + None => format!("{stem} ({i})"), + }; + let c = dir.join(fname); + if !c.exists() { + return c; + } + } + candidate +} + +/// Move a completed download (streamed to a temp `.part` file on disk, never +/// buffered in RAM) into the Downloads folder, returning the final path. +fn move_download(name: &str, part: &std::path::Path) -> std::io::Result { + let dir = downloads_dir(); + let _ = std::fs::create_dir_all(&dir); + let safe = if name.trim().is_empty() { "file" } else { name }; + let path = unique_path(&dir, safe); + // rename is atomic on the same volume; fall back to copy+remove across volumes. + if std::fs::rename(part, &path).is_err() { + std::fs::copy(part, &path)?; + let _ = std::fs::remove_file(part); + } + Ok(path) +} + +/// Stream one local file to the host in `UPLOAD_CHUNK` pieces, reporting +/// progress through the shared inbox. Runs on its own thread (spawned by +/// `start_upload`) so the UI thread never blocks on disk I/O or encryption; the +/// session lock is taken per chunk so other UI actions can interleave. +fn upload_worker( + session: Arc>>, + shared: Arc, + ctx: egui::Context, + local: PathBuf, + remote_dir: String, +) { + let name = local + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "file".to_string()); + + let size = match std::fs::metadata(&local) { + Ok(m) => m.len(), + Err(e) => { + shared.push(UiEvent::FileErr { msg: format!("{name}: {e}") }); + ctx.request_repaint(); + return; + } + }; + + // allocate the fid + announce the destination (file.put) under the lock. + let fid = match session.lock().unwrap().as_ref() { + Some(s) => s.upload_begin(&remote_dir, &name, size), + None => { + shared.push(UiEvent::FileErr { msg: "no active session".into() }); + ctx.request_repaint(); + return; + } + }; + shared.push(UiEvent::TransferStart { fid, name: name.clone(), total: size, up: true }); + ctx.request_repaint(); + + let mut file = match std::fs::File::open(&local) { + Ok(f) => f, + Err(e) => { + shared.push(UiEvent::FileFail { fid, msg: format!("open {name}: {e}") }); + ctx.request_repaint(); + return; + } + }; + + if size == 0 { + // zero-byte file: the host completes on a single empty data frame. + if let Some(s) = session.lock().unwrap().as_ref() { + s.upload_chunk(fid, 0, &[]); + } + shared.push(UiEvent::UploadSent { fid }); + ctx.request_repaint(); + return; + } + + let mut buf = vec![0u8; UPLOAD_CHUNK]; + let mut off: u64 = 0; + loop { + let n = match file.read(&mut buf) { + Ok(0) => break, + Ok(n) => n, + Err(e) => { + shared.push(UiEvent::FileFail { fid, msg: format!("read {name}: {e}") }); + ctx.request_repaint(); + return; + } + }; + match session.lock().unwrap().as_ref() { + Some(s) => s.upload_chunk(fid, off, &buf[..n]), + None => { + shared.push(UiEvent::FileFail { fid, msg: "session closed".into() }); + ctx.request_repaint(); + return; + } + } + off += n as u64; + shared.push(UiEvent::FileProgress { fid, done: off }); + ctx.request_repaint(); + } + + shared.push(UiEvent::UploadSent { fid }); + ctx.request_repaint(); +} + +// ----------------------------- helpers -------------------------------------- +fn apply_theme(ctx: &egui::Context) { + // Load the Phosphor icon font as a fallback family so button glyphs (web, + // copy, folder, lock, power, arrows…) always render crisply instead of + // missing-glyph boxes. + let mut fonts = egui::FontDefinitions::default(); + egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular); + ctx.set_fonts(fonts); + + let mut v = egui::Visuals::dark(); + v.panel_fill = C_BG; + v.window_fill = C_PANEL; + v.extreme_bg_color = C_FIELD; + v.faint_bg_color = C_FIELD; + v.window_stroke = Stroke::new(1.0_f32, C_STROKE); + v.override_text_color = Some(C_TEXT); + v.hyperlink_color = c_accent(); + // Neutral SLATE-BLUE text selection (not green) with no outline, and a + // neutral light caret. A green-tinted selection/caret was being read as a + // stray "green line" when selecting/copying the ID or Partner-ID. + v.selection.bg_fill = Color32::from_rgba_unmultiplied(78, 108, 150, 96); + v.selection.stroke = Stroke::NONE; + v.text_cursor.stroke = Stroke::new(2.0_f32, Color32::from_rgb(214, 222, 233)); + + let r = Rounding::same(7.0); + v.widgets.noninteractive.bg_fill = C_FIELD; + v.widgets.noninteractive.weak_bg_fill = C_FIELD; + v.widgets.inactive.bg_fill = C_FIELD; + v.widgets.inactive.weak_bg_fill = C_FIELD; + v.widgets.inactive.rounding = r; + v.widgets.hovered.bg_fill = C_HOVER; + v.widgets.hovered.weak_bg_fill = C_HOVER; + v.widgets.hovered.rounding = r; + v.widgets.hovered.bg_stroke = Stroke::new(1.0_f32, c_accent().linear_multiply(0.6)); + v.widgets.active.bg_fill = c_accent_hi(); + v.widgets.active.weak_bg_fill = c_accent_hi(); + v.widgets.active.rounding = r; + v.widgets.open.bg_fill = C_HOVER; + v.widgets.open.rounding = r; + + ctx.set_visuals(v); + + let mut style = (*ctx.style()).clone(); + style.spacing.button_padding = egui::vec2(8.0, 5.0); + style.spacing.item_spacing = egui::vec2(8.0, 6.0); + ctx.set_style(style); +} + +/// Fit an `iw x ih` image into `avail`, preserving aspect ratio, centered. +/// Fallback remote-cursor pointer (white triangle, tip at `tip`), shown until +/// the host's real cursor bitmap arrives. +fn draw_arrow_cursor(p: &egui::Painter, tip: egui::Pos2) { + let pts = vec![ + tip, + tip + egui::vec2(0.0, 17.0), + tip + egui::vec2(12.0, 12.0), + ]; + p.add(egui::Shape::convex_polygon( + pts, + Color32::WHITE, + egui::Stroke::new(1.0, Color32::from_black_alpha(200)), + )); +} + +fn fit_rect(avail: Rect, iw: f32, ih: f32) -> Rect { + let scale = (avail.width() / iw).min(avail.height() / ih).max(0.0); + let w = iw * scale; + let h = ih * scale; + let min = Pos2::new( + avail.left() + (avail.width() - w) * 0.5, + avail.top() + (avail.height() - h) * 0.5, + ); + Rect::from_min_size(min, Vec2::new(w, h)) +} + +/// Invert the letterbox: window pixel -> remote screen pixel. +fn map_to_remote(rect: Rect, p: Pos2, rw: u32, rh: u32) -> (i32, i32) { + let fx = ((p.x - rect.left()) / rect.width()).clamp(0.0, 1.0); + let fy = ((p.y - rect.top()) / rect.height()).clamp(0.0, 1.0); + let x = (fx * rw as f32).round() as i32; + let y = (fy * rh as f32).round() as i32; + (x.clamp(0, rw as i32 - 1), y.clamp(0, rh as i32 - 1)) +} + +/// Where to drop a saved screenshot: the user's Pictures folder if we can find +/// it, else the current directory. +fn screenshot_path() -> std::path::PathBuf { + let ts = { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + now + }; + let name = format!("netdesk-{ts}.png"); + if let Ok(up) = std::env::var("USERPROFILE") { + let pics = std::path::Path::new(&up).join("Pictures"); + if pics.is_dir() { + return pics.join(name); + } + return std::path::Path::new(&up).join(&name); + } + std::path::PathBuf::from(name) +} + +/// Map an egui logical key to a W3C UI-Events DOM `code` string (what the host +/// expects). Modifier keys are handled separately via `Modifiers` diffing. +/// Whether a DOM key code names a printable character key (letters, digits, +/// space, punctuation). In translate mode these are typed via Event::Text +/// (Unicode) rather than key events, so this decides which keys to suppress. +fn is_char_code(code: &str) -> bool { + code.starts_with("Key") + || code.starts_with("Digit") + || matches!( + code, + "Space" | "Minus" | "Equal" | "BracketLeft" | "BracketRight" | "Backslash" + | "Semicolon" | "Quote" | "Backquote" | "Comma" | "Period" | "Slash" + ) +} + +fn key_to_dom(key: Key) -> Option<&'static str> { + use Key::*; + let s = match key { + A => "KeyA", B => "KeyB", C => "KeyC", D => "KeyD", E => "KeyE", F => "KeyF", + G => "KeyG", H => "KeyH", I => "KeyI", J => "KeyJ", K => "KeyK", L => "KeyL", + M => "KeyM", N => "KeyN", O => "KeyO", P => "KeyP", Q => "KeyQ", R => "KeyR", + S => "KeyS", T => "KeyT", U => "KeyU", V => "KeyV", W => "KeyW", X => "KeyX", + Y => "KeyY", Z => "KeyZ", + Num0 => "Digit0", Num1 => "Digit1", Num2 => "Digit2", Num3 => "Digit3", + Num4 => "Digit4", Num5 => "Digit5", Num6 => "Digit6", Num7 => "Digit7", + Num8 => "Digit8", Num9 => "Digit9", + F1 => "F1", F2 => "F2", F3 => "F3", F4 => "F4", F5 => "F5", F6 => "F6", + F7 => "F7", F8 => "F8", F9 => "F9", F10 => "F10", F11 => "F11", F12 => "F12", + F13 => "F13", F14 => "F14", F15 => "F15", F16 => "F16", F17 => "F17", + F18 => "F18", F19 => "F19", F20 => "F20", + F21 => "F21", F22 => "F22", F23 => "F23", F24 => "F24", + ArrowUp => "ArrowUp", ArrowDown => "ArrowDown", + ArrowLeft => "ArrowLeft", ArrowRight => "ArrowRight", + Enter => "Enter", Escape => "Escape", Backspace => "Backspace", + Tab => "Tab", Space => "Space", + Delete => "Delete", Insert => "Insert", + Home => "Home", End => "End", PageUp => "PageUp", PageDown => "PageDown", + Minus => "Minus", Equals => "Equal", Plus => "Equal", + Comma => "Comma", Period => "Period", Semicolon => "Semicolon", + Backslash => "Backslash", Slash => "Slash", + OpenBracket => "BracketLeft", CloseBracket => "BracketRight", + Backtick => "Backquote", + Quote => "Quote", // apostrophe / double-quote key — was unmapped (dead) + _ => return None, + }; + Some(s) +} + +#[cfg(test)] +mod key_tests { + use super::{key_to_dom, Key}; + + #[test] + fn maps_quote_high_fkeys_and_core_keys() { + // newly added mappings (were returning None → dead keys) + assert_eq!(key_to_dom(Key::Quote), Some("Quote")); + assert_eq!(key_to_dom(Key::F21), Some("F21")); + assert_eq!(key_to_dom(Key::F24), Some("F24")); + // regression guard on existing mappings + assert_eq!(key_to_dom(Key::A), Some("KeyA")); + assert_eq!(key_to_dom(Key::Num0), Some("Digit0")); + assert_eq!(key_to_dom(Key::Enter), Some("Enter")); + assert_eq!(key_to_dom(Key::Backtick), Some("Backquote")); + } +} + +#[cfg(test)] +mod reconnect_tests { + use super::{reconnect_action, ReconnectAction}; + + #[test] + fn transient_drop_after_connect_retries() { + // Was connected, not a user stop, not denied -> auto-reconnect. + assert_eq!(reconnect_action(false, false, true), ReconnectAction::Retry); + } + + #[test] + fn user_disconnect_is_terminal() { + // Deliberate disconnect never reconnects, even after a good session. + assert_eq!(reconnect_action(true, false, true), ReconnectAction::Stop("disconnected")); + } + + #[test] + fn denial_is_terminal_and_wins() { + // Wrong password / host offline -> no retry, even if "ever_connected". + assert_eq!(reconnect_action(false, true, true), ReconnectAction::Stop("denied")); + assert_eq!(reconnect_action(false, true, false), ReconnectAction::Stop("denied")); + } + + #[test] + fn drop_before_first_connect_is_terminal() { + // Socket closed mid-handshake (never connected) -> surface on the form, + // don't loop forever on an unreachable host. + assert_eq!(reconnect_action(false, false, false), ReconnectAction::Stop("closed")); + } +} + +#[cfg(all(test, windows))] +mod config_cli_tests { + // Unattended `--config` injection must write config.json from flags and hash + // the password (never store it in plaintext). + #[test] + fn writes_config_from_flags_and_hashes_password() { + let dir = std::env::temp_dir().join(format!("nd-cfgtest-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("config.json"); + let ps = path.to_str().unwrap().to_string(); + let args: Vec = [ + "NetDesk.exe", "--config", "--path", &ps, + "--server", "wss://deploy.example/ws", "--reg-key", "ENTERPRISE1", + "--id", "123456789", "--name", "Lab-PC-07", "--password", "Sup3rSecret!", + "--allow-ips", "10.0.0.0/8", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + + super::apply_cli_config(&args).expect("apply_cli_config"); + + let s = std::fs::read_to_string(&path).unwrap(); + let v: serde_json::Value = serde_json::from_str(&s).unwrap(); + assert_eq!(v["server"], "wss://deploy.example/ws"); + assert_eq!(v["reg_key"], "ENTERPRISE1"); + assert_eq!(v["id"], "123456789"); + assert_eq!(v["name"], "Lab-PC-07"); + assert_eq!(v["allow_ips"], "10.0.0.0/8"); + // The password is hashed into a PBKDF2 record (salt+iters+verifier) that + // the server verifies — the same records the GUI's "set password" writes. + assert!( + v["verifier"].as_str().map(|x| !x.is_empty()).unwrap_or(false), + "password must be hashed into a verifier" + ); + assert!( + v["salt"].as_str().map(|x| !x.is_empty()).unwrap_or(false), + "a PBKDF2 salt must be present" + ); + assert_eq!(v["iters"], 200_000, "PBKDF2 iteration count persisted"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/native/vpx/Cargo.toml b/native/vpx/Cargo.toml new file mode 100644 index 0000000..21bf3a1 --- /dev/null +++ b/native/vpx/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "netdesk-vpx" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +description = "VP8/VP9 (libvpx) encode/decode for NetDesk — the constrained-link codec. Developed by Akronlogic." +links = "vpx" +build = "build.rs" + +[dependencies] +anyhow = { workspace = true } + +[build-dependencies] +bindgen = "=0.65.1" diff --git a/native/vpx/build.rs b/native/vpx/build.rs new file mode 100644 index 0000000..fe08b82 --- /dev/null +++ b/native/vpx/build.rs @@ -0,0 +1,62 @@ +//! Link the vendored static libvpx (UCRT MinGW ABI) + generate FFI bindings +//! from the vendored vpx headers via bindgen (libclang). RustDesk does the same +//! (its vpx.rs include!s an OUT_DIR/vpx_ffi.rs). We vendor a UCRT-ABI libvpx.a +//! so there is no vcpkg/MSYS2 or runtime DLL dependency. +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=vpx"); + println!("cargo:rustc-link-lib=pthread"); + println!("cargo:rerun-if-changed=wrapper.h"); + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed={}", lib.join("libvpx.a").display()); + + // bindgen needs libclang; point at the installed LLVM if the env is unset. + 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; + } + } + } + + // Based on RustDesk's proven bindgen config (libs/scrap/build.rs): a single + // `^[vV].*` allowlist for types/vars/functions, rustified enums. + // + // vpx_codec_enc_cfg and vpx_codec_dec_cfg are forward-declared (by pointer) + // in vpx_codec.h's iface struct BEFORE vpx_encoder.h/vpx_decoder.h define + // them. libclang 22 then reports them to bindgen as incomplete, so bindgen + // emits them as opaque `_address: u8` blobs with no fields — making g_w / + // rc_target_bitrate / threads unsettable. (bindgen version, trust_clang_ + // mangling, rustified_enum, and a by-value use all failed to change this.) + // So we blocklist both here and hand-write their exact #[repr(C)] layouts + // in src/lib.rs (ffi module) from the vendored headers. Everything else + // (vpx_image, cx_pkt, ctx, enums, functions) still comes from bindgen. + let regex = "^[vV].*"; + 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) + .blocklist_type("vpx_codec_enc_cfg") + .blocklist_type("vpx_codec_enc_cfg_t") + .blocklist_type("vpx_codec_dec_cfg") + .blocklist_type("vpx_codec_dec_cfg_t") + .trust_clang_mangling(false) + .layout_tests(false) + .generate_comments(false) + .generate() + .expect("bindgen libvpx"); + bindings + .write_to_file(PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("vpx_ffi.rs")) + .expect("write vpx_ffi.rs"); +} diff --git a/native/vpx/src/lib.rs b/native/vpx/src/lib.rs new file mode 100644 index 0000000..e547db0 --- /dev/null +++ b/native/vpx/src/lib.rs @@ -0,0 +1,742 @@ +//! VP8/VP9 software codec over the vendored static libvpx (UCRT MinGW). +//! +//! The raw FFI is generated by bindgen from the vendored headers (build.rs), +//! EXCEPT for `vpx_codec_enc_cfg` / `vpx_codec_dec_cfg`: those are forward- +//! declared by pointer in vpx_codec.h before their definitions, which libclang +//! reports to bindgen as opaque (`_address: u8`) blobs with no settable fields. +//! They are blocklisted in build.rs and hand-written below with the exact +//! `#[repr(C)]` layout from the vendored headers (ABI-identical: C `enum` and +//! `unsigned int` are both 4 bytes, `struct vpx_rational` is two i32, and +//! `vpx_fixed_buf_t` is `{void*, size_t}`). +//! +//! The safe Encoder/Decoder are ported from RustDesk's libs/scrap vpxcodec.rs +//! (VP9 CBR realtime config, cpu-used 7, row-mt, 4 tile columns). Color +//! conversions (BGRA<->I420, BT.601) are pure-Rust so there is no libyuv dep. +#![allow(non_upper_case_globals)] +#![allow(non_camel_case_types)] +#![allow(non_snake_case)] +#![allow(dead_code)] + +use anyhow::{anyhow, bail, Result}; +use std::os::raw::{c_int, c_uint}; + +pub mod ffi { + #![allow( + non_camel_case_types, + non_snake_case, + non_upper_case_globals, + dead_code, + improper_ctypes + )] + use std::os::raw::{c_int, c_uint, c_void}; + + // ---- hand-written config structs (blocklisted from bindgen) ---- + // Distinct helper names (Nd*) so they never collide with bindgen's own + // vpx_rational / vpx_fixed_buf while staying ABI-identical. + #[repr(C)] + #[derive(Copy, Clone)] + pub struct NdVpxRational { + pub num: c_int, + pub den: c_int, + } + #[repr(C)] + #[derive(Copy, Clone)] + pub struct NdVpxFixedBuf { + pub buf: *mut c_void, + pub sz: usize, + } + + /// Mirror of `vpx_codec_enc_cfg_t` (vpx_encoder.h) — 61 fields, in order. + #[repr(C)] + #[derive(Copy, Clone)] + pub struct vpx_codec_enc_cfg { + pub g_usage: c_uint, + pub g_threads: c_uint, + pub g_profile: c_uint, + pub g_w: c_uint, + pub g_h: c_uint, + pub g_bit_depth: c_uint, // enum vpx_bit_depth + pub g_input_bit_depth: c_uint, + pub g_timebase: NdVpxRational, + pub g_error_resilient: c_uint, // vpx_codec_er_flags_t + pub g_pass: c_uint, // enum vpx_enc_pass + pub g_lag_in_frames: c_uint, + pub rc_dropframe_thresh: c_uint, + pub rc_resize_allowed: c_uint, + pub rc_scaled_width: c_uint, + pub rc_scaled_height: c_uint, + pub rc_resize_up_thresh: c_uint, + pub rc_resize_down_thresh: c_uint, + pub rc_end_usage: c_uint, // enum vpx_rc_mode + pub rc_twopass_stats_in: NdVpxFixedBuf, + pub rc_firstpass_mb_stats_in: NdVpxFixedBuf, + pub rc_target_bitrate: c_uint, + pub rc_min_quantizer: c_uint, + pub rc_max_quantizer: c_uint, + pub rc_undershoot_pct: c_uint, + pub rc_overshoot_pct: c_uint, + pub rc_buf_sz: c_uint, + pub rc_buf_initial_sz: c_uint, + pub rc_buf_optimal_sz: c_uint, + pub rc_2pass_vbr_bias_pct: c_uint, + pub rc_2pass_vbr_minsection_pct: c_uint, + pub rc_2pass_vbr_maxsection_pct: c_uint, + pub rc_2pass_vbr_corpus_complexity: c_uint, + pub kf_mode: c_uint, // enum vpx_kf_mode + pub kf_min_dist: c_uint, + pub kf_max_dist: c_uint, + pub ss_number_layers: c_uint, + pub ss_enable_auto_alt_ref: [c_int; 5], // VPX_SS_MAX_LAYERS + pub ss_target_bitrate: [c_uint; 5], + pub ts_number_layers: c_uint, + pub ts_target_bitrate: [c_uint; 5], // VPX_TS_MAX_LAYERS + pub ts_rate_decimator: [c_uint; 5], + pub ts_periodicity: c_uint, + pub ts_layer_id: [c_uint; 16], // VPX_TS_MAX_PERIODICITY + pub layer_target_bitrate: [c_uint; 12], // VPX_MAX_LAYERS + pub temporal_layering_mode: c_int, + pub use_vizier_rc_params: c_int, + pub active_wq_factor: NdVpxRational, + pub err_per_mb_factor: NdVpxRational, + pub sr_default_decay_limit: NdVpxRational, + pub sr_diff_factor: NdVpxRational, + pub kf_err_per_mb_factor: NdVpxRational, + pub kf_frame_min_boost_factor: NdVpxRational, + pub kf_frame_max_boost_first_factor: NdVpxRational, + pub kf_frame_max_boost_subs_factor: NdVpxRational, + pub kf_max_total_boost_factor: NdVpxRational, + pub gf_max_total_boost_factor: NdVpxRational, + pub gf_frame_max_boost_factor: NdVpxRational, + pub zm_factor: NdVpxRational, + pub rd_mult_inter_qp_fac: NdVpxRational, + pub rd_mult_arf_qp_fac: NdVpxRational, + pub rd_mult_key_qp_fac: NdVpxRational, + } + pub type vpx_codec_enc_cfg_t = vpx_codec_enc_cfg; + + /// Mirror of `vpx_codec_dec_cfg_t` (vpx_decoder.h). + #[repr(C)] + #[derive(Copy, Clone)] + pub struct vpx_codec_dec_cfg { + pub threads: c_uint, + pub w: c_uint, + pub h: c_uint, + } + pub type vpx_codec_dec_cfg_t = vpx_codec_dec_cfg; + + include!(concat!(env!("OUT_DIR"), "/vpx_ffi.rs")); +} + +use std::ffi::CStr; + +/// libvpx version string, e.g. "v1.17.0". +pub fn version() -> String { + unsafe { + let p = ffi::vpx_codec_version_str(); + if p.is_null() { + return String::new(); + } + CStr::from_ptr(p).to_string_lossy().into_owned() + } +} + +/// Which libvpx codec to use. VP9 is the constrained-link default (far better +/// per-bitrate than H.264); VP8 is a fallback for very old decoders. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum VpxCodec { + Vp8, + Vp9, +} + +/// One compressed output packet. +pub struct EncodedFrame { + pub data: Vec, + pub key: bool, +} + +/// One decoded frame, converted to packed BGRA (8-bit, straight alpha = 255). +pub struct DecodedFrame { + pub width: u32, + pub height: u32, + pub bgra: Vec, +} + +#[inline] +fn ck(code: i32, what: &str) -> Result<()> { + if code == 0 { + Ok(()) + } else { + Err(anyhow!("{what}: vpx error {code}")) + } +} + +fn thread_num() -> c_uint { + std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4) + .min(16) as c_uint +} + +/// VP8/VP9 encoder (software, libvpx). Input is packed BGRA; dimensions must be +/// even. One keyframe is emitted on the first frame (or on demand). +pub struct Encoder { + ctx: ffi::vpx_codec_ctx_t, + width: u32, + height: u32, + i420: Vec, // reusable packed plane scratch (I420 Y|U|V, or I444 Y|U|V) + i444: bool, // true => VP9 profile-1 4:4:4 chroma (crisp text); else I420 +} + +impl Encoder { + /// `bitrate_kbps` is the CBR target in kilobits/sec. I420 (4:2:0) chroma. + pub fn new(codec: VpxCodec, width: u32, height: u32, fps: u32, bitrate_kbps: u32) -> Result { + Self::new_i444(codec, width, height, fps, bitrate_kbps, false) + } + + /// Like [`Encoder::new`], but `i444` selects VP9 profile-1 4:4:4 chroma + /// (full-resolution U/V) for crisp text and saturated 1px edges that 4:2:0 + /// blurs. VP8 has no such profile, so it is ignored there (stays I420). + pub fn new_i444( + codec: VpxCodec, + width: u32, + height: u32, + fps: u32, + bitrate_kbps: u32, + i444: bool, + ) -> Result { + if width == 0 || height == 0 || width % 2 != 0 || height % 2 != 0 { + bail!("vpx encoder needs non-zero even dimensions, got {width}x{height}"); + } + let _ = fps; // reserved for future rc tuning; ms timebase is fps-independent + unsafe { + let iface = match codec { + VpxCodec::Vp8 => ffi::vpx_codec_vp8_cx(), + VpxCodec::Vp9 => ffi::vpx_codec_vp9_cx(), + }; + if iface.is_null() { + bail!("vpx codec iface null"); + } + let mut cfg: ffi::vpx_codec_enc_cfg_t = std::mem::zeroed(); + ck( + ffi::vpx_codec_enc_config_default(iface, &mut cfg, 0) as i32, + "enc_config_default", + )?; + + // Match RustDesk's VP9 realtime CBR config. + cfg.g_w = width; + cfg.g_h = height; + cfg.g_timebase.num = 1; + cfg.g_timebase.den = 1000; // ms timestamps + cfg.rc_undershoot_pct = 95; + cfg.rc_dropframe_thresh = 25; + cfg.g_threads = thread_num(); + cfg.g_lag_in_frames = 0; // realtime: no lookahead, emit every frame immediately + cfg.g_error_resilient = 1; // VPX_ERROR_RESILIENT_DEFAULT + cfg.rc_end_usage = 1; // VPX_CBR + cfg.kf_mode = 0; // VPX_KF_DISABLED (we force keyframes on demand) + cfg.rc_min_quantizer = 0; + cfg.rc_max_quantizer = 63; + cfg.rc_target_bitrate = bitrate_kbps.max(50); + // VP9 profile 1 carries 8-bit 4:2:2/4:4:4; VP8 has no such profile. + let use_i444 = i444 && codec == VpxCodec::Vp9; + cfg.g_profile = if use_i444 { 1 } else { 0 }; + + let mut ctx: ffi::vpx_codec_ctx_t = std::mem::zeroed(); + ck( + ffi::vpx_codec_enc_init_ver( + &mut ctx, + iface, + &cfg, + 0, + ffi::VPX_ENCODER_ABI_VERSION as c_int, + ) as i32, + "enc_init", + )?; + + if codec == VpxCodec::Vp9 { + ck( + ffi::vpx_codec_control_( + &mut ctx, + ffi::vp8e_enc_control_id::VP8E_SET_CPUUSED as c_int, + 7 as c_int, + ) as i32, + "set_cpuused", + )?; + ck( + ffi::vpx_codec_control_( + &mut ctx, + ffi::vp8e_enc_control_id::VP9E_SET_ROW_MT as c_int, + 1 as c_int, + ) as i32, + "set_row_mt", + )?; + ck( + ffi::vpx_codec_control_( + &mut ctx, + ffi::vp8e_enc_control_id::VP9E_SET_TILE_COLUMNS as c_int, + 4 as c_int, + ) as i32, + "set_tile_columns", + )?; + } else { + ck( + ffi::vpx_codec_control_( + &mut ctx, + ffi::vp8e_enc_control_id::VP8E_SET_CPUUSED as c_int, + 12 as c_int, + ) as i32, + "set_cpuused_vp8", + )?; + } + + Ok(Self { + ctx, + width, + height, + i420: Vec::new(), + i444: use_i444, + }) + } + } + + /// Encode one packed-BGRA frame at `pts_ms`. Returns any compressed packets + /// the encoder produced (usually one). `force_key` requests a keyframe. + pub fn encode_bgra(&mut self, bgra: &[u8], pts_ms: i64, force_key: bool) -> Result> { + let (w, h) = (self.width as usize, self.height as usize); + if bgra.len() != w * h * 4 { + bail!("bgra len {} != {}", bgra.len(), w * h * 4); + } + let fmt = if self.i444 { + bgra_to_i444(bgra, w, h, &mut self.i420); + ffi::vpx_img_fmt::VPX_IMG_FMT_I444 + } else { + bgra_to_i420(bgra, w, h, &mut self.i420); + ffi::vpx_img_fmt::VPX_IMG_FMT_I420 + }; + + unsafe { + let mut img: ffi::vpx_image_t = std::mem::zeroed(); + let wrapped = ffi::vpx_img_wrap( + &mut img, + fmt, + self.width as c_uint, + self.height as c_uint, + 1, + self.i420.as_ptr() as *mut u8, + ); + if wrapped.is_null() { + bail!("vpx_img_wrap failed"); + } + let flags: ffi::vpx_enc_frame_flags_t = if force_key { + ffi::VPX_EFLAG_FORCE_KF as ffi::vpx_enc_frame_flags_t + } else { + 0 + }; + ck( + ffi::vpx_codec_encode( + &mut self.ctx, + &img, + pts_ms as ffi::vpx_codec_pts_t, + 1, + flags, + ffi::VPX_DL_REALTIME as _, + ) as i32, + "encode", + )?; + Ok(self.drain()) + } + } + + unsafe fn drain(&mut self) -> Vec { + let mut iter: ffi::vpx_codec_iter_t = std::ptr::null(); + let mut out = Vec::new(); + loop { + let pkt = ffi::vpx_codec_get_cx_data(&mut self.ctx, &mut iter); + if pkt.is_null() { + break; + } + if (*pkt).kind == ffi::vpx_codec_cx_pkt_kind::VPX_CODEC_CX_FRAME_PKT { + let f = &(*pkt).data.frame; + let data = std::slice::from_raw_parts(f.buf as *const u8, f.sz as usize).to_vec(); + let key = (f.flags & ffi::VPX_FRAME_IS_KEY) != 0; + out.push(EncodedFrame { data, key }); + } + } + out + } +} + +impl Drop for Encoder { + fn drop(&mut self) { + unsafe { + ffi::vpx_codec_destroy(&mut self.ctx); + } + } +} + +// A libvpx context holds raw pointers, so it is not auto-Send. It is never +// shared (no Sync), but a single owner may move it between threads — the host +// capture/encode thread owns the Encoder, the viewer network thread owns the +// Decoder. This matches RustDesk's VpxEncoder/VpxDecoder. +unsafe impl Send for Encoder {} +unsafe impl Send for Decoder {} + +/// VP8/VP9 decoder (software, libvpx). Produces packed BGRA frames. +pub struct Decoder { + ctx: ffi::vpx_codec_ctx_t, +} + +impl Decoder { + pub fn new(codec: VpxCodec) -> Result { + unsafe { + let iface = match codec { + VpxCodec::Vp8 => ffi::vpx_codec_vp8_dx(), + VpxCodec::Vp9 => ffi::vpx_codec_vp9_dx(), + }; + if iface.is_null() { + bail!("vpx decoder iface null"); + } + let cfg = ffi::vpx_codec_dec_cfg_t { + threads: thread_num(), + w: 0, + h: 0, + }; + let mut ctx: ffi::vpx_codec_ctx_t = std::mem::zeroed(); + ck( + ffi::vpx_codec_dec_init_ver( + &mut ctx, + iface, + &cfg, + 0, + ffi::VPX_DECODER_ABI_VERSION as c_int, + ) as i32, + "dec_init", + )?; + Ok(Self { ctx }) + } + } + + /// Decode one compressed packet into zero or more BGRA frames. + pub fn decode_to_bgra(&mut self, data: &[u8]) -> Result> { + unsafe { + ck( + ffi::vpx_codec_decode( + &mut self.ctx, + data.as_ptr(), + data.len() as c_uint, + std::ptr::null_mut(), + 0, + ) as i32, + "decode", + )?; + let mut iter: ffi::vpx_codec_iter_t = std::ptr::null(); + let mut out = Vec::new(); + loop { + let img = ffi::vpx_codec_get_frame(&mut self.ctx, &mut iter); + if img.is_null() { + break; + } + let im = &*img; + let w = im.d_w as usize; + let h = im.d_h as usize; + // The VP9 decoder adapts to the stream's profile: profile-1 + // frames arrive as I444 (full-res chroma), profile-0 as I420. + // One Decoder therefore handles either without reconfiguration. + let bgra = if im.fmt == ffi::vpx_img_fmt::VPX_IMG_FMT_I444 { + i444_to_bgra( + im.planes[0] as *const u8, + im.planes[1] as *const u8, + im.planes[2] as *const u8, + im.stride[0] as usize, + im.stride[1] as usize, + w, + h, + ) + } else { + i420_to_bgra( + im.planes[0] as *const u8, + im.planes[1] as *const u8, + im.planes[2] as *const u8, + im.stride[0] as usize, + im.stride[1] as usize, + w, + h, + ) + }; + out.push(DecodedFrame { + width: im.d_w, + height: im.d_h, + bgra, + }); + } + Ok(out) + } + } +} + +impl Drop for Decoder { + fn drop(&mut self) { + unsafe { + ffi::vpx_codec_destroy(&mut self.ctx); + } + } +} + +// ---- pure-Rust BT.601 color conversion (no libyuv) ---- + +/// Packed BGRA -> packed I420 (Y plane w*h, then U, then V at cw*ch). `w`,`h` +/// must be even. Output is laid out exactly as vpx_img_wrap(I420, align=1) +/// expects for even dimensions. +fn bgra_to_i420(bgra: &[u8], w: usize, h: usize, out: &mut Vec) { + let cw = w / 2; + let ch = h / 2; + let y_size = w * h; + let c_size = cw * ch; + out.clear(); + out.resize(y_size + 2 * c_size, 0); + let (y_plane, rest) = out.split_at_mut(y_size); + let (u_plane, v_plane) = rest.split_at_mut(c_size); + + for j in 0..h { + for i in 0..w { + let p = (j * w + i) * 4; + let b = bgra[p] as f32; + let g = bgra[p + 1] as f32; + let r = bgra[p + 2] as f32; + let y = 0.257 * r + 0.504 * g + 0.098 * b + 16.0; + y_plane[j * w + i] = clamp8(y); + } + } + for cj in 0..ch { + for ci in 0..cw { + let mut rs = 0.0f32; + let mut gs = 0.0f32; + let mut bs = 0.0f32; + for dy in 0..2 { + for dx in 0..2 { + let p = ((cj * 2 + dy) * w + (ci * 2 + dx)) * 4; + bs += bgra[p] as f32; + gs += bgra[p + 1] as f32; + rs += bgra[p + 2] as f32; + } + } + let (r, g, b) = (rs / 4.0, gs / 4.0, bs / 4.0); + let u = -0.148 * r - 0.291 * g + 0.439 * b + 128.0; + let v = 0.439 * r - 0.368 * g - 0.071 * b + 128.0; + u_plane[cj * cw + ci] = clamp8(u); + v_plane[cj * cw + ci] = clamp8(v); + } + } +} + +/// I420 planes (with strides) -> packed BGRA. +unsafe fn i420_to_bgra( + y: *const u8, + u: *const u8, + v: *const u8, + ys: usize, + cs: usize, + w: usize, + h: usize, +) -> Vec { + let mut out = vec![0u8; w * h * 4]; + for j in 0..h { + for i in 0..w { + let yv = *y.add(j * ys + i) as f32; + let uu = *u.add((j / 2) * cs + i / 2) as f32; + let vv = *v.add((j / 2) * cs + i / 2) as f32; + let c = yv - 16.0; + let d = uu - 128.0; + let e = vv - 128.0; + let r = 1.164 * c + 1.596 * e; + let g = 1.164 * c - 0.391 * d - 0.813 * e; + let b = 1.164 * c + 2.018 * d; + let p = (j * w + i) * 4; + out[p] = clamp8(b); + out[p + 1] = clamp8(g); + out[p + 2] = clamp8(r); + out[p + 3] = 255; + } + } + out +} + +/// Packed BGRA -> packed I444 (Y|U|V, each w*h at full resolution). Unlike +/// I420 the chroma planes keep every pixel's own U/V, so 4:4:4 preserves 1px +/// colored text and hard edges that 4:2:0 averages away. Layout matches +/// vpx_img_wrap(I444, align=1). +fn bgra_to_i444(bgra: &[u8], w: usize, h: usize, out: &mut Vec) { + let n = w * h; + out.clear(); + out.resize(3 * n, 0); + let (y_plane, rest) = out.split_at_mut(n); + let (u_plane, v_plane) = rest.split_at_mut(n); + for idx in 0..n { + let p = idx * 4; + let b = bgra[p] as f32; + let g = bgra[p + 1] as f32; + let r = bgra[p + 2] as f32; + y_plane[idx] = clamp8(0.257 * r + 0.504 * g + 0.098 * b + 16.0); + u_plane[idx] = clamp8(-0.148 * r - 0.291 * g + 0.439 * b + 128.0); + v_plane[idx] = clamp8(0.439 * r - 0.368 * g - 0.071 * b + 128.0); + } +} + +/// I444 planes (full-res chroma, with strides) -> packed BGRA. +unsafe fn i444_to_bgra( + y: *const u8, + u: *const u8, + v: *const u8, + ys: usize, + cs: usize, + w: usize, + h: usize, +) -> Vec { + let mut out = vec![0u8; w * h * 4]; + for j in 0..h { + for i in 0..w { + let yv = *y.add(j * ys + i) as f32; + let uu = *u.add(j * cs + i) as f32; + let vv = *v.add(j * cs + i) as f32; + let c = yv - 16.0; + let d = uu - 128.0; + let e = vv - 128.0; + let p = (j * w + i) * 4; + out[p] = clamp8(1.164 * c + 2.018 * d); + out[p + 1] = clamp8(1.164 * c - 0.391 * d - 0.813 * e); + out[p + 2] = clamp8(1.164 * c + 1.596 * e); + out[p + 3] = 255; + } + } + out +} + +#[inline] +fn clamp8(v: f32) -> u8 { + v.round().clamp(0.0, 255.0) as u8 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bindgen_links_libvpx() { + let v = version(); + assert!(v.starts_with('v'), "libvpx version via bindgen FFI: {v}"); + let iface = unsafe { ffi::vpx_codec_vp9_cx() }; + assert!(!iface.is_null(), "vp9 encoder iface"); + println!("VPX_BINDGEN_OK {v}"); + } + + #[test] + fn vp9_roundtrip_bgra() { + let (w, h) = (64usize, 64usize); + let mut bgra = vec![0u8; w * h * 4]; + // four solid quadrants (BGRA order) + 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 = Encoder::new(VpxCodec::Vp9, w as u32, h as u32, 30, 2000).unwrap(); + let frames = enc.encode_bgra(&bgra, 0, true).unwrap(); + assert!(!frames.is_empty(), "encoder produced no packets"); + assert!(frames[0].key, "first frame must be a keyframe"); + + let mut dec = Decoder::new(VpxCodec::Vp9).unwrap(); + let mut got = Vec::new(); + for f in &frames { + got.extend(dec.decode_to_bgra(&f.data).unwrap()); + } + assert_eq!(got.len(), 1, "expected exactly one decoded frame"); + let img = &got[0]; + assert_eq!((img.width, img.height), (w as u32, h as u32)); + + let sample = |x: usize, y: usize| { + let p = (y * w + x) * 4; + (img.bgra[p], img.bgra[p + 1], img.bgra[p + 2]) + }; + let approx = |a: u8, b: u8| (a as i32 - b as i32).abs() <= 48; + + let (b, g, r) = sample(w / 4, h / 4); // red quadrant center + assert!( + approx(r, 200) && approx(g, 30) && approx(b, 30), + "red quad decoded as b{b} g{g} r{r}" + ); + let (b, g, r) = sample(3 * w / 4, 3 * h / 4); // gray quadrant center + assert!( + approx(r, 200) && approx(g, 200) && approx(b, 200), + "gray quad decoded as b{b} g{g} r{r}" + ); + + println!( + "VPX_ROUNDTRIP_OK vp9 {w}x{h} key_bytes={}", + frames[0].data.len() + ); + } + + #[test] + fn vp9_i444_beats_i420_on_1px_chroma() { + // 1px-wide vertical stripes of two DIFFERENT-chroma colors (magenta / + // green). 4:2:0 stores one chroma sample per 2x2 block, so it CANNOT + // represent per-column chroma no matter the bitrate; 4:4:4 keeps it. + let (w, h) = (64usize, 64usize); + 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 i % 2 == 0 { + (200u8, 0, 200) // magenta + } else { + (0, 170, 0) // green + }; + bgra[p] = b; + bgra[p + 1] = g; + bgra[p + 2] = r; + bgra[p + 3] = 255; + } + } + let mae = |i444: bool| -> f64 { + let mut enc = + Encoder::new_i444(VpxCodec::Vp9, w as u32, h as u32, 30, 4000, i444).unwrap(); + let frames = enc.encode_bgra(&bgra, 0, true).unwrap(); + let mut dec = Decoder::new(VpxCodec::Vp9).unwrap(); + let mut got = Vec::new(); + for f in &frames { + got.extend(dec.decode_to_bgra(&f.data).unwrap()); + } + assert_eq!(got.len(), 1); + let img = &got[0]; + let mut e = 0f64; + for idx in 0..w * h { + let p = idx * 4; + e += (img.bgra[p] as i32 - bgra[p] as i32).abs() as f64; + e += (img.bgra[p + 1] as i32 - bgra[p + 1] as i32).abs() as f64; + e += (img.bgra[p + 2] as i32 - bgra[p + 2] as i32).abs() as f64; + } + e / (3 * w * h) as f64 + }; + let e420 = mae(false); + let e444 = mae(true); + println!("VPX_I444_OK e420={e420:.2} e444={e444:.2}"); + assert!( + e444 + 1.0 < e420, + "i444 MAE {e444:.2} should clearly beat i420 {e420:.2} on 1px chroma" + ); + } +} diff --git a/native/vpx/wrapper.h b/native/vpx/wrapper.h new file mode 100644 index 0000000..78ba358 --- /dev/null +++ b/native/vpx/wrapper.h @@ -0,0 +1,9 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include diff --git a/publishing/README.md b/publishing/README.md new file mode 100644 index 0000000..33a70da --- /dev/null +++ b/publishing/README.md @@ -0,0 +1,52 @@ +# Publishing the source on netdesk.space + +The full source is downloadable + git-cloneable straight from the website: +- Page: https://netdesk.space/source +- Download: https://netdesk.space/downloads/netdesk-src-latest.tar.gz (+ .zip) +- Clone: git clone https://netdesk.space/git/netdesk.git + +## How it's built (secret-safe) + +1. **Export tracked files only** (never DBs/artifacts/secrets): + `git archive HEAD` from this repo + the sibling `netdesk-native` repo + (extracted into `netdesk/native/`). +2. **Strip internal + sensitive** files: planning/roadmap/queue docs, + `native/certs/`, `native/docs/implementation-specs.md`, dev screenshots. +3. **Secret scan** the export (known secrets + an adversarial multi-scanner + sweep for keys/tokens/PII/stray binaries) and scrub any hit. The real + account password, owner email, account token and live host ID have been + removed from source — keep it that way (they must never be committed). +4. **Package**: `.tar.gz` + `.zip` (top-level `netdesk/`) and a bare repo + (`git clone --bare` + `git update-server-info`) for dumb-HTTP clone. + +## How it's served + +Static nginx locations in `/etc/nginx/conf.d/netdesk.space.conf` (see +`nginx-source.snippet`) serving `/opt/netdesk/public/{source,downloads,git}`. +Zero app-server changes; `nginx -t` + `systemctl reload nginx` (no downtime). + +`source-page.html` is the download/clone landing page (served at /source/). + +## GitHub-style browsing (Gitea) + +The source is also browsable like GitHub — file tree, rendered README, commit +history, language stats, per-branch ZIP/TAR.GZ downloads: + +- **Browse:** https://netdesk.space/git/akronlogic/netdesk +- **Clone:** git clone https://netdesk.space/git/akronlogic/netdesk.git + +Served by **Gitea** (self-hosted, latest stable) running as the systemd service +`gitea` on 127.0.0.1:3030, reverse-proxied by nginx at the `/git/` sub-path +(`ROOT_URL = https://netdesk.space/git/`). SQLite data in `/var/lib/gitea`, +config in `/etc/gitea/app.ini`. Hardened: registration disabled, anonymous +read-only, SSH/Actions/Packages off, offline mode. Admin login is in +`/root/gitea-admin.txt` (root-only) — user `ndadmin`. + +### Publishing an update +Rebuild the secret-scrubbed export (see above), then push it to the Gitea repo: +```bash +cd && git init && git add -A && git commit -m "release" +git push --force https://ndadmin:@netdesk.space/git/akronlogic/netdesk.git master +``` +(Only ever push the SCRUBBED export — never the raw repo, which contains the +owner email in portal.js. Re-run the secret scan first.) diff --git a/publishing/nginx-source.snippet b/publishing/nginx-source.snippet new file mode 100644 index 0000000..30923ac --- /dev/null +++ b/publishing/nginx-source.snippet @@ -0,0 +1,16 @@ + # ---- Public source code: download + git clone (served statically) ---- + location = /source { return 302 /source/; } + location /source/ { + alias /opt/netdesk/public/source/; + index index.html; + add_header Cache-Control "public, max-age=300"; + } + location /downloads/ { + alias /opt/netdesk/public/downloads/; + autoindex on; + add_header Cache-Control "public, max-age=300"; + } + location /git/ { + alias /opt/netdesk/public/git/; + autoindex off; + } diff --git a/publishing/source-page.html b/publishing/source-page.html new file mode 100644 index 0000000..704ac73 --- /dev/null +++ b/publishing/source-page.html @@ -0,0 +1,133 @@ + + + + + +NetDesk — Source Code + + + + + +
+ + +
+ Open source · self-hosted +

The complete NetDesk source

+

Every line — the relay server, the Windows host, the zero-install web viewer, and the native Rust client. Clone it or download it straight from here. No GitHub account required.

+ +

Version 1.5.0 · AES-256 end-to-end · Windows + Web today, macOS/Linux/Android via the native client build

+
+ +
+

Clone with git

+

Served straight from netdesk.space over HTTPS:

+
# get the full source +git clone https://netdesk.space/git/akronlogic/netdesk.git +cd netdesk
+
+ +
+

What's inside

+

A single repository with every component of the system.

+
+
server/Python aiohttp signaling + relay + REST API, SSO (SAML/OIDC/LDAP), SCIM, admin console, and the web/ browser viewer.
+
host/Windows host/agent (runs as a SYSTEM service — reachable at the logon screen & across UAC) + a pywebview desktop app.
+
native/From-scratch native client: a Rust core (capture, encode, crypto, P2P) with a Flutter UI. Workspace crates: core, viewer, host, cli, opus, vpx, frb.
+
docker/Dockerfile + compose to run your own relay server with one command.
+
+
+ +
+

Self-host in 60 seconds

+

Run your own relay server (your VPS, your rules — the shop never sees your screen):

+
# 1. get it +git clone https://netdesk.space/git/akronlogic/netdesk.git && cd netdesk +# 2. install deps + run the server +pip install -r requirements.txt +python server/netdesk_server.py --port 8443 --portal-password YOUR_PASSWORD +# 3. put it behind TLS (nginx/Caddy) and point your host + browser at it
+

Full deployment guide in docker/README.md and README.md. Source-available license — free to self-host & modify. See LICENSE.

+
+ +
+ NetDesk · self-hosted remote desktop · netdesk.space
+ © 2026 Akronlogic. Source-available — free to self-host. +
+
+ + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0748812 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,17 @@ +# NetDesk dependencies +# Server (VPS or local): aiohttp, cryptography +# Host (Windows PC): + mss, numpy, av (bundles FFmpeg x264), pillow +# Optional host audio: pip install PyAudioWPatch +aiohttp>=3.9 +cryptography>=42 +mss>=9 +dxcam>=0.3.0 +comtypes>=1.4 +numpy>=1.26 +av>=12 +pillow>=10 +aioice==0.10.2 +aiortc==1.15.0 +pyee==13.0.1 +pylibsrtp==1.0.0 +zeroconf==0.151.5 diff --git a/scripts/build_mac.sh b/scripts/build_mac.sh new file mode 100644 index 0000000..919594a --- /dev/null +++ b/scripts/build_mac.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# NetDesk build for Apple Silicon / Intel Macs. +# Run ON the Mac (PyInstaller cannot cross-compile): +# bash scripts/build_mac.sh +# Produces: +# dist/NetDesk.app - desktop control panel (double-click app) +# dist/netdesk-engine - headless agent binary (LaunchAgent / CLI) +# dist/NetDeskServer - server binary (optional, if the Mac is the VPS) +set -euo pipefail +cd "$(dirname "$0")/.." # netdesk root + +echo "==> creating build venv" +python3 -m venv .venv-build +./.venv-build/bin/pip install --quiet --upgrade pip +./.venv-build/bin/pip install --quiet aiohttp cryptography mss numpy av pillow pyinstaller + +echo "==> building NetDesk.app (desktop control panel, double-click to run)" +./.venv-build/bin/pyinstaller --windowed --name NetDesk \ + --distpath dist --workpath build --specpath build host/netdesk_host.py + +echo "==> building netdesk-engine (headless agent for LaunchAgent/CLI)" +./.venv-build/bin/pyinstaller --onefile --name netdesk-engine \ + --distpath dist --workpath build --specpath build host/netdesk_host.py + +echo "==> building NetDeskServer" +./.venv-build/bin/pyinstaller --onefile --name NetDeskServer \ + --add-data "server/web:web" \ + --distpath dist --workpath build --specpath build server/netdesk_server.py + +rm -rf build .venv-build +echo +echo "==> done:" +ls -lh dist/ +echo +echo "What you got:" +echo " dist/NetDesk.app - the desktop control panel (double-click)" +echo " dist/netdesk-engine - headless agent (used by Start-at-Login and CLI)" +echo " dist/NetDeskServer - the server binary (optional, if the Mac is the VPS)" +echo +echo "First run: macOS asks for Screen Recording and Accessibility" +echo "(System Settings -> Privacy & Security) - both are required for" +echo "remote control, same as RustDesk/TeamViewer." +echo "If Gatekeeper blocks an app: xattr -cr dist/NetDesk.app" +echo +echo "Autostart: open NetDesk.app -> enter server + key -> Start at Login." +echo "This installs ~/Library/LaunchAgents/com.netdesk.host.plist (invisible engine)." diff --git a/scripts/build_windows.bat b/scripts/build_windows.bat new file mode 100755 index 0000000..d889178 --- /dev/null +++ b/scripts/build_windows.bat @@ -0,0 +1,27 @@ +@echo off +REM Build the NetDesk portable Windows binaries (host app + server) with PyInstaller. +REM Run from the repo root: scripts\build_windows.bat +setlocal +cd /d "%~dp0.." +set PY=..\.venv\Scripts\python.exe + +echo === Building NetDeskHost.exe (windowed desktop app + engine) === +"%PY%" -m PyInstaller --noconfirm --clean --onefile --name NetDeskHost --windowed ^ + --icon host\icon.ico ^ + --add-data "host\netdesk_gui.html;." ^ + --paths host ^ + --hidden-import netdesk_gui ^ + --hidden-import pyaudiowpatch ^ + host\netdesk_host.py +if errorlevel 1 exit /b 1 + +echo === Building NetDeskServer.exe (console signaling/relay server) === +"%PY%" -m PyInstaller --noconfirm --clean --onefile --name NetDeskServer --console ^ + --add-data "server\web;web" ^ + server\netdesk_server.py +if errorlevel 1 exit /b 1 + +echo === Done. Binaries in dist\ === +copy /Y dist\NetDeskHost.exe portable\NetDeskHost.exe +copy /Y dist\NetDeskServer.exe portable\NetDeskServer.exe +endlocal diff --git a/scripts/deploy/com.netdesk.host.plist b/scripts/deploy/com.netdesk.host.plist new file mode 100644 index 0000000..1918d6a --- /dev/null +++ b/scripts/deploy/com.netdesk.host.plist @@ -0,0 +1,27 @@ + + + + + Label + com.netdesk.host + + ProgramArguments + + + /Applications/NetDesk/NetDeskHost + + --server + wss://your-vps.example:8443/ws + + + RunAtLoad + + KeepAlive + + + StandardOutPath + /tmp/netdesk-host.log + StandardErrorPath + /tmp/netdesk-host.log + + diff --git a/scripts/deploy/deploy_vps.sh b/scripts/deploy/deploy_vps.sh new file mode 100644 index 0000000..307576e --- /dev/null +++ b/scripts/deploy/deploy_vps.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# NetDesk VPS deployment helper - run as root on a fresh Debian/Ubuntu VPS. +# Reads REG_KEY from the environment or generates one and prints it. +set -euo pipefail + +REG_KEY="${REG_KEY:-$(head -c 24 /dev/urandom | base64 | tr -d '/+=')}" +STATUS_KEY="${STATUS_KEY:-$(head -c 24 /dev/urandom | base64 | tr -d '/+=')}" +PORT="${PORT:-8443}" +DEST=/opt/netdesk + +echo "==> installing python deps" +apt-get update -qq +apt-get install -y -qq python3 python3-pip +python3 -m pip install --break-system-packages --quiet aiohttp cryptography || \ + python3 -m pip install --quiet aiohttp cryptography + +echo "==> installing files to $DEST" +mkdir -p "$DEST/server" +cp -r "$(dirname "$0")/../server/." "$DEST/server/" + +echo "==> creating service user" +id -u netdesk >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin netdesk +chown -R netdesk:netdesk "$DEST" + +echo "==> writing systemd unit" +sed -e "s/CHANGE_ME_STRONG_KEY/$REG_KEY/" \ + -e "s/CHANGE_ME_TOO/$STATUS_KEY/" \ + -e "s/--port 8443/--port $PORT/" \ + "$(dirname "$0")/netdesk.service" > /etc/systemd/system/netdesk.service +systemctl daemon-reload +systemctl enable --now netdesk + +cat <nul + +echo [netdesk] starting host agent (finds server URL from its config) ... +start "NetDesk Host" "%PY%" host\netdesk_host.py --server ws://127.0.0.1:8443/ws + +timeout /t 3 /nobreak >nul +echo [netdesk] opening viewer in browser... +start http://127.0.0.1:8443/ +echo. +echo Your host ID and password are in %%APPDATA%%\NetDesk\config.json +echo (password is printed by the host on its FIRST run only; use --set-password to change) +endlocal diff --git a/scripts/start_host_hidden.vbs b/scripts/start_host_hidden.vbs new file mode 100644 index 0000000..4550f54 --- /dev/null +++ b/scripts/start_host_hidden.vbs @@ -0,0 +1,6 @@ +' Runs the NetDesk host agent with no console window. +Set sh = CreateObject("WScript.Shell") +repo = Left(WScript.ScriptFullName, InStrRev(WScript.ScriptFullName, "\scripts\") - 1) +py = repo & "\.venv\Scripts\pythonw.exe" +If Not CreateObject("Scripting.FileSystemObject").FileExists(py) Then py = "pythonw.exe" +sh.Run """" & py & """ """ & repo & "\host\netdesk_host.py""", 0, False diff --git a/scripts/uninstall_autostart.bat b/scripts/uninstall_autostart.bat new file mode 100755 index 0000000..cceb593 --- /dev/null +++ b/scripts/uninstall_autostart.bat @@ -0,0 +1,8 @@ +@echo off +rem Removes the NetDesk boot-time service. +setlocal +cd /d "%~dp0.." +set PY=%~dp0..\.venv\Scripts\python.exe +if not exist "%PY%" set PY=python +"%PY%" host\netdesk_host.py --uninstall +pause diff --git a/server/netdesk_server.py b/server/netdesk_server.py new file mode 100644 index 0000000..e2a6ccc --- /dev/null +++ b/server/netdesk_server.py @@ -0,0 +1,4263 @@ +#!/usr/bin/env python3 +""" +NetDesk Server - signaling + relay + web viewer host. + +Runs on a VPS (Linux/Windows) or locally. Stateless: nothing is stored on disk. +- Hosts register by ID and keep one WebSocket open (outbound only, NAT-friendly). +- Viewers load the web client from this server, authenticate through a + host-generated challenge/response (the server never sees the password), + then audio/video/input/file traffic is relayed opaquely between the two. +- With end-to-end encryption enabled (default), even this server cannot see + the screen: media is encrypted host->viewer with an ephemeral E2E key. + +Usage: + python netdesk_server.py [--port 8443] [--cert cert.pem --key key.pem] + [--reg-key SECRET] [--status-key SECRET] + + Without --cert the server runs plain HTTP/WS (fine for localhost/LAN). + For internet use, put a real certificate behind it (Let's Encrypt) or use + --gen-cert --domain your.domain for a self-signed one (browser warning). +""" + +import argparse +import asyncio +import json +import logging +import os +import ssl +import time +import uuid + +from aiohttp import web, WSMsgType + +log = logging.getLogger("netdesk.server") + +VERSION = "1.5.0-netdesk" +import sys +_web_base = getattr(sys, "_MEIPASS", None) or os.path.dirname(os.path.abspath(__file__)) +WEB_DIR = os.path.join(_web_base, "web") + +# ---------------------------------------------------------------- metrics + +class Stats: + def __init__(self): + self.started = time.time() + self.bytes_relayed = 0 + self.sessions_total = 0 + +STATS = Stats() + + +class Host: + """A registered host connection.""" + + def __init__(self, ws, hid, name, ver): + self.ws = ws + self.id = hid + self.name = name + self.ver = ver + self.connected_at = time.time() + self.session = None # active/pending Session + self.telemetry = {} # latest CPU/RAM/disk/battery sample (portal) + + +class Session: + def __init__(self, sid, host, viewer_ws): + self.sid = sid + self.host = host + self.viewer_ws = viewer_ws + self.active = False + self.plan = "guest" + self.vlimit = None + self.vemail = "" + self.vip = "" + self.vgeo = {} + self.started = time.time() + + def other(self, ws): + return self.viewer_ws if ws is self.host.ws else self.host.ws + + +HOSTS: dict[str, Host] = {} # id -> Host (online right now) + +# ---------------------------------------------------------------- portal state + +import secrets as _secrets +import collections + +KNOWN_HOSTS: dict[str, dict] = {} # id -> registry entry (survives disconnects, in-memory) +EVENTS = collections.deque(maxlen=200) +PORTAL_SESSIONS: set[str] = set() + + +# ---------------------------------------------------------------- brute-force throttle +# Per-(scope, source-IP) failed-attempt counter with decay + lockout, mirroring +# RustDesk's LOGIN_FAILURES model (MAX_CONSECUTIVE_FAILURES=10). A failed +# password/portal login records a failure; too many within the window locks the +# IP out for a cooldown; a success clears it. In-memory (resets on restart), +# which is fine for an abuse brake. Scopes keep account- and portal-login +# counters independent so one can't lock the other out. +_LOGIN_FAILS: dict[tuple, list] = {} # (scope, ip) -> [count, first_ts, locked_until] +LOGIN_MAX_FAILS = 10 # failures before lockout +LOGIN_WINDOW = 300.0 # seconds the count accumulates over +LOGIN_LOCKOUT = 300.0 # seconds locked out once tripped + + +def login_locked(scope: str, ip: str) -> float: + """Seconds remaining on an active lockout for (scope, ip), else 0.0.""" + rec = _LOGIN_FAILS.get((scope, ip)) + if not rec: + return 0.0 + now = time.time() + if rec[2] > now: + return rec[2] - now + # expired lockout, or a stale window → clear it + if rec[2] and rec[2] <= now: + _LOGIN_FAILS.pop((scope, ip), None) + elif now - rec[1] > LOGIN_WINDOW: + _LOGIN_FAILS.pop((scope, ip), None) + return 0.0 + + +def login_fail(scope: str, ip: str) -> None: + """Record one failed attempt; trip a lockout at LOGIN_MAX_FAILS in-window.""" + now = time.time() + rec = _LOGIN_FAILS.get((scope, ip)) + if not rec or (now - rec[1] > LOGIN_WINDOW and rec[2] <= now): + _LOGIN_FAILS[(scope, ip)] = [1, now, 0.0] + return + rec[0] += 1 + if rec[0] >= LOGIN_MAX_FAILS: + rec[2] = now + LOGIN_LOCKOUT + log_event("auth", f"lockout: {scope} from {ip} after {rec[0]} failures") + + +def login_ok(scope: str, ip: str) -> None: + """Clear the counter for (scope, ip) after a successful login.""" + _LOGIN_FAILS.pop((scope, ip), None) + + +def req_ip(req) -> str: + """Client IP, preferring the forwarded header (behind nginx req.remote is + 127.0.0.1).""" + return ((req.headers.get("X-Forwarded-For") or "").split(",")[0].strip() + or req.remote or "?") + +# ---------------------------------------------------------------- user accounts (email sign-in for viewers) +import sqlite3 +import smtplib +from email.message import EmailMessage as _MailMsg + +def _nd_data_root(): + """Directory holding persistent state (accounts.db, audit.log, files/). + `ND_DATA_DIR` overrides it — used by the Docker image to point at a mounted + volume — otherwise it's the executable/script dir, exactly as before, so the + systemd deployment is unaffected.""" + d = os.environ.get("ND_DATA_DIR") + if d: + os.makedirs(d, exist_ok=True) + return d + return (os.path.dirname(sys.executable) if getattr(sys, "frozen", False) + else os.path.dirname(os.path.abspath(__file__))) + +ACCOUNTS_DB = os.path.join(_nd_data_root(), "accounts.db") +USER_SESSIONS = {} # token -> email (cache; tokens persist in the db) + +def _acct_db(): + conn = sqlite3.connect(ACCOUNTS_DB) + conn.execute( + "CREATE TABLE IF NOT EXISTS users(id INTEGER PRIMARY KEY AUTOINCREMENT," + " email TEXT UNIQUE NOT NULL, pw_salt TEXT NOT NULL, pw_hash TEXT NOT NULL," + " verified INTEGER NOT NULL DEFAULT 0, verify_token TEXT," + " reset_token TEXT, reset_exp INTEGER, created INTEGER NOT NULL)") + # login tokens survive server restarts (browser cookie + desktop app bearer) + conn.execute( + "CREATE TABLE IF NOT EXISTS tokens(tok TEXT PRIMARY KEY, email TEXT NOT NULL," + " created INTEGER NOT NULL)") + # computers bound to an account (like RustDesk's address book / device list) + conn.execute( + "CREATE TABLE IF NOT EXISTS devices(id TEXT PRIMARY KEY, owner TEXT NOT NULL," + " name TEXT, ver TEXT, last_seen INTEGER)") + try: # WoL support (added 1.5) - MAC of the host's primary NIC + conn.execute("ALTER TABLE devices ADD COLUMN mac TEXT DEFAULT ''") + except Exception: + pass + try: # portal: hide a host from the guest/public surface + conn.execute("ALTER TABLE devices ADD COLUMN hidden INTEGER DEFAULT 0") + except Exception: + pass + try: # address book: comma-separated tags (e.g. "prod,linux") + conn.execute("ALTER TABLE devices ADD COLUMN tags TEXT DEFAULT ''") + except Exception: + pass + try: # address book: a group / folder name + conn.execute("ALTER TABLE devices ADD COLUMN grp TEXT DEFAULT ''") + except Exception: + pass + try: # RBAC: 'admin' sees/manages all devices; default 'member' + conn.execute("ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'member'") + except Exception: + pass + try: # account 2FA: base32 TOTP secret (empty = disabled) + conn.execute("ALTER TABLE users ADD COLUMN totp TEXT DEFAULT ''") + except Exception: + pass + try: # SCIM: IdP-deactivated accounts cannot sign in + conn.execute("ALTER TABLE users ADD COLUMN disabled INTEGER DEFAULT 0") + except Exception: + pass + try: # SCIM externalId (IdP correlation) + conn.execute("ALTER TABLE users ADD COLUMN scim_ext TEXT DEFAULT ''") + except Exception: + pass + # REST API keys (X-API-Key header -> owner account) + outbound webhooks + conn.execute( + "CREATE TABLE IF NOT EXISTS api_keys(key TEXT PRIMARY KEY, owner TEXT NOT NULL," + " name TEXT, created INTEGER NOT NULL, last_used INTEGER DEFAULT 0)") + conn.execute( + "CREATE TABLE IF NOT EXISTS webhooks(id INTEGER PRIMARY KEY AUTOINCREMENT," + " owner TEXT NOT NULL, url TEXT NOT NULL, events TEXT DEFAULT '*', created INTEGER NOT NULL)") + # seat/licensing: per-account cap on concurrently-registered hosts (0 = unlimited) + conn.execute( + "CREATE TABLE IF NOT EXISTS licenses(owner TEXT PRIMARY KEY, seats INTEGER NOT NULL DEFAULT 0," + " plan TEXT DEFAULT '', updated INTEGER NOT NULL DEFAULT 0)") + # MDM-lite: per-account managed policy pushed to hosts at registration + conn.execute( + "CREATE TABLE IF NOT EXISTS account_policy(owner TEXT PRIMARY KEY," + " policy TEXT NOT NULL DEFAULT '{}', updated INTEGER NOT NULL DEFAULT 0)") + # teams: shared address books / device lists across multiple operators + conn.execute( + "CREATE TABLE IF NOT EXISTS teams(id INTEGER PRIMARY KEY AUTOINCREMENT," + " name TEXT NOT NULL, created_by TEXT NOT NULL, created INTEGER NOT NULL)") + conn.execute( + "CREATE TABLE IF NOT EXISTS team_members(team_id INTEGER NOT NULL, email TEXT NOT NULL," + " role TEXT NOT NULL DEFAULT 'member', added INTEGER NOT NULL," + " PRIMARY KEY(team_id,email))") + conn.execute( + "CREATE TABLE IF NOT EXISTS team_devices(team_id INTEGER NOT NULL, device_id TEXT NOT NULL," + " added_by TEXT, added INTEGER NOT NULL, PRIMARY KEY(team_id,device_id))") + # conditional-access rules: first-match-wins allow/deny, evaluated at session.begin + conn.execute( + "CREATE TABLE IF NOT EXISTS access_rules(id INTEGER PRIMARY KEY AUTOINCREMENT," + " owner TEXT NOT NULL, priority INTEGER NOT NULL DEFAULT 100," + " action TEXT NOT NULL DEFAULT 'deny', subject_type TEXT NOT NULL DEFAULT 'any'," + " subject TEXT DEFAULT '', cidr TEXT DEFAULT '', weekdays TEXT DEFAULT ''," + " time_start INTEGER, time_end INTEGER, tz_offset INTEGER DEFAULT 0," + " reason TEXT DEFAULT '', enabled INTEGER NOT NULL DEFAULT 1," + " created_by TEXT, created INTEGER NOT NULL DEFAULT 0)") + # SCIM 2.0 provisioning tokens (Bearer) minted by an admin + conn.execute( + "CREATE TABLE IF NOT EXISTS scim_tokens(token TEXT PRIMARY KEY, label TEXT," + " created_by TEXT, created INTEGER NOT NULL DEFAULT 0, last_used INTEGER DEFAULT 0)") + # central session recording: stored files + short-lived upload tokens + conn.execute( + "CREATE TABLE IF NOT EXISTS recordings(id INTEGER PRIMARY KEY AUTOINCREMENT," + " owner TEXT, host_id TEXT, session_id TEXT, name TEXT, path TEXT, size INTEGER DEFAULT 0," + " duration INTEGER DEFAULT 0, viewer TEXT, mime TEXT, created INTEGER NOT NULL DEFAULT 0)") + conn.execute( + "CREATE TABLE IF NOT EXISTS rec_tokens(token TEXT PRIMARY KEY, host_id TEXT, owner TEXT," + " session_id TEXT, created INTEGER, exp INTEGER)") + return conn + + +# ---------------------------------------------------------------- portal intel +RECENT_VISITORS = collections.deque(maxlen=120) # recent viewer sessions (+geo) +GEO_CACHE: dict[str, dict] = {} # ip -> geo (best-effort, cached) + + +async def geo_lookup(ip: str) -> dict: + """Best-effort IP geolocation via ip-api.com, cached. Local/failed -> {}.""" + try: + import ipaddress + a = ipaddress.ip_address(ip) + if a.is_private or a.is_loopback or a.is_link_local: + return {} + except Exception: + if not ip: + return {} + if ip in GEO_CACHE: + return GEO_CACHE[ip] + GEO_CACHE[ip] = {} # mark in-flight so we don't hammer the API + try: + import aiohttp + url = ("http://ip-api.com/json/" + ip + + "?fields=status,country,countryCode,regionName,city,isp,lat,lon") + async with aiohttp.ClientSession() as s: + async with s.get(url, timeout=aiohttp.ClientTimeout(total=5)) as r: + d = await r.json() + if isinstance(d, dict) and d.get("status") == "success": + GEO_CACHE[ip] = { + "country": d.get("country", ""), "cc": d.get("countryCode", ""), + "region": d.get("regionName", ""), "city": d.get("city", ""), + "isp": d.get("isp", ""), "lat": d.get("lat"), "lon": d.get("lon"), + } + except Exception: + pass + return GEO_CACHE.get(ip, {}) + + +# ---------------------------------------------- relay tiers / nearest-relay geo +_RELAYS_CACHE = None + + +def _relays(): + """Configured relay nodes [{id,region,url,lat,lon,tier}] for nearest-relay + geo selection. Falls back to a single self-entry (this server) when --relays + is unset, so selection always returns something on a one-node deployment.""" + global _RELAYS_CACHE + if _RELAYS_CACHE is not None: + return _RELAYS_CACHE + out = [] + raw = (ARGS.relays or "").strip() if ARGS else "" + if raw: + try: + arr = json.loads(raw) + if isinstance(arr, list): + for r in arr: + if not isinstance(r, dict) or not r.get("url"): + continue + out.append({ + "id": str(r.get("id", r.get("region", "relay"))), + "region": str(r.get("region", "")), + "url": str(r["url"]), + "lat": float(r.get("lat", 0.0)), + "lon": float(r.get("lon", 0.0)), + "tier": str(r.get("tier", "")), + }) + except Exception as e: + log.warning("relays: bad --relays JSON: %s", e) + if not out: + base = ARGS.public_url.rstrip("/") if (ARGS and ARGS.public_url) else "https://netdesk.space" + ws = base.replace("https://", "wss://").replace("http://", "ws://") + "/ws" + out = [{"id": "default", "region": "eu-central", "url": ws, + "lat": 50.11, "lon": 8.68, "tier": ""}] # Frankfurt VPS + _RELAYS_CACHE = out + return out + + +def _haversine_km(lat1, lon1, lat2, lon2): + import math + r = 6371.0 + p1, p2 = math.radians(lat1), math.radians(lat2) + dphi = math.radians(lat2 - lat1) + dlmb = math.radians(lon2 - lon1) + a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlmb / 2) ** 2 + return 2 * r * math.asin(min(1.0, math.sqrt(a))) + + +def _nearest_relay(lat, lon, tier=None): + cands = _relays() + if tier: + filtered = [r for r in cands if not r.get("tier") or r["tier"] == tier] + if filtered: + cands = filtered + best, bestd = None, None + for r in cands: + d = _haversine_km(lat, lon, r["lat"], r["lon"]) + if bestd is None or d < bestd: + best, bestd = r, d + return best, bestd + + +async def api_relays(req): + """Public: the configured relay nodes (clients cache + may choose).""" + return web.json_response({"relays": _relays()}) + + +async def api_relay_best(req): + """Nearest relay for the caller. Explicit ?lat=&lon= wins; else geo-locate + the client IP; else the first/default relay. ?tier= filters by tier.""" + tier = req.query.get("tier") or None + lat = lon = None + via = "default" + try: + if "lat" in req.query and "lon" in req.query: + lat, lon = float(req.query["lat"]), float(req.query["lon"]) + via = "param" + except (TypeError, ValueError): + lat = lon = None + if lat is None: + ip = ((req.headers.get("X-Forwarded-For") or "").split(",")[0].strip() + or (req.remote or "")) + geo = await geo_lookup(ip) + if geo.get("lat") is not None and geo.get("lon") is not None: + lat, lon = float(geo["lat"]), float(geo["lon"]) + via = "geo" + relays = _relays() + if lat is None: + return web.json_response({"relay": relays[0] if relays else None, + "via": "default", "dist_km": None, "count": len(relays)}) + best, dist = _nearest_relay(lat, lon, tier) + return web.json_response({"relay": best, "via": via, + "dist_km": round(dist, 1) if dist is not None else None, + "count": len(relays)}) + + +def is_hidden(hid: str) -> bool: + try: + with _acct_db() as conn: + row = conn.execute("SELECT hidden FROM devices WHERE id=?", (hid,)).fetchone() + return bool(row and row[0]) + except Exception: + return False + + +def device_owner(hid: str) -> str: + try: + with _acct_db() as conn: + row = conn.execute("SELECT owner FROM devices WHERE id=?", (hid,)).fetchone() + return (row[0] or "") if row else "" + except Exception: + return "" + + +def set_hidden(hid: str, hidden: bool): + with _acct_db() as conn: + conn.execute("INSERT INTO devices(id,owner,name,ver,last_seen) VALUES(?,?,?,?,?) " + "ON CONFLICT(id) DO NOTHING", (hid, "", "", "", int(time.time()))) + conn.execute("UPDATE devices SET hidden=? WHERE id=?", (1 if hidden else 0, hid)) + conn.commit() + + +def _new_token(email): + tok = _secrets.token_urlsafe(32) + with _acct_db() as conn: + conn.execute("INSERT INTO tokens(tok,email,created) VALUES(?,?,?)", + (tok, email, int(time.time()))) + USER_SESSIONS[tok] = email + return tok + + +def email_for_token(tok): + if not tok: + return None + if tok in USER_SESSIONS: + return USER_SESSIONS[tok] + with _acct_db() as conn: + row = conn.execute("SELECT email FROM tokens WHERE tok=?", (tok,)).fetchone() + if row: + USER_SESSIONS[tok] = row[0] + return row[0] + return None + + +def _drop_token(tok): + USER_SESSIONS.pop(tok, None) + with _acct_db() as conn: + conn.execute("DELETE FROM tokens WHERE tok=?", (tok,)) + + +def _req_token(req): + auth = req.headers.get("Authorization", "") + if auth.lower().startswith("bearer "): + return auth[7:].strip() + return req.cookies.get("nd_user", "") + +def _pbkdf(password, salt): + import hashlib as _h + return _h.pbkdf2_hmac("sha256", password.encode(), salt, 200_000).hex() + +def _mail_html(title, body_html, link=None, link_text=None): + link_html = "" + if link: + link_html = ( + '

' + '' + link_text + "

" + '

Or paste this link: ' + link + "

") + logo = ('' + '' + '' + '') + return ( + '
' + '
' + logo + + 'NetDesk
' + '

' + title + "

" + '
' + body_html + "
" + + link_html + + '

' + "If you did not request this, ignore this email.

") + +def send_mail(to, subject, html): + if not ARGS.mail_from: + raise RuntimeError("email not configured on this server") + msg = _MailMsg() + msg["From"] = "NetDesk <" + ARGS.mail_from + ">" + msg["To"] = to + msg["Subject"] = subject + msg.set_content("Your mail client does not support HTML.") + msg.add_alternative(html, subtype="html") + host = ARGS.smtp_host or "127.0.0.1" + with smtplib.SMTP(host, 25, timeout=20) as smtp: + smtp.send_message(msg) + +def user_from_cookie(req): + """Signed-in account email from the nd_user cookie or a Bearer token.""" + return email_for_token(_req_token(req)) + + +def public_base(req): + if ARGS.public_url: + return ARGS.public_url.rstrip("/") + proto = req.headers.get("X-Forwarded-Proto") or req.scheme + return f"{proto}://{req.host}" + + +async def _json_body(req): + try: + body = await req.json() + return body if isinstance(body, dict) else {} + except Exception: + return {} + + +def _login_response(email, extra=None): + tok = _new_token(email) + d = {"ok": True, "email": email, "token": tok, + "owner": bool(ARGS.owner_email and email.lower() == ARGS.owner_email.lower())} + d.update(extra or {}) + resp = web.json_response(d) + resp.set_cookie("nd_user", tok, httponly=True, samesite="Lax", max_age=365 * 86400) + return resp + + +# ------------------------------------------------- TOTP (account + portal 2FA) +# RFC 6238 (HMAC-SHA1, 30s step, 6 digits), pure-stdlib so there is no pyotp dep. +def _totp_new_secret(): + import base64 as _b64 + return _b64.b32encode(os.urandom(20)).decode("ascii").rstrip("=") + + +def _b32_decode(x): + import base64 as _b64 + x = (x or "").strip().replace(" ", "").upper() + x += "=" * ((8 - len(x) % 8) % 8) + return _b64.b32decode(x) + + +def _totp_at(secret_b32, counter): + import hmac as _hm, hashlib as _hl, struct as _st + key = _b32_decode(secret_b32) + h = _hm.new(key, _st.pack(">Q", counter), _hl.sha1).digest() + o = h[-1] & 0x0F + num = _st.unpack(">I", h[o:o + 4])[0] & 0x7FFF_FFFF + return "%06d" % (num % 1_000_000) + + +def _totp_verify(secret_b32, code, step=30, skew=1): + code = (code or "").strip() + if not secret_b32 or not code.isdigit() or len(code) != 6: + return False + counter = int(time.time()) // step + for d in range(-skew, skew + 1): + try: + if _secrets.compare_digest(_totp_at(secret_b32, counter + d), code): + return True + except Exception: + return False + return False + + +def _otpauth_uri(account, secret_b32, issuer="NetDesk"): + from urllib.parse import quote + label = quote(issuer + ":" + account) + return ("otpauth://totp/" + label + "?secret=" + secret_b32 + + "&issuer=" + quote(issuer) + "&algorithm=SHA1&digits=6&period=30") + + +async def auth_signup(req): + body = await _json_body(req) + email = str(body.get("email", "")).strip().lower() + pw = str(body.get("password", "")) + if "@" not in email or "." not in email.split("@")[-1] or len(email) < 5: + return web.json_response({"error": "enter a valid email"}, status=400) + if len(pw) < 8: + return web.json_response({"error": "password needs 8+ characters"}, status=400) + salt = _secrets.token_bytes(16) + vtok = _secrets.token_urlsafe(24) + now = int(time.time()) + with _acct_db() as conn: + try: + conn.execute( + "INSERT INTO users(email,pw_salt,pw_hash,verified,verify_token,created) " + "VALUES(?,?,?,?,?,?)", (email, salt.hex(), _pbkdf(pw, salt), 0, vtok, now)) + except sqlite3.IntegrityError: + return web.json_response( + {"error": "email already registered - sign in or reset password"}, status=409) + log_event("auth", "signup: " + email) + # verification mail is best-effort and never blocks sign-up (RustDesk-style: + # the account works immediately; verifying only confirms the address) + if ARGS.mail_from: + link = public_base(req) + "/api/auth/verify?token=" + vtok + + def _send(): + try: + send_mail(email, "Verify your NetDesk account", + _mail_html("Confirm your email", + "Welcome to NetDesk. Your account is active - click below " + "to confirm this email address.", link, "Verify my email")) + except Exception as e: + log.warning("verify mail to %s failed: %s", email, e) + asyncio.get_running_loop().run_in_executor(None, _send) + return _login_response(email, {"msg": "account created - you are signed in"}) + +async def auth_verify(req): + tok = req.query.get("token", "") + with _acct_db() as conn: + row = conn.execute("SELECT id FROM users WHERE verify_token=? AND verified=0", (tok,)).fetchone() + if not row: + return web.Response(status=400, text="invalid or used link") + conn.execute("UPDATE users SET verified=1, verify_token=NULL WHERE id=?", (row[0],)) + log_event("auth", "email verified") + raise web.HTTPFound(public_base(req) + "/app?verified=1") + +# ---------------------------------------------------------------- LDAP / AD +# Minimal pure-stdlib LDAPv3 simple-bind authentication (no ldap3 dependency). +# Enable with --ldap-url; the user's own credentials are bound against the +# directory, and a local account is auto-provisioned on first success. +def _ber_len(n): + if n < 0x80: + return bytes([n]) + out = b"" + while n: + out = bytes([n & 0xFF]) + out + n >>= 8 + return bytes([0x80 | len(out)]) + out + + +def _ber_tlv(tag, value): + return bytes([tag]) + _ber_len(len(value)) + value + + +def _ber_read_tlv(buf, off=0): + """Read one TLV at buf[off:] -> (tag, value_bytes, next_off).""" + tag = buf[off] + off += 1 + l0 = buf[off] + off += 1 + if l0 < 0x80: + length = l0 + else: + nb = l0 & 0x7F + length = int.from_bytes(buf[off:off + nb], "big") + off += nb + return tag, buf[off:off + length], off + length + + +def _recvn(sock, n): + out = b"" + while len(out) < n: + chunk = sock.recv(n - len(out)) + if not chunk: + raise IOError("LDAP connection closed") + out += chunk + return out + + +def _ldap_read_result_code(sock): + """Read one LDAPMessage, return the BindResponse resultCode (int).""" + tag = _recvn(sock, 1)[0] + l0 = _recvn(sock, 1)[0] + if l0 < 0x80: + length = l0 + else: + nb = l0 & 0x7F + length = int.from_bytes(_recvn(sock, nb), "big") + body = _recvn(sock, length) # SEQUENCE contents: messageID + protocolOp + _t, _mid, off = _ber_read_tlv(body, 0) # messageID INTEGER + optag, opval, _ = _ber_read_tlv(body, off) # protocolOp (BindResponse 0x61) + rtag, rval, _ = _ber_read_tlv(opval, 0) # resultCode ENUMERATED (0x0a) + return rval[0] if rval else -1 + + +def _ldap_bind_dn(email): + tmpl = (ARGS.ldap_bind_template or "{email}") + local = email.split("@")[0] + domain = email.split("@", 1)[1] if "@" in email else "" + return tmpl.replace("{user}", local).replace("{email}", email).replace("{domain}", domain) + + +def _ldap_simple_bind(host, port, use_tls, dn, password, timeout=6, insecure=False): + """Perform an LDAPv3 simple bind. Returns True iff resultCode == success(0).""" + import socket, ssl + version = _ber_tlv(0x02, bytes([3])) # version INTEGER 3 + name = _ber_tlv(0x04, dn.encode("utf-8")) # LDAPDN + auth = _ber_tlv(0x80, password.encode("utf-8")) # [0] simple + bindreq = _ber_tlv(0x60, version + name + auth) # [APPLICATION 0] + msg = _ber_tlv(0x30, _ber_tlv(0x02, bytes([1])) + bindreq) + s = socket.create_connection((host, port), timeout=timeout) + try: + if use_tls: + ctx = ssl.create_default_context() + if insecure: + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + s = ctx.wrap_socket(s, server_hostname=host) + s.settimeout(timeout) + s.sendall(msg) + return _ldap_read_result_code(s) == 0 + finally: + try: + s.close() + except Exception: + pass + + +def _ldap_login_sync(email, pw): + """Blocking LDAP auth (run in an executor). Empty password is always + rejected so an LDAP 'unauthenticated bind' can never pass as success.""" + if not ARGS.ldap_url or not email or not pw: + return False + try: + import urllib.parse + u = urllib.parse.urlparse(ARGS.ldap_url) + use_tls = (u.scheme == "ldaps") + host = u.hostname + port = u.port or (636 if use_tls else 389) + if not host: + return False + dn = _ldap_bind_dn(email) + return _ldap_simple_bind(host, port, use_tls, dn, pw, + ARGS.ldap_timeout or 6, bool(ARGS.ldap_insecure)) + except Exception as e: + log.warning("LDAP bind error for %s: %s", email, e) + return False + + +def _ldap_provision(email): + """Ensure a local shadow account exists for an LDAP-authenticated user.""" + now = int(time.time()) + with _acct_db() as conn: + if not conn.execute("SELECT 1 FROM users WHERE lower(email)=lower(?)", (email,)).fetchone(): + salt = _secrets.token_bytes(16) + pw = _secrets.token_urlsafe(24) # unused; LDAP is the authority + role = (ARGS.ldap_default_role or "member") + conn.execute("INSERT INTO users(email,pw_salt,pw_hash,verified,created,role) " + "VALUES(?,?,?,1,?,?)", (email, salt.hex(), _pbkdf(pw, salt), now, role)) + conn.commit() + log_event("auth", f"LDAP provisioned account {email}") + + +async def _ldap_try(email, pw): + """True if LDAP is configured and the credentials bind successfully.""" + if not ARGS.ldap_url: + return False + ok = await asyncio.get_running_loop().run_in_executor(None, _ldap_login_sync, email, pw) + if ok: + _ldap_provision(email) + return ok + + +async def auth_login(req): + ip = req_ip(req) + locked = login_locked("account", ip) + if locked: + return web.json_response( + {"error": f"too many attempts — try again in {int(locked) + 1}s"}, + status=429) + body = await _json_body(req) + email = str(body.get("email", "")).strip().lower() + pw = str(body.get("password", "")) + with _acct_db() as conn: + row = conn.execute("SELECT pw_salt,pw_hash,verified,totp," + "COALESCE(disabled,0) FROM users WHERE email=?", (email,)).fetchone() + local_ok = bool(row) and _secrets.compare_digest(_pbkdf(pw, bytes.fromhex(row[0])), row[1]) + ldap_ok = False + if not local_ok: + # LDAP / Active Directory: bind the user's own credentials; on success + # a local shadow account is auto-provisioned and we re-read the row. + ldap_ok = await _ldap_try(email, pw) + if ldap_ok: + with _acct_db() as conn: + row = conn.execute("SELECT pw_salt,pw_hash,verified,totp," + "COALESCE(disabled,0) FROM users WHERE email=?", (email,)).fetchone() + if not (local_ok or ldap_ok): + login_fail("account", ip) + await asyncio.sleep(0.4) + return web.json_response({"error": "wrong email or password"}, status=401) + if row and row[4]: # SCIM/IdP-deactivated account + return web.json_response({"error": "this account has been deactivated"}, status=403) + # Account 2FA: when enrolled, a valid TOTP code is also required. The client + # re-POSTs with "totp" after seeing the twofa flag. + sec = (row[3] if len(row) > 3 else "") or "" + if sec: + code = str(body.get("totp", "")).strip() + if not code: + return web.json_response({"error": "2FA code required", "twofa": True}, status=401) + if not _totp_verify(sec, code): + login_fail("account", ip) + await asyncio.sleep(0.4) + return web.json_response({"error": "invalid 2FA code", "twofa": True}, status=401) + login_ok("account", ip) + log_event("auth", "signed in: " + email + (" (ldap)" if ldap_ok else "") + (" (app)" if body.get("app") else "")) + return _login_response(email) + +async def auth_me(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + owner = bool(ARGS.owner_email and email.lower() == ARGS.owner_email.lower()) + return web.json_response({"email": email, "owner": owner, "plan": plan_for(email)[0], + "limit": plan_for(email)[1] or 0}) + +async def auth_logout(req): + _drop_token(_req_token(req)) + resp = web.json_response({"ok": True}) + resp.del_cookie("nd_user") + return resp + + +# ------------------------------------------------- account 2FA (TOTP) endpoints +async def auth_2fa_status(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + with _acct_db() as conn: + row = conn.execute("SELECT totp FROM users WHERE email=?", (email,)).fetchone() + return web.json_response({"enabled": bool(row and row[0])}) + + +async def auth_2fa_setup(req): + """Generate a NEW (pending) secret + otpauth URI. Not saved until enable + confirms a code, so the client holds the secret between setup and enable.""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + secret = _totp_new_secret() + return web.json_response({"secret": secret, "otpauth": _otpauth_uri(email, secret)}) + + +async def auth_2fa_enable(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + secret = str(body.get("secret", "")).strip() + code = str(body.get("code", "")).strip() + if not secret or not _totp_verify(secret, code): + return web.json_response({"error": "invalid code"}, status=400) + with _acct_db() as conn: + conn.execute("UPDATE users SET totp=? WHERE email=?", (secret, email)) + conn.commit() + log_event("auth", "2FA enabled: " + email) + return web.json_response({"ok": True, "enabled": True}) + + +async def auth_2fa_disable(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + with _acct_db() as conn: + row = conn.execute("SELECT totp FROM users WHERE email=?", (email,)).fetchone() + sec = (row[0] if row else "") or "" + if sec and not _totp_verify(sec, str(body.get("code", "")).strip()): + return web.json_response({"error": "invalid code"}, status=400) + with _acct_db() as conn: + conn.execute("UPDATE users SET totp='' WHERE email=?", (email,)) + conn.commit() + log_event("auth", "2FA disabled: " + email) + return web.json_response({"ok": True, "enabled": False}) + + +# ------------------------------------------------- SSO: OpenID Connect (OIDC) +# Google / Azure / Okta / any OIDC provider. Configure with --oidc (or ND_OIDC): +# {"google":{"issuer":"https://accounts.google.com","client_id":"...","client_secret":"..."}} +# Discovery (/.well-known/openid-configuration) gives the authorize/token/jwks +# endpoints; the ID token is verified (RS256 via JWKS + iss/aud/exp/nonce), then +# the account is linked by email. PKCE (S256) + state/nonce guard the flow. +_OIDC_STATE = {} # state -> (provider, nonce, pkce_verifier, ts) +_OIDC_DISCO = {} # issuer -> (doc, ts) +_OIDC_JWKS = {} # jwks_uri -> (keys_by_kid, ts) + + +def _oidc_providers(): + raw = getattr(ARGS, "oidc", "") or os.environ.get("ND_OIDC", "") + try: + return json.loads(raw) if raw else {} + except Exception: + return {} + + +def _b64url_decode(s): + import base64 + s += "=" * (-len(s) % 4) + return base64.urlsafe_b64decode(s) + + +async def _http_json(url, method="GET", data=None): + import aiohttp + async with aiohttp.ClientSession() as s: + if method == "POST": + cm = s.post(url, data=data, timeout=aiohttp.ClientTimeout(total=8)) + else: + cm = s.get(url, timeout=aiohttp.ClientTimeout(total=8)) + async with cm as r: + return await r.json(content_type=None) + + +async def _oidc_discovery(issuer): + now = time.time() + c = _OIDC_DISCO.get(issuer) + if c and now - c[1] < 3600: + return c[0] + doc = await _http_json(issuer.rstrip("/") + "/.well-known/openid-configuration") + _OIDC_DISCO[issuer] = (doc, now) + return doc + + +async def _oidc_jwks(jwks_uri): + now = time.time() + c = _OIDC_JWKS.get(jwks_uri) + if c and now - c[1] < 3600: + return c[0] + doc = await _http_json(jwks_uri) + keys = {k.get("kid"): k for k in doc.get("keys", [])} + _OIDC_JWKS[jwks_uri] = (keys, now) + return keys + + +def _verify_oidc_jwt(id_token, jwks, issuer, client_id, nonce): + """Verify an OIDC ID token: RS256 signature (via JWKS) + iss/aud/exp/nonce. + Returns the claims dict, or raises ValueError.""" + from cryptography.hazmat.primitives.asymmetric import rsa, padding + from cryptography.hazmat.primitives import hashes + parts = id_token.split(".") + if len(parts) != 3: + raise ValueError("malformed") + header = json.loads(_b64url_decode(parts[0])) + payload = json.loads(_b64url_decode(parts[1])) + sig = _b64url_decode(parts[2]) + kid = header.get("kid") + jwk = jwks.get(kid) or (next(iter(jwks.values())) if len(jwks) == 1 else None) + if not jwk: + raise ValueError("no JWKS key") + n = int.from_bytes(_b64url_decode(jwk["n"]), "big") + e = int.from_bytes(_b64url_decode(jwk["e"]), "big") + pub = rsa.RSAPublicNumbers(e, n).public_key() + try: + pub.verify(sig, (parts[0] + "." + parts[1]).encode(), padding.PKCS1v15(), hashes.SHA256()) + except Exception: + raise ValueError("bad signature") + if issuer and payload.get("iss", "").rstrip("/") != issuer.rstrip("/"): + raise ValueError("bad iss") + aud = payload.get("aud") + if client_id and client_id not in (aud if isinstance(aud, list) else [aud]): + raise ValueError("bad aud") + if float(payload.get("exp", 0)) < time.time(): + raise ValueError("expired") + if nonce and payload.get("nonce") != nonce: + raise ValueError("bad nonce") + return payload + + +async def auth_oidc_start(req): + """Begin SSO: redirect the browser to the provider's authorize endpoint.""" + provider = req.match_info.get("provider", "") + cfg = _oidc_providers().get(provider) + if not cfg: + return web.json_response({"error": "SSO provider not configured"}, status=404) + import hashlib + import base64 as _b + from urllib.parse import urlencode + disco = await _oidc_discovery(cfg["issuer"]) + state = _secrets.token_urlsafe(24) + nonce = _secrets.token_urlsafe(16) + verifier = _secrets.token_urlsafe(48) + challenge = _b.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() + now = time.time() + _OIDC_STATE[state] = (provider, nonce, verifier, now) + for k in [k for k, v in _OIDC_STATE.items() if now - v[3] > 600]: + _OIDC_STATE.pop(k, None) + redirect_uri = public_base(req) + f"/api/auth/oidc/{provider}/callback" + url = disco["authorization_endpoint"] + "?" + urlencode({ + "client_id": cfg["client_id"], "redirect_uri": redirect_uri, + "response_type": "code", "scope": "openid email profile", + "state": state, "nonce": nonce, + "code_challenge": challenge, "code_challenge_method": "S256", + }) + raise web.HTTPFound(url) + + +async def auth_oidc_callback(req): + """Finish SSO: exchange the code, verify the ID token, link the account.""" + provider = req.match_info.get("provider", "") + cfg = _oidc_providers().get(provider) + if not cfg: + return web.json_response({"error": "SSO provider not configured"}, status=404) + code = req.query.get("code", "") + state = req.query.get("state", "") + st = _OIDC_STATE.pop(state, None) + if not st or st[0] != provider: + return web.json_response({"error": "invalid state"}, status=400) + _, nonce, verifier, _ = st + disco = await _oidc_discovery(cfg["issuer"]) + redirect_uri = public_base(req) + f"/api/auth/oidc/{provider}/callback" + tok = await _http_json(disco["token_endpoint"], "POST", { + "grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri, + "client_id": cfg["client_id"], "client_secret": cfg.get("client_secret", ""), + "code_verifier": verifier, + }) + id_token = tok.get("id_token") + if not id_token: + return web.json_response({"error": "no id_token from provider"}, status=502) + jwks = await _oidc_jwks(disco["jwks_uri"]) + try: + payload = _verify_oidc_jwt(id_token, jwks, cfg["issuer"], cfg["client_id"], nonce) + except ValueError as ex: + return web.json_response({"error": f"id token invalid: {ex}"}, status=401) + email = (payload.get("email") or "").strip().lower() + if not email: + return web.json_response({"error": "no email in id token"}, status=401) + with _acct_db() as conn: + row = conn.execute("SELECT email FROM users WHERE email=?", (email,)).fetchone() + if not row: + salt = _secrets.token_bytes(16) + pw = _secrets.token_urlsafe(24) # random; SSO users never use a password + conn.execute("INSERT INTO users(email,pw_salt,pw_hash,verified,created) VALUES(?,?,?,1,?)", + (email, salt.hex(), _pbkdf(pw, salt), int(time.time()))) + log_event("auth", f"SSO sign-in ({provider}): {email}") + tokv = _new_token(email) + resp = web.HTTPFound(public_base(req) + "/app") + resp.set_cookie("nd_user", tokv, httponly=True, samesite="Lax", max_age=365 * 86400) + return resp + + +# ------------------------------------------- REST API (X-API-Key) + webhooks +def owner_from_api_key(req): + """Account email behind an X-API-Key header, or None. Touches last_used.""" + key = req.headers.get("X-API-Key", "").strip() + if not key: + return None + with _acct_db() as conn: + row = conn.execute("SELECT owner FROM api_keys WHERE key=?", (key,)).fetchone() + if row: + conn.execute("UPDATE api_keys SET last_used=? WHERE key=?", (int(time.time()), key)) + return row[0] + return None + + +async def _post_webhook(url, body): + try: + import aiohttp + async with aiohttp.ClientSession() as s: + await s.post(url, data=body, headers={"Content-Type": "application/json"}, + timeout=aiohttp.ClientTimeout(total=5)) + except Exception: + pass + + +def fire_webhook(owner, event, payload): + """Deliver an event (connect/disconnect/file/test) to the owner's webhooks + that subscribe to it. Fire-and-forget on the running loop.""" + if not owner: + return + try: + with _acct_db() as conn: + hooks = conn.execute("SELECT url,events FROM webhooks WHERE owner=?", (owner,)).fetchall() + except Exception: + return + urls = [u for (u, ev) in hooks + if not ev or ev == "*" or event in [e.strip() for e in ev.split(",")]] + if not urls: + return + body = json.dumps({"event": event, "ts": int(time.time()), **payload}) + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + for u in urls: + loop.create_task(_post_webhook(u, body)) + + +def _device_owner(hid): + with _acct_db() as conn: + row = conn.execute("SELECT owner FROM devices WHERE id=?", (hid,)).fetchone() + return row[0] if row else None + + +# ---- seat/licensing + MDM-lite policy (relay admission + central push) ---- +# Known policy keys the native host honors (viewer/src/host_ctl.rs apply_policy): +# server, reg_key, allow_ips, totp (strings), lock_on_end, approve_click (bools), +# perms{input,clipboard,files,audio}. The server stores + pushes the SAME json an +# admin would otherwise drop at %ProgramData%/NetDesk/policy.json on each machine. +_POLICY_KEYS = {"server", "reg_key", "allow_ips", "totp", + "lock_on_end", "approve_click", "perms", "record_required"} + + +def _owner_seats(owner): + """Max concurrently-registered hosts for an account; 0 = unlimited.""" + if not owner: + return 0 + try: + with _acct_db() as conn: + row = conn.execute("SELECT seats FROM licenses WHERE lower(owner)=lower(?)", + (owner,)).fetchone() + return int(row[0]) if row and row[0] else 0 + except Exception: + return 0 + + +def _owner_online_hosts(owner, exclude=None): + """Set of currently-connected host IDs owned by `owner` (minus `exclude`).""" + if not owner: + return set() + try: + with _acct_db() as conn: + rows = conn.execute("SELECT id FROM devices WHERE lower(owner)=lower(?)", + (owner,)).fetchall() + except Exception: + return set() + owned = {r[0] for r in rows} + out = set() + for hid in list(HOSTS.keys()): + if hid == exclude or hid not in owned: + continue + h = HOSTS.get(hid) + if h is None: + continue + try: + if h.ws.closed: + continue + except Exception: + pass + out.add(hid) + return out + + +def _seat_ok(owner, hid): + """True if admitting host `hid` for `owner` stays within the seat limit. + Re-registration of an already-online hid keeps its own seat (exclude=hid).""" + seats = _owner_seats(owner) + if seats <= 0: + return True + return len(_owner_online_hosts(owner, exclude=hid)) < seats + + +def _valid_policy(obj): + """Drop unrecognized keys so a push can't inject junk the host ignores anyway.""" + if not isinstance(obj, dict): + return {} + return {k: v for k, v in obj.items() if k in _POLICY_KEYS} + + +def _account_policy(owner): + """Owner's MDM-lite policy dict (empty if unset).""" + if not owner: + return {} + try: + with _acct_db() as conn: + row = conn.execute("SELECT policy FROM account_policy WHERE lower(owner)=lower(?)", + (owner,)).fetchone() + return json.loads(row[0]) if row and row[0] else {} + except Exception: + return {} + + +def _set_account_policy(owner, policy): + """Store a validated policy for `owner`; returns what was saved.""" + pj = json.dumps(_valid_policy(policy)) + with _acct_db() as conn: + conn.execute("INSERT INTO account_policy(owner,policy,updated) VALUES(?,?,?) " + "ON CONFLICT(owner) DO UPDATE SET policy=excluded.policy," + " updated=excluded.updated", + ((owner or "").strip().lower(), pj, int(time.time()))) + conn.commit() + return json.loads(pj) + + +# ---- API-key management (session-authed, from the logged-in portal) ---- +async def api_keys_create(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + name = str(body.get("name", "")).strip()[:64] or "key" + key = "ndk_" + _secrets.token_urlsafe(32) + with _acct_db() as conn: + conn.execute("INSERT INTO api_keys(key,owner,name,created) VALUES(?,?,?,?)", + (key, email, name, int(time.time()))) + log_event("apikey", f"{email} created API key '{name}'") + return web.json_response({"ok": True, "key": key, "name": name}) # shown once + + +async def api_keys_list(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + with _acct_db() as conn: + rows = conn.execute("SELECT key,name,created,last_used FROM api_keys WHERE owner=? " + "ORDER BY created DESC", (email,)).fetchall() + out = [{"name": n, "created": c, "last_used": lu, "key_preview": k[:12] + "…" + k[-4:]} + for (k, n, c, lu) in rows] + return web.json_response({"keys": out}) + + +async def api_keys_revoke(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + k = str(body.get("key", "")).strip() + with _acct_db() as conn: + if k.startswith("ndk_") and "…" not in k: + cur = conn.execute("DELETE FROM api_keys WHERE key=? AND owner=?", (k, email)) + else: + pref = k.split("…")[0] + cur = conn.execute("DELETE FROM api_keys WHERE owner=? AND key LIKE ?", (email, pref + "%")) + return web.json_response({"ok": True, "revoked": cur.rowcount}) + + +# ---- /api/v1 (X-API-Key authed REST API) ---- +async def v1_devices(req): + owner = owner_from_api_key(req) + if not owner: + return web.json_response({"error": "invalid or missing X-API-Key"}, status=401) + with _acct_db() as conn: + rows = conn.execute("SELECT id,name,ver,last_seen,tags,grp FROM devices WHERE owner=? " + "ORDER BY name", (owner,)).fetchall() + out = [] + for hid, name, ver, seen, tags, grp in rows: + h = HOSTS.get(hid) + out.append({"id": hid, "name": name or hid, "ver": ver or "", + "online": h is not None and not h.ws.closed, + "tags": [t.strip() for t in (tags or "").split(",") if t.strip()], + "group": grp or "", "last_seen": seen or 0}) + return web.json_response({"devices": out}) + + +async def v1_webhooks_list(req): + owner = owner_from_api_key(req) + if not owner: + return web.json_response({"error": "invalid or missing X-API-Key"}, status=401) + with _acct_db() as conn: + rows = conn.execute("SELECT id,url,events,created FROM webhooks WHERE owner=? ORDER BY id", + (owner,)).fetchall() + return web.json_response({"webhooks": [{"id": i, "url": u, "events": ev, "created": c} + for (i, u, ev, c) in rows]}) + + +async def v1_webhooks_add(req): + owner = owner_from_api_key(req) + if not owner: + return web.json_response({"error": "invalid or missing X-API-Key"}, status=401) + body = await _json_body(req) + url = str(body.get("url", "")).strip() + if not (url.startswith("http://") or url.startswith("https://")): + return web.json_response({"error": "url must be http(s)"}, status=400) + events = str(body.get("events", "*")).strip() or "*" + with _acct_db() as conn: + cur = conn.execute("INSERT INTO webhooks(owner,url,events,created) VALUES(?,?,?,?)", + (owner, url, events, int(time.time()))) + wid = cur.lastrowid + log_event("webhook", f"{owner} added webhook {url} [{events}]") + return web.json_response({"ok": True, "id": wid, "url": url, "events": events}) + + +async def v1_webhooks_remove(req): + owner = owner_from_api_key(req) + if not owner: + return web.json_response({"error": "invalid or missing X-API-Key"}, status=401) + body = await _json_body(req) + with _acct_db() as conn: + cur = conn.execute("DELETE FROM webhooks WHERE id=? AND owner=?", (body.get("id"), owner)) + return web.json_response({"ok": True, "removed": cur.rowcount}) + + +async def v1_webhooks_test(req): + owner = owner_from_api_key(req) + if not owner: + return web.json_response({"error": "invalid or missing X-API-Key"}, status=401) + fire_webhook(owner, "test", {"message": "NetDesk webhook test", "owner": owner}) + return web.json_response({"ok": True, "fired": "test"}) + +def _user_role(conn, email): + """RBAC role of an account ('admin' or 'member').""" + row = conn.execute("SELECT role FROM users WHERE email=?", (email,)).fetchone() + return row[0] if row and row[0] else "member" + + +async def api_devices(req): + """Computers bound to the signed-in account (address book), with live online + state + tags/group. Filters: ?tag= / ?group=. An admin may pass + ?scope=all to list every account's devices (RBAC).""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + tag = req.query.get("tag", "").strip().lower() + group = req.query.get("group", "").strip() + scope_all = req.query.get("scope", "") == "all" + with _acct_db() as conn: + role = _user_role(conn, email) + admin_all = scope_all and role == "admin" + if admin_all: + rows = conn.execute("SELECT id,name,ver,last_seen,mac,tags,grp,owner FROM devices " + "ORDER BY grp,name").fetchall() + else: + rows = conn.execute("SELECT id,name,ver,last_seen,mac,tags,grp,owner FROM devices " + "WHERE owner=? ORDER BY grp,name", (email,)).fetchall() + out = [] + groups = set() + for hid, name, ver, seen, mac, tags, grp, owner in rows: + tag_list = [t.strip() for t in (tags or "").split(",") if t.strip()] + if tag and tag not in [t.lower() for t in tag_list]: + continue + if group and (grp or "") != group: + continue + groups.add(grp or "") + h = HOSTS.get(hid) + out.append({"id": hid, "name": name or hid, "ver": ver or "", + "online": h is not None and not h.ws.closed, + "busy": bool(h and h.session is not None), + "mac": mac or "", "tags": tag_list, "group": grp or "", + "owner": owner if admin_all else None, + "last_seen": seen or 0}) + # also surface devices shared into any team the user belongs to (shared + # address book) - deduped vs the user's own devices, flagged shared + team. + if not admin_all: + have = {d["id"] for d in out} + with _acct_db() as tconn: + trows = tconn.execute( + "SELECT d.id,d.name,d.ver,d.last_seen,d.mac,d.tags,d.grp,d.owner,t.name " + "FROM team_members m JOIN team_devices td ON td.team_id=m.team_id " + "JOIN devices d ON d.id=td.device_id JOIN teams t ON t.id=m.team_id " + "WHERE lower(m.email)=lower(?) ORDER BY t.name,d.grp,d.name", (email,)).fetchall() + for hid, name, ver, seen, mac, tags, grp, owner, tname in trows: + if hid in have: + continue + have.add(hid) + tag_list = [t.strip() for t in (tags or "").split(",") if t.strip()] + if tag and tag not in [t.lower() for t in tag_list]: + continue + if group and (grp or "") != group: + continue + groups.add(grp or "") + h = HOSTS.get(hid) + out.append({"id": hid, "name": name or hid, "ver": ver or "", + "online": h is not None and not h.ws.closed, + "busy": bool(h and h.session is not None), + "mac": mac or "", "tags": tag_list, "group": grp or "", + "owner": owner, "shared": True, "team": tname, + "last_seen": seen or 0}) + return web.json_response({"devices": out, "role": role, + "groups": sorted(g for g in groups if g)}) + + +# ===================== teams (shared address books) ===================== +def _user_teams(conn, email): + """[(team_id, team_name, my_role)] for every team the account belongs to.""" + return conn.execute( + "SELECT t.id, t.name, m.role FROM teams t JOIN team_members m ON m.team_id=t.id " + "WHERE lower(m.email)=lower(?) ORDER BY t.name", (email,)).fetchall() + + +def _team_role(conn, team_id, email): + row = conn.execute("SELECT role FROM team_members WHERE team_id=? AND lower(email)=lower(?)", + (team_id, email)).fetchone() + return row[0] if row else None + + +def _can_manage_team(conn, team_id, email): + """True if the caller may administer the team: its owner, or an account admin.""" + if _user_role(conn, email) == "admin": + return True + return _team_role(conn, team_id, email) == "owner" + + +def _team_id_arg(v): + try: + return int(v) + except (TypeError, ValueError): + return None + + +async def api_teams(req): + """Teams the signed-in account belongs to (shared address books).""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + with _acct_db() as conn: + rows = _user_teams(conn, email) + out = [] + for tid, name, myrole in rows: + nm = conn.execute("SELECT COUNT(*) FROM team_members WHERE team_id=?", (tid,)).fetchone()[0] + nd = conn.execute("SELECT COUNT(*) FROM team_devices WHERE team_id=?", (tid,)).fetchone()[0] + cb = conn.execute("SELECT created_by, created FROM teams WHERE id=?", (tid,)).fetchone() + out.append({"id": tid, "name": name, "role": myrole, "members": nm, "devices": nd, + "created_by": cb[0] if cb else "", "created": cb[1] if cb else 0}) + return web.json_response({"teams": out}) + + +async def api_team_create(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + name = str(body.get("name", "")).strip()[:80] + if not name: + return web.json_response({"error": "team name required"}, status=400) + now = int(time.time()) + with _acct_db() as conn: + cur = conn.execute("INSERT INTO teams(name,created_by,created) VALUES(?,?,?)", (name, email, now)) + tid = cur.lastrowid + conn.execute("INSERT INTO team_members(team_id,email,role,added) VALUES(?,?,?,?)", + (tid, email, "owner", now)) + conn.commit() + log_event("team", f"{email} created team '{name}' (#{tid})", email) + return web.json_response({"ok": True, "id": tid, "name": name}) + + +async def api_team_rename(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + tid = _team_id_arg(body.get("team_id")) + name = str(body.get("name", "")).strip()[:80] + if tid is None or not name: + return web.json_response({"error": "team_id + name required"}, status=400) + with _acct_db() as conn: + if not _can_manage_team(conn, tid, email): + return web.json_response({"error": "not a team owner"}, status=403) + conn.execute("UPDATE teams SET name=? WHERE id=?", (name, tid)) + conn.commit() + return web.json_response({"ok": True, "id": tid, "name": name}) + + +async def api_team_delete(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + tid = _team_id_arg(body.get("team_id")) + if tid is None: + return web.json_response({"error": "team_id required"}, status=400) + with _acct_db() as conn: + if not _can_manage_team(conn, tid, email): + return web.json_response({"error": "not a team owner"}, status=403) + conn.execute("DELETE FROM team_members WHERE team_id=?", (tid,)) + conn.execute("DELETE FROM team_devices WHERE team_id=?", (tid,)) + conn.execute("DELETE FROM teams WHERE id=?", (tid,)) + conn.commit() + log_event("team", f"{email} deleted team #{tid}", email) + return web.json_response({"ok": True}) + + +async def api_team_members(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + tid = _team_id_arg(req.query.get("team_id")) + if tid is None: + return web.json_response({"error": "team_id required"}, status=400) + with _acct_db() as conn: + if _team_role(conn, tid, email) is None and _user_role(conn, email) != "admin": + return web.json_response({"error": "not a team member"}, status=403) + rows = conn.execute("SELECT email, role, added FROM team_members WHERE team_id=? " + "ORDER BY role DESC, email", (tid,)).fetchall() + can = _can_manage_team(conn, tid, email) + return web.json_response({"members": [{"email": e, "role": r, "added": a} for (e, r, a) in rows], + "can_manage": can}) + + +async def api_team_member_add(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + tid = _team_id_arg(body.get("team_id")) + target = str(body.get("email", "")).strip().lower() + role = str(body.get("role", "member")).strip().lower() + if role not in ("owner", "member"): + role = "member" + if tid is None or not target: + return web.json_response({"error": "team_id + email required"}, status=400) + now = int(time.time()) + with _acct_db() as conn: + if not _can_manage_team(conn, tid, email): + return web.json_response({"error": "not a team owner"}, status=403) + if not conn.execute("SELECT 1 FROM users WHERE lower(email)=lower(?)", (target,)).fetchone(): + return web.json_response({"error": "no NetDesk account with that email"}, status=404) + conn.execute("INSERT INTO team_members(team_id,email,role,added) VALUES(?,?,?,?) " + "ON CONFLICT(team_id,email) DO UPDATE SET role=excluded.role", + (tid, target, role, now)) + conn.commit() + log_event("team", f"{email} added {target} to team #{tid} as {role}", email) + return web.json_response({"ok": True}) + + +async def api_team_member_remove(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + tid = _team_id_arg(body.get("team_id")) + target = str(body.get("email", "")).strip().lower() + if tid is None or not target: + return web.json_response({"error": "team_id + email required"}, status=400) + with _acct_db() as conn: + # a member can remove themselves (leave); an owner/admin can remove anyone + if target != email.lower() and not _can_manage_team(conn, tid, email): + return web.json_response({"error": "not a team owner"}, status=403) + owners = conn.execute("SELECT COUNT(*) FROM team_members WHERE team_id=? AND role='owner'", + (tid,)).fetchone()[0] + if _team_role(conn, tid, target) == "owner" and owners <= 1: + return web.json_response({"error": "a team needs at least one owner"}, status=400) + conn.execute("DELETE FROM team_members WHERE team_id=? AND lower(email)=lower(?)", (tid, target)) + conn.commit() + log_event("team", f"{email} removed {target} from team #{tid}", email) + return web.json_response({"ok": True}) + + +async def api_team_devices(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + tid = _team_id_arg(req.query.get("team_id")) + if tid is None: + return web.json_response({"error": "team_id required"}, status=400) + with _acct_db() as conn: + if _team_role(conn, tid, email) is None and _user_role(conn, email) != "admin": + return web.json_response({"error": "not a team member"}, status=403) + rows = conn.execute( + "SELECT d.id,d.name,d.ver,d.last_seen,d.tags,d.grp,d.owner FROM team_devices td " + "JOIN devices d ON d.id=td.device_id WHERE td.team_id=? ORDER BY d.grp,d.name", (tid,)).fetchall() + can = _can_manage_team(conn, tid, email) + out = [] + for hid, name, ver, seen, tags, grp, owner in rows: + h = HOSTS.get(hid) + out.append({"id": hid, "name": name or hid, "ver": ver or "", + "online": h is not None and not h.ws.closed, + "busy": bool(h and h.session is not None), + "tags": [t.strip() for t in (tags or "").split(",") if t.strip()], + "group": grp or "", "owner": owner, "last_seen": seen or 0}) + return web.json_response({"devices": out, "can_manage": can}) + + +async def api_team_device_add(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + tid = _team_id_arg(body.get("team_id")) + hid = str(body.get("device_id", "")).strip() + if tid is None or not hid: + return web.json_response({"error": "team_id + device_id required"}, status=400) + now = int(time.time()) + with _acct_db() as conn: + role = _user_role(conn, email) + if _team_role(conn, tid, email) is None and role != "admin": + return web.json_response({"error": "not a team member"}, status=403) + drow = conn.execute("SELECT owner FROM devices WHERE id=?", (hid,)).fetchone() + if not drow: + return web.json_response({"error": "unknown device"}, status=404) + if drow[0] != email and role != "admin": + return web.json_response({"error": "not your device"}, status=403) + conn.execute("INSERT OR IGNORE INTO team_devices(team_id,device_id,added_by,added) " + "VALUES(?,?,?,?)", (tid, hid, email, now)) + conn.commit() + log_event("team", f"{email} shared device {hid} into team #{tid}", email) + return web.json_response({"ok": True}) + + +async def api_team_device_remove(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + tid = _team_id_arg(body.get("team_id")) + hid = str(body.get("device_id", "")).strip() + if tid is None or not hid: + return web.json_response({"error": "team_id + device_id required"}, status=400) + with _acct_db() as conn: + drow = conn.execute("SELECT owner FROM devices WHERE id=?", (hid,)).fetchone() + owns = bool(drow) and drow[0] == email + if not owns and not _can_manage_team(conn, tid, email): + return web.json_response({"error": "not allowed"}, status=403) + conn.execute("DELETE FROM team_devices WHERE team_id=? AND device_id=?", (tid, hid)) + conn.commit() + return web.json_response({"ok": True}) + + +# ===================== conditional-access rule engine ===================== +def _device_owner_group(hid): + try: + with _acct_db() as conn: + row = conn.execute("SELECT owner, grp FROM devices WHERE id=?", (hid,)).fetchone() + if row: + return (row[0] or ""), (row[1] or "") + except Exception: + pass + return "", "" + + +def _access_rules_for(conn, owner): + rows = conn.execute( + "SELECT id,priority,action,subject_type,subject,cidr,weekdays,time_start,time_end," + "tz_offset,reason,enabled,created,created_by FROM access_rules " + "WHERE lower(owner)=lower(?) ORDER BY priority, id", (owner,)).fetchall() + out = [] + for r in rows: + out.append({"id": r[0], "priority": r[1], "action": r[2], "subject_type": r[3], + "subject": r[4] or "", "cidr": r[5] or "", "weekdays": r[6] or "", + "time_start": r[7], "time_end": r[8], "tz_offset": r[9] or 0, + "reason": r[10] or "", "enabled": bool(r[11]), + "created": r[12] or 0, "created_by": r[13] or ""}) + return out + + +def _ip_in_cidr(ip, cidr): + try: + import ipaddress + return ipaddress.ip_address(ip) in ipaddress.ip_network(cidr, strict=False) + except Exception: + return False + + +def _rule_matches(rule, viewer_email, host_id, host_group, ip, now_ts): + st = rule["subject_type"] + sub = (rule["subject"] or "").strip() + if st == "user": + if not viewer_email or viewer_email.lower() != sub.lower(): + return False + elif st == "device": + if host_id != sub: + return False + elif st == "group": + if (host_group or "") != sub: + return False + if rule["cidr"]: + if not ip or not _ip_in_cidr(ip, rule["cidr"]): + return False + wd = (rule["weekdays"] or "").strip() + ts, te = rule["time_start"], rule["time_end"] + if wd or (ts is not None and te is not None): + import datetime + local = datetime.datetime.utcfromtimestamp(now_ts + (rule["tz_offset"] or 0) * 60) + if wd: + days = set(int(x) for x in wd.split(",") if x.strip().isdigit()) + if local.weekday() not in days: # Monday=0 .. Sunday=6 + return False + if ts is not None and te is not None: + minute = local.hour * 60 + local.minute + if ts <= te: + if not (ts <= minute <= te): + return False + else: # overnight window, e.g. 22:00-06:00 + if not (minute >= ts or minute <= te): + return False + return True + + +def _eval_access(owner, viewer_email, host_id, host_group, ip, now_ts): + """First-match-wins, default ALLOW. Returns (allowed, reason, rule_id).""" + if not owner: + return True, "", None + try: + with _acct_db() as conn: + rules = _access_rules_for(conn, owner) + except Exception: + return True, "", None + for r in rules: + if not r["enabled"]: + continue + if _rule_matches(r, viewer_email, host_id, host_group, ip, now_ts): + if r["action"] == "deny": + return False, (r["reason"] or "Access denied by policy"), r["id"] + return True, "", r["id"] + return True, "", None + + +def _rule_int(v): + try: + return int(v) + except (TypeError, ValueError): + return None + + +async def api_access_rules(req): + """List the conditional-access rules protecting an account's hosts.""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + target = (req.query.get("owner", "").strip().lower() or email.lower()) + if target != email.lower(): + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only for other accounts"}, status=403) + with _acct_db() as conn: + rules = _access_rules_for(conn, target) + return web.json_response({"owner": target, "rules": rules}) + + +async def api_access_rule_set(req): + """Create (no id) or update (id) an access rule. Owner-self, or admin for any.""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + target = str(body.get("owner", "")).strip().lower() or email.lower() + if target != email.lower(): + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only for other accounts"}, status=403) + action = str(body.get("action", "deny")).strip().lower() + if action not in ("allow", "deny"): + return web.json_response({"error": "action must be allow|deny"}, status=400) + st = str(body.get("subject_type", "any")).strip().lower() + if st not in ("any", "user", "device", "group"): + return web.json_response({"error": "bad subject_type"}, status=400) + prio = _rule_int(body.get("priority")) + if prio is None: + prio = 100 + ts = _rule_int(body.get("time_start")) + te = _rule_int(body.get("time_end")) + tz = _rule_int(body.get("tz_offset")) or 0 + cidr = str(body.get("cidr", "")).strip() + if cidr: + try: + import ipaddress + ipaddress.ip_network(cidr, strict=False) + except Exception: + return web.json_response({"error": "invalid CIDR"}, status=400) + wd = str(body.get("weekdays", "")).strip() + subject = str(body.get("subject", "")).strip() + reason = str(body.get("reason", "")).strip()[:200] + enabled = 1 if body.get("enabled", True) else 0 + rid = _rule_int(body.get("id")) + now = int(time.time()) + with _acct_db() as conn: + if rid: + row = conn.execute("SELECT owner FROM access_rules WHERE id=?", (rid,)).fetchone() + if not row or (row[0] or "").lower() != target: + return web.json_response({"error": "unknown rule"}, status=404) + conn.execute("UPDATE access_rules SET priority=?,action=?,subject_type=?,subject=?,cidr=?," + "weekdays=?,time_start=?,time_end=?,tz_offset=?,reason=?,enabled=? WHERE id=?", + (prio, action, st, subject, cidr, wd, ts, te, tz, reason, enabled, rid)) + else: + cur = conn.execute("INSERT INTO access_rules(owner,priority,action,subject_type,subject," + "cidr,weekdays,time_start,time_end,tz_offset,reason,enabled,created_by,created) " + "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + (target, prio, action, st, subject, cidr, wd, ts, te, tz, reason, enabled, email, now)) + rid = cur.lastrowid + conn.commit() + log_event("access", f"{email} {'updated' if body.get('id') else 'added'} rule #{rid} " + f"({action} {st} {subject or '*'}) for {target}", email) + return web.json_response({"ok": True, "id": rid}) + + +async def api_access_rule_delete(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + rid = _rule_int(body.get("id")) + if rid is None: + return web.json_response({"error": "id required"}, status=400) + with _acct_db() as conn: + row = conn.execute("SELECT owner FROM access_rules WHERE id=?", (rid,)).fetchone() + if not row: + return web.json_response({"error": "unknown rule"}, status=404) + if (row[0] or "").lower() != email.lower() and _user_role(conn, email) != "admin": + return web.json_response({"error": "not your rule"}, status=403) + conn.execute("DELETE FROM access_rules WHERE id=?", (rid,)) + conn.commit() + log_event("access", f"{email} deleted access rule #{rid}", email) + return web.json_response({"ok": True}) + + +async def api_access_test(req): + """Dry-run the access decision for a hypothetical connection (rule tester).""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + target = (req.query.get("owner", "").strip().lower() or email.lower()) + if target != email.lower(): + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only for other accounts"}, status=403) + viewer = req.query.get("viewer", "").strip() + host_id = req.query.get("host", "").strip() + group = req.query.get("group", "").strip() + ip = req.query.get("ip", "").strip() + now_ts = _rule_int(req.query.get("at")) or int(time.time()) + ok, reason, rid = _eval_access(target, viewer, host_id, group, ip, now_ts) + return web.json_response({"allowed": ok, "reason": reason, "rule_id": rid}) + + +async def api_device_meta(req): + """Set a device's address-book tags / group (and optional rename). Owner-only, + or an admin for any device.""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + try: + data = await req.json() + except Exception: + data = dict(await req.post()) + hid = (data.get("id") or "").strip() + if not hid: + return web.json_response({"error": "missing id"}, status=400) + tags = (data.get("tags") or "").strip() + grp = (data.get("group") or data.get("grp") or "").strip() + name = data.get("name") + with _acct_db() as conn: + role = _user_role(conn, email) + row = conn.execute("SELECT owner FROM devices WHERE id=?", (hid,)).fetchone() + if not row: + return web.json_response({"error": "unknown device"}, status=404) + if row[0] != email and role != "admin": + return web.json_response({"error": "not your device"}, status=403) + sets, vals = ["tags=?", "grp=?"], [tags, grp] + if name is not None: + sets.append("name=?") + vals.append(str(name)) + vals.append(hid) + conn.execute("UPDATE devices SET " + ",".join(sets) + " WHERE id=?", vals) + log_event("devmeta", f"{email} set {hid} tags='{tags}' group='{grp}'") + return web.json_response({"ok": True, "id": hid, "tags": tags, "group": grp}) + + +async def api_admin_role(req): + """RBAC: an admin sets another account's role ('admin' | 'member').""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + try: + data = await req.json() + except Exception: + data = dict(await req.post()) + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only"}, status=403) + target = (data.get("email") or "").strip().lower() + role = (data.get("role") or "").strip().lower() + if role not in ("admin", "member"): + return web.json_response({"error": "role must be admin|member"}, status=400) + cur = conn.execute("UPDATE users SET role=? WHERE email=?", (role, target)) + if cur.rowcount == 0: + return web.json_response({"error": "unknown user"}, status=404) + log_event("rbac", f"{email} set role of {target} -> {role}") + return web.json_response({"ok": True, "email": target, "role": role}) + + +# ---- MDM-lite policy + seat/licensing (REST, X-API-Key / admin session) ---- +async def v1_policy_get(req): + owner = owner_from_api_key(req) + if not owner: + return web.json_response({"error": "valid X-API-Key required"}, status=401) + return web.json_response({ + "policy": _account_policy(owner), + "seats": _owner_seats(owner), + "seats_in_use": len(_owner_online_hosts(owner)), + "policy_keys": sorted(_POLICY_KEYS), + }) + + +async def v1_policy_set(req): + owner = owner_from_api_key(req) + if not owner: + return web.json_response({"error": "valid X-API-Key required"}, status=401) + body = await _json_body(req) + pol = body.get("policy", body) # accept {"policy": {...}} or a bare object + saved = _set_account_policy(owner, pol) + log_event("policy", f"{owner} set MDM policy ({len(saved)} keys)") + return web.json_response({"ok": True, "policy": saved}) + + +async def api_admin_license(req): + """Admin sets an account's seat limit (0 = unlimited).""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only"}, status=403) + data = await _json_body(req) + target = str(data.get("email", "")).strip().lower() + try: + seats = int(data.get("seats")) + except (TypeError, ValueError): + return web.json_response({"error": "seats must be an integer (0 = unlimited)"}, + status=400) + if not target or seats < 0: + return web.json_response({"error": "email + seats>=0 required"}, status=400) + with _acct_db() as conn: + conn.execute("INSERT INTO licenses(owner,seats,updated) VALUES(?,?,?) " + "ON CONFLICT(owner) DO UPDATE SET seats=excluded.seats," + " updated=excluded.updated", + (target, seats, int(time.time()))) + conn.commit() + log_event("license", f"{email} set seats of {target} -> {seats}") + return web.json_response({"ok": True, "email": target, "seats": seats}) + + +async def api_wake(req): + """Wake-on-LAN: relay a wake request for an offline computer through any + other ONLINE computer on the same account (which broadcasts the magic + packet on its local network - works when both are on the same LAN).""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + hid = str((await _json_body(req)).get("id", "")) + with _acct_db() as conn: + row = conn.execute("SELECT mac FROM devices WHERE id=? AND owner=?", + (hid, email)).fetchone() + others = conn.execute("SELECT id FROM devices WHERE owner=? AND id<>?", + (email, hid)).fetchall() + if row is None: + return web.json_response({"error": "not one of your computers"}, status=404) + h = HOSTS.get(hid) + if h is not None and not h.ws.closed: + return web.json_response({"ok": True, "already_online": True}) + mac = row[0] or "" + if not mac: + return web.json_response({"error": "no MAC recorded for this computer yet " + "(it is stored the next time it comes online)"}) + sent = 0 + for (oid,) in others: + oh = HOSTS.get(oid) + if oh is not None and not oh.ws.closed: + try: + await send_json(oh.ws, {"t": "wol", "mac": mac}) + sent += 1 + except Exception: + pass + if not sent: + return web.json_response({"error": "no other online computer on your account " + "can broadcast the wake packet"}) + log_event("wake", f"WoL for {hid} relayed via {sent} computer(s)") + return web.json_response({"ok": True, "relayed": sent}) + +async def api_admin_users(req): + """All accounts (admin console): role, verified, 2FA, seat limit, device count.""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only"}, status=403) + rows = conn.execute( + "SELECT email, role, verified, created, " + "CASE WHEN totp IS NOT NULL AND totp<>'' THEN 1 ELSE 0 END, " + "(SELECT seats FROM licenses WHERE lower(owner)=lower(users.email)) " + "FROM users ORDER BY created DESC").fetchall() + devc = dict(conn.execute("SELECT owner, COUNT(*) FROM devices GROUP BY owner").fetchall()) + out = [{"email": em, "role": role or "member", "verified": bool(ver), "created": created or 0, + "twofa": bool(t), "seats": se or 0, "devices": devc.get(em, 0)} + for (em, role, ver, created, t, se) in rows] + return web.json_response({"users": out, "me": email}) + + +async def api_admin_overview(req): + """Org counts for the admin console dashboard.""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only"}, status=403) + nusers = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + nadmins = conn.execute("SELECT COUNT(*) FROM users WHERE role='admin'").fetchone()[0] + ndevices = conn.execute("SELECT COUNT(*) FROM devices").fetchone()[0] + nkeys = conn.execute("SELECT COUNT(*) FROM api_keys").fetchone()[0] + online = sum(1 for h in HOSTS.values() if not h.ws.closed) + insess = sum(1 for h in HOSTS.values() if h.session is not None) + return web.json_response({"users": nusers, "admins": nadmins, "devices": ndevices, + "api_keys": nkeys, "hosts_online": online, "in_session": insess, + "relays": len(_relays())}) + + +async def api_admin_teams(req): + """All teams in the org (admin console). Any team can then be managed via the + team endpoints, which honour an account-admin override.""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only"}, status=403) + rows = conn.execute("SELECT id,name,created_by,created FROM teams ORDER BY name").fetchall() + out = [] + for tid, name, cb, created in rows: + nm = conn.execute("SELECT COUNT(*) FROM team_members WHERE team_id=?", (tid,)).fetchone()[0] + nd = conn.execute("SELECT COUNT(*) FROM team_devices WHERE team_id=?", (tid,)).fetchone()[0] + out.append({"id": tid, "name": name, "created_by": cb, "created": created, + "members": nm, "devices": nd}) + return web.json_response({"teams": out}) + + +async def api_admin_policy(req): + """Session-admin view/set of an account's MDM-lite policy (the operator + equivalent of the X-API-Key /api/v1/policy, for the admin console UI).""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only"}, status=403) + if req.method == "GET": + target = (req.query.get("email", "").strip().lower() or email) + return web.json_response({"email": target, "policy": _account_policy(target), + "seats": _owner_seats(target), "keys": sorted(_POLICY_KEYS)}) + body = await _json_body(req) + target = str(body.get("email", "")).strip().lower() + if not target: + return web.json_response({"error": "email required"}, status=400) + saved = _set_account_policy(target, body.get("policy", {})) + log_event("policy", f"{email} set MDM policy for {target} ({len(saved)} keys)") + return web.json_response({"ok": True, "email": target, "policy": saved}) + + +async def api_device_remove(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + hid = str((await _json_body(req)).get("id", "")) + with _acct_db() as conn: + conn.execute("DELETE FROM devices WHERE id=? AND owner=?", (hid, email)) + return web.json_response({"ok": True}) + +def bind_device(hid, email, name, ver, mac=""): + with _acct_db() as conn: + if email: + conn.execute("INSERT INTO devices(id,owner,name,ver,last_seen,mac) VALUES(?,?,?,?,?,?) " + "ON CONFLICT(id) DO UPDATE SET owner=excluded.owner, name=excluded.name," + " ver=excluded.ver, last_seen=excluded.last_seen," + " mac=CASE WHEN excluded.mac<>'' THEN excluded.mac ELSE devices.mac END", + (hid, email, name, ver, int(time.time()), mac)) + else: + conn.execute("DELETE FROM devices WHERE id=?", (hid,)) + +def touch_device(hid): + try: + with _acct_db() as conn: + conn.execute("UPDATE devices SET last_seen=? WHERE id=?", (int(time.time()), hid)) + except Exception: + pass + +async def api_info(req): + """Public server info - lets clients auto-configure (ws URL, features).""" + base = public_base(req) + return web.json_response({ + "name": "NetDesk", "version": VERSION, + "ws": base.replace("https://", "wss://").replace("http://", "ws://") + "/ws", + "web": base, "reg_key_required": bool(ARGS.reg_key), + "accounts": True, "mail": bool(ARGS.mail_from)}) + + +async def api_contact(req): + """Website contact form -> email + audit log.""" + body = await _json_body(req) + name = str(body.get("name", "")).strip()[:120] + email = str(body.get("email", "")).strip()[:160] + topic = str(body.get("topic", "General")).strip()[:60] + message = str(body.get("message", "")).strip()[:4000] + if not name or "@" not in email or len(message) < 2: + return web.json_response({"error": "please fill in every field"}, status=400) + log_event("contact", f"{topic}: {name} <{email}>") + dest = ARGS.owner_email or ARGS.mail_from + if ARGS.mail_from and dest: + def _send(): + try: + send_mail(dest, f"[NetDesk contact] {topic} — {name}", + _mail_html("New contact message", + f"From: {name} <{email}>
Topic: {topic}" + f"

{message}".replace(chr(10), '
'))) + except Exception as e: + log.warning("contact mail failed: %s", e) + try: + asyncio.get_running_loop().run_in_executor(None, _send) + except Exception: + pass + return web.json_response({"ok": True}) + +async def auth_forgot(req): + body = await _json_body(req) + email = str(body.get("email", "")).strip().lower() + with _acct_db() as conn: + row = conn.execute("SELECT id FROM users WHERE email=?", (email,)).fetchone() + if row: + tok = _secrets.token_urlsafe(24) + conn.execute("UPDATE users SET reset_token=?, reset_exp=? WHERE id=?", + (tok, int(time.time()) + 1800, row[0])) + link = public_base(req) + "/app?reset=" + tok + try: + send_mail(email, "Reset your NetDesk password", + _mail_html("Password reset", + "Click below to choose a new password. " + "The link expires in 30 minutes.", link, "Reset password")) + except Exception as e: + log.warning("reset mail to %s failed: %s", email, e) + return web.json_response( + {"ok": True, "msg": "if that account exists, a reset email was sent"}) + +async def auth_reset(req): + body = await _json_body(req) + tok, pw = str(body.get("token", "")), str(body.get("password", "")) + if len(pw) < 8: + return web.json_response({"error": "password needs 8+ characters"}, status=400) + with _acct_db() as conn: + row = conn.execute("SELECT id FROM users WHERE reset_token=? AND reset_exp>?", + (tok, int(time.time()))).fetchone() + if not row: + return web.json_response({"error": "link expired or invalid"}, status=400) + salt = _secrets.token_bytes(16) + conn.execute("UPDATE users SET pw_salt=?, pw_hash=?, reset_token=NULL, reset_exp=NULL " + "WHERE id=?", (salt.hex(), _pbkdf(pw, salt), row[0])) + log_event("auth", "password reset") + return web.json_response({"ok": True}) + +TUNNELS: dict[int, web.WebSocketResponse] = {} # tid -> viewer-side socket +TUNNEL_TID = [1] + +# session plans: guests 20 min, verified users 45 min, owner unlimited +PLAN_LIMITS = {"owner": None, "user": 45 * 60, "guest": 20 * 60} + +def plan_for(email): + if email and ARGS.owner_email and email.lower() == ARGS.owner_email.lower(): + return "owner", None + if email: + return "user", PLAN_LIMITS["user"] + return "guest", PLAN_LIMITS["guest"] + + +def host_is_owners(hid): + """The server owner's OWN computers are never time-limited - whoever + holds the unattended password already has full control; the limit is a + lever for third parties using the free relay, not a security control.""" + if not ARGS.owner_email: + return False + try: + with _acct_db() as conn: + row = conn.execute("SELECT owner FROM devices WHERE id=?", (hid,)).fetchone() + return bool(row and row[0] and row[0].lower() == ARGS.owner_email.lower()) + except Exception: + return False + + +# ---- ICE servers for the hybrid transport (cached Cloudflare TURN creds) ---- +_ICE_CACHE = {"servers": None, "exp": 0.0} +_DEFAULT_ICE = [{"urls": ["stun:stun.cloudflare.com:3478", "stun:stun.l.google.com:19302"]}] + + +def _mint_cloudflare_ice(): + import urllib.request + req = urllib.request.Request( + "https://rtc.live.cloudflare.com/v1/turn/keys/%s/credentials/generate-ice-servers" + % ARGS.turn_key_id, + data=json.dumps({"ttl": 86400}).encode(), + headers={"Authorization": "Bearer " + ARGS.turn_token, + "Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=8) as r: + d = json.loads(r.read().decode()) + servers = d.get("iceServers") + if isinstance(servers, dict): # older response shape + servers = [servers] + return servers or None + + +def _mint_secret_ice(): + """Our own coturn (use-auth-secret): TURN REST creds = 'expiry:nd' + + base64(HMAC-SHA1(secret, username)). Valid 12h, no API call needed.""" + import hmac, hashlib, base64 + user = "%d:nd" % (int(time.time()) + 12 * 3600) + cred = base64.b64encode(hmac.new(ARGS.turn_secret.encode(), user.encode(), + hashlib.sha1).digest()).decode() + host = ARGS.turn_host + return [{"urls": ["stun:%s:3478" % host]}, + {"urls": ["turn:%s:3478?transport=udp" % host, + "turn:%s:3478?transport=tcp" % host, + "turns:%s:5349?transport=tcp" % host], + "username": user, "credential": cred}] + + +async def ice_servers(): + """STUN always; TURN when configured. Cloudflare creds are minted once + and reused for ~20h (they live 24h) - one API call per day, not per call. + + With --always-use-relay the server hands out NO ICE servers, so clients + cannot gather P2P candidates and every session stays on the WSS relay — a + privacy/compliance control (no peer IP exposure, all traffic through the + operator's server), matching RustDesk's ALWAYS_USE_RELAY.""" + if getattr(ARGS, "always_use_relay", False): + return [] + now_t = time.time() + if _ICE_CACHE["servers"] and now_t < _ICE_CACHE["exp"]: + return _ICE_CACHE["servers"] + servers = None + if ARGS.turn_secret and ARGS.turn_host: + servers = _mint_secret_ice() + _ICE_CACHE.update(servers=servers, exp=now_t + 6 * 3600) + return servers + if ARGS.turn_key_id and ARGS.turn_token: + try: + servers = await asyncio.get_event_loop().run_in_executor(None, _mint_cloudflare_ice) + except Exception as e: + log.warning("cloudflare TURN mint failed: %s", e) + elif ARGS.turn_urls: + servers = _DEFAULT_ICE + [{"urls": [u.strip() for u in ARGS.turn_urls.split(",") if u.strip()], + "username": ARGS.turn_user, "credential": ARGS.turn_pass}] + if not servers: + return _DEFAULT_ICE + _ICE_CACHE.update(servers=servers, exp=now_t + 20 * 3600) + return servers + +# binaries offered for download from the portal (put e.g. NetDeskHost.exe here) +_server_root = _nd_data_root() +FILES_DIR = os.path.join(_server_root, "files") + + +AUDIT_LOG = os.path.join(_server_root, "audit.log") +NL = chr(10) + +def log_event(kind: str, msg: str, actor: str = ""): + """In-memory ring for the portal + persistent JSONL audit file + (survives restarts, like RustDesk 1.4.9 audit logs).""" + EVENTS.appendleft({"ts": int(time.time()), "kind": kind, "msg": msg, "actor": actor}) + try: + with open(AUDIT_LOG, "a", encoding="utf-8") as f: + f.write(json.dumps({"ts": int(time.time()), "kind": kind, + "msg": msg, "actor": actor}) + NL) + except Exception: + pass + + +def owner_from_cookie(req) -> bool: + email = user_from_cookie(req) + return bool(email and ARGS.owner_email and email.lower() == ARGS.owner_email.lower()) + + +def _load_audit(): + try: + with open(AUDIT_LOG, "r", encoding="utf-8") as f: + for line in f.readlines()[-200:]: + try: + EVENTS.appendleft(json.loads(line)) + except Exception: + pass + except FileNotFoundError: + pass + + +def _read_audit(kind=None, q=None, actor=None, since=None, until=None, limit=500): + """Full persistent audit.log (JSONL), newest first, filtered. This is the + enterprise/compliance report source (RustDesk/TeamViewer-style connection + + event history), beyond the 200-entry in-memory ring the overview shows.""" + try: + with open(AUDIT_LOG, "r", encoding="utf-8") as f: + lines = f.readlines() + except Exception: + lines = [] + ql = q.lower() if q else None + al = actor.lower() if actor else None + out = [] + for line in reversed(lines): + line = line.strip() + if not line: + continue + try: + e = json.loads(line) + except Exception: + continue + ts = e.get("ts", 0) + if since and ts < since: + continue + if until and ts > until: + continue + if kind and e.get("kind") != kind: + continue + if al and al not in (e.get("actor") or "").lower(): + continue + if ql: + hay = (str(e.get("msg", "")) + " " + str(e.get("kind", "")) + " " + + str(e.get("actor") or "")).lower() + if ql not in hay: + continue + out.append(e) + if len(out) >= limit: + break + return out + + +def _audit_query(req): + q = req.query + def _int(name): + try: + return int(q[name]) if q.get(name) else None + except (TypeError, ValueError): + return None + return dict(kind=(q.get("kind") or None), q=(q.get("q") or None), + actor=(q.get("actor") or None), since=_int("since"), until=_int("until")) + + +async def portal_audit(req): + """Filterable audit/connection history for the operator console.""" + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + qp = _audit_query(req) + try: + limit = min(int(req.query.get("limit") or 500), 5000) + except (TypeError, ValueError): + limit = 500 + events = _read_audit(limit=limit, **qp) + # the distinct event kinds present, for the filter dropdown + try: + with open(AUDIT_LOG, "r", encoding="utf-8") as f: + kinds = sorted({json.loads(l).get("kind", "") for l in f.readlines()[-4000:] + if l.strip()} - {""}) + except Exception: + kinds = sorted({e.get("kind", "") for e in events} - {""}) + return web.json_response({"events": events, "count": len(events), "kinds": kinds}) + + +async def portal_audit_csv(req): + """CSV export of the (filtered) audit log — SIEM / compliance import.""" + if not portal_authorized(req): + return web.Response(status=401, text="unauthorized") + import csv as _csv, io as _io, datetime as _dt + qp = _audit_query(req) + try: + limit = min(int(req.query.get("limit") or 50000), 200000) + except (TypeError, ValueError): + limit = 50000 + events = _read_audit(limit=limit, **qp) + buf = _io.StringIO() + w = _csv.writer(buf) + w.writerow(["timestamp_utc", "unix_ts", "kind", "actor", "message"]) + for e in events: + ts = e.get("ts", 0) + iso = (_dt.datetime.utcfromtimestamp(ts).isoformat() + "Z") if ts else "" + w.writerow([iso, ts, e.get("kind", ""), e.get("actor", ""), e.get("msg", "")]) + fname = "netdesk-audit-%d.csv" % int(time.time()) + return web.Response( + body=buf.getvalue().encode("utf-8"), + headers={"Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": 'attachment; filename="%s"' % fname}) + + +def portal_authorized(req) -> bool: + if owner_from_cookie(req): + return True + if not ARGS.portal_password: + return False + tok = req.cookies.get("ndportal", "") + return tok in PORTAL_SESSIONS + + +# ---------------------------------------------------------------- HTTP + +MIME = { + ".html": "text/html", + ".js": "application/javascript", + ".css": "text/css", + ".svg": "image/svg+xml", + ".png": "image/png", + ".ico": "image/x-icon", + ".xml": "application/xml", + ".webmanifest": "application/manifest+json", +} + +# marketing pages are public & indexable; the app/console are not +NOINDEX_FILES = {"app.html", "portal.html", "portal.js", "app.js", "admin.html", "admin.js"} + + +async def serve_file(name): + path = os.path.normpath(os.path.join(WEB_DIR, name)) + if not path.startswith(WEB_DIR) or not os.path.isfile(path): + return web.Response(status=404, text="not found") + with open(path, "rb") as f: + body = f.read() + ext = os.path.splitext(path)[1] + resp = web.Response( + body=body, + content_type=MIME.get(ext, "application/octet-stream"), + charset="utf-8" if MIME.get(ext, "").startswith("text/") or + MIME.get(ext, "").endswith(("javascript", "xml", "json")) else None, + ) + if os.path.basename(path) in NOINDEX_FILES: + resp.headers["X-Robots-Tag"] = "noindex, nofollow" + resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + else: + # marketing pages / assets: allow indexing + sane caching + resp.headers["Cache-Control"] = "public, max-age=600" + return resp + + +async def http_index(req): + return await serve_file("index.html") + + +async def http_app(req): + return await serve_file("app.html") + + +async def http_health(req): + return web.json_response({"ok": True, "version": VERSION, "uptime": int(time.time() - STATS.started)}) + + +async def http_status(req): + if ARGS.status_key: + tok = req.query.get("token", "") + import hmac + if not hmac.compare_digest(tok, ARGS.status_key): + return web.json_response({"error": "bad token"}, status=403) + hosts = [ + { + "id": h.id, + "name": h.name, + "version": h.ver, + "connected_for": int(time.time() - h.connected_at), + "in_session": h.session is not None, + } + for h in HOSTS.values() + ] + return web.json_response( + { + "version": VERSION, + "uptime": int(time.time() - STATS.started), + "hosts_online": len(HOSTS), + "hosts": hosts, + "sessions_total": STATS.sessions_total, + "bytes_relayed": STATS.bytes_relayed, + } + ) + + +# ---------------------------------------------------------------- portal + +async def http_robots(req): + base = public_base(req) + txt = ("User-agent: *\n" + "Allow: /\n" + "Disallow: /app\n" + "Disallow: /portal\n" + "Disallow: /api/\n" + "Disallow: /ws\n" + "Disallow: /dl/\n" + f"Sitemap: {base}/sitemap.xml\n") + return web.Response(text=txt, content_type="text/plain") + + +async def http_sitemap(req): + return await serve_file("sitemap.xml") + + +async def portal_page(req): + if not ARGS.portal_password: + return web.Response(status=404, text="Portal disabled. Start the server with --portal-password.") + return await serve_file("portal.html") + + +async def portal_login(req): + if not ARGS.portal_password: + return web.json_response({"error": "disabled"}, status=404) + ip = req_ip(req) + locked = login_locked("portal", ip) + if locked: + return web.json_response( + {"error": f"too many attempts — try again in {int(locked) + 1}s"}, + status=429) + form = await req.post() + pw = str(form.get("password", "")) + if not _secrets.compare_digest(pw, ARGS.portal_password): + login_fail("portal", ip) + await asyncio.sleep(0.4) + return web.json_response({"error": "wrong password"}, status=401) + if ARGS.portal_totp and not _totp_verify(ARGS.portal_totp, str(form.get("totp", "")).strip()): + login_fail("portal", ip) + await asyncio.sleep(0.4) + return web.json_response({"error": "wrong or missing 2FA code"}, status=401) + login_ok("portal", ip) + tok = _secrets.token_urlsafe(32) + PORTAL_SESSIONS.add(tok) + log_event("portal", "portal login") + resp = web.Response(status=303, headers={"Location": "/portal"}) + resp.set_cookie("ndportal", tok, httponly=True, samesite="Lax", max_age=7 * 24 * 3600) + return resp + + +async def portal_logout(req): + tok = req.cookies.get("ndportal", "") + PORTAL_SESSIONS.discard(tok) + resp = web.Response(status=303, headers={"Location": "/portal"}) + resp.del_cookie("ndportal") + return resp + + +async def portal_overview(req): + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + now = time.time() + known = [] + for hid, k in sorted(KNOWN_HOSTS.items()): + entry = dict(k) + h = HOSTS.get(hid) + entry["online"] = h is not None and not h.ws.closed + entry["in_session"] = bool(h and h.session is not None) + entry["hidden"] = is_hidden(hid) + entry["telemetry"] = (h.telemetry if h else {}) or {} + known.append(entry) + files = [] + if os.path.isdir(FILES_DIR): + for n in os.listdir(FILES_DIR): + p = os.path.join(FILES_DIR, n) + if os.path.isfile(p): + files.append({"name": n, "size": os.path.getsize(p), "mtime": int(os.path.getmtime(p))}) + files.sort(key=lambda f: f["name"]) + users = [] + with _acct_db() as conn: + for row in conn.execute("SELECT email, verified, created FROM users " + "ORDER BY created DESC LIMIT 500"): + users.append({"email": row[0], "verified": bool(row[1]), "created": row[2]}) + for k in known: + h = HOSTS.get(k["id"]) + k["plan"] = h.session.plan if (h and h.session) else "" + # Live visitors: everyone currently connected to one of the hosts. + visitors = [] + for hid2, h2 in HOSTS.items(): + if h2.session is not None: + s = h2.session + visitors.append({ + "ip": getattr(s, "vip", ""), "geo": getattr(s, "vgeo", {}) or {}, + "host": hid2, "host_name": h2.name, "plan": s.plan, + "email": s.vemail or "", "since": int(s.started), "active": bool(s.active), + }) + return web.json_response({ + "version": VERSION, + "visitors": visitors, + "visitors_recent": list(RECENT_VISITORS)[:50], + "owner": bool(ARGS.owner_email and user_from_cookie(req) and + user_from_cookie(req).lower() == ARGS.owner_email.lower()), + "users": users, + "reg_key": ARGS.reg_key or "", + "uptime": int(now - STATS.started), + "hosts_online": sum(1 for k in known if k["online"]), + "hosts": known, + "sessions_total": STATS.sessions_total, + "bytes_relayed": STATS.bytes_relayed, + "events": list(EVENTS)[:60], + "files": files, + "portal": True, + }) + + +async def public_download(req): + name = os.path.basename(req.match_info.get("name", "")) + path = os.path.join(FILES_DIR, name) + if not os.path.isfile(path): + return web.Response(status=404, text="not found") + with open(path, "rb") as f: + body = f.read() + resp = web.Response(body=body, content_type="application/octet-stream") + resp.headers["Content-Disposition"] = f'attachment; filename="{name}"' + return resp + + +async def tunnel_ws(req): + """Viewer-side endpoint: wss://server/tunnel/ + Client sends {"t":"open","port":N}; then raw binary both ways is the TCP + stream. The server asks the host to open the TCP connection and pipes.""" + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + hid = req.match_info.get("hid", "") + h = HOSTS.get(hid) + if h is None or h.ws.closed: + return web.json_response({"error": "host offline"}, status=404) + ws = web.WebSocketResponse(max_msg_size=64 * 1024 * 1024) + await ws.prepare(req) + my_tids = [] + try: + async for msg in ws: + if msg.type == WSMsgType.TEXT: + try: + m = json.loads(msg.data) + except Exception: + continue + if m.get("t") != "open": + continue + tid = TUNNEL_TID[0] + TUNNEL_TID[0] += 1 + TUNNELS[tid] = ws + my_tids.append(tid) + await h.ws.send_json({"t": "tunnel.open", "tid": tid, + "thost": m.get("host", "127.0.0.1"), + "port": int(m.get("port", 0))}) + elif msg.type == WSMsgType.BINARY: + if my_tids: + await h.ws.send_bytes(b"\xf3" + my_tids[0].to_bytes(4, "little") + msg.data) + finally: + for tid in my_tids: + TUNNELS.pop(tid, None) + try: + if h and not h.ws.closed: + await h.ws.send_json({"t": "tunnel.close", "tid": tid}) + except Exception: + pass + return ws + + +async def portal_users(req): + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + users = [] + with _acct_db() as conn: + for row in conn.execute("SELECT email, verified, created FROM users " + "ORDER BY created DESC LIMIT 500"): + users.append({"email": row[0], "verified": bool(row[1]), "created": row[2]}) + return web.json_response({"users": users}) + + +async def portal_user_delete(req): + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + body = await req.json() + email = str(body.get("email", "")).lower() + if ARGS.owner_email and email == ARGS.owner_email.lower(): + return web.json_response({"error": "cannot delete the owner"}, status=400) + with _acct_db() as conn: + conn.execute("DELETE FROM users WHERE email=?", (email,)) + conn.execute("DELETE FROM tokens WHERE email=?", (email,)) + conn.execute("DELETE FROM devices WHERE owner=?", (email,)) + for tok, em in list(USER_SESSIONS.items()): + if em == email: + USER_SESSIONS.pop(tok, None) + log_event("admin", f"user deleted: {email}") + return web.json_response({"ok": True}) + + +async def portal_host_delete(req): + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + hid = str((await req.json()).get("id", "")) + h = HOSTS.get(hid) + if h is None: + return web.json_response({"error": "host offline (removed from registry)"}, status=404) + try: + await h.ws.close() + except Exception: + pass + log_event("admin", f"host {hid} removed by admin") + return web.json_response({"ok": True, "msg": "host removed; it must re-register"}) + + +async def portal_host_hide(req): + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + body = await req.json() + hid = str(body.get("id", "")).strip() + hidden = bool(body.get("hidden", True)) + if not hid: + return web.json_response({"error": "no host id"}, status=400) + set_hidden(hid, hidden) + log_event("admin", f"host {hid} {'hidden' if hidden else 'unhidden'}") + return web.json_response({"ok": True, "hidden": hidden}) + + +async def portal_session_disconnect(req): + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + hid = str((await req.json()).get("id", "")) + h = HOSTS.get(hid) + if h is None or not h.session: + return web.json_response({"error": "no active session"}, status=404) + await relay_session_end(h.session, "disconnected by admin", None) + h.session = None + log_event("admin", f"session on {hid} disconnected by admin") + return web.json_response({"ok": True}) + + +async def portal_download(req): + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + name = os.path.basename(req.match_info.get("name", "")) + path = os.path.join(FILES_DIR, name) + if not os.path.isfile(path): + return web.Response(status=404, text="not found") + with open(path, "rb") as f: + body = f.read() + return web.Response( + body=body, + headers={"Content-Disposition": f'attachment; filename="{name}"'}, + content_type="application/octet-stream", + ) + + +# ===================== SCIM 2.0 provisioning (RFC 7643/7644) ===================== +# An IdP (Okta/Azure AD/OneLogin) auto-creates/updates/deactivates NetDesk +# accounts (Users) and teams (Groups) with a Bearer provisioning token. +SCIM_USER = "urn:ietf:params:scim:schemas:core:2.0:User" +SCIM_GROUP = "urn:ietf:params:scim:schemas:core:2.0:Group" +SCIM_LIST = "urn:ietf:params:scim:api:messages:2.0:ListResponse" +SCIM_ERR = "urn:ietf:params:scim:api:messages:2.0:Error" +SCIM_PATCH = "urn:ietf:params:scim:api:messages:2.0:PatchOp" + + +def _scim_json(data, status=200, location=None): + headers = {"Location": location} if location else None + return web.json_response(data, status=status, headers=headers, + content_type="application/scim+json") + + +def _scim_err(status, detail, scim_type=None): + body = {"schemas": [SCIM_ERR], "detail": detail, "status": str(status)} + if scim_type: + body["scimType"] = scim_type + return _scim_json(body, status=status) + + +def _scim_auth(req): + """Validate the Bearer provisioning token -> {token, admin} or None.""" + auth = req.headers.get("Authorization", "") + if not auth.lower().startswith("bearer "): + return None + tok = auth[7:].strip() + if not tok: + return None + with _acct_db() as conn: + row = conn.execute("SELECT token, created_by FROM scim_tokens WHERE token=?", (tok,)).fetchone() + if not row: + return None + conn.execute("UPDATE scim_tokens SET last_used=? WHERE token=?", (int(time.time()), tok)) + conn.commit() + return {"token": row[0], "admin": row[1] or ""} + + +def _iso(ts): + import datetime + return datetime.datetime.utcfromtimestamp(int(ts or 0)).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _scim_user_row(conn, uid=None, email=None): + if uid is not None: + return conn.execute("SELECT id,email,verified,created,COALESCE(disabled,0)," + "COALESCE(scim_ext,'') FROM users WHERE id=?", (uid,)).fetchone() + return conn.execute("SELECT id,email,verified,created,COALESCE(disabled,0)," + "COALESCE(scim_ext,'') FROM users WHERE lower(email)=lower(?)", (email,)).fetchone() + + +def _scim_user_json(row, req): + uid, email, verified, created, disabled, ext = row + base = public_base(req) + d = {"schemas": [SCIM_USER], "id": str(uid), "userName": email, + "name": {"formatted": email.split("@")[0]}, + "emails": [{"value": email, "primary": True, "type": "work"}], + "active": not bool(disabled), + "meta": {"resourceType": "User", "location": f"{base}/scim/v2/Users/{uid}", + "created": _iso(created), "lastModified": _iso(created)}} + if ext: + d["externalId"] = ext + return d + + +def _scim_group_json(conn, team_row, req): + tid, name = team_row[0], team_row[1] + base = public_base(req) + members = [] + for em, in conn.execute("SELECT email FROM team_members WHERE team_id=? ORDER BY email", (tid,)).fetchall(): + ur = conn.execute("SELECT id FROM users WHERE lower(email)=lower(?)", (em,)).fetchone() + members.append({"value": str(ur[0]) if ur else em, "display": em}) + return {"schemas": [SCIM_GROUP], "id": str(tid), "displayName": name, + "members": members, + "meta": {"resourceType": "Group", "location": f"{base}/scim/v2/Groups/{tid}"}} + + +async def scim_spc(req): + """ServiceProviderConfig: advertise PATCH + filter support.""" + base = public_base(req) + return _scim_json({ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], + "documentationUri": base + "/docs", + "patch": {"supported": True}, + "bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0}, + "filter": {"supported": True, "maxResults": 200}, + "changePassword": {"supported": False}, + "sort": {"supported": False}, + "etag": {"supported": False}, + "authenticationSchemes": [{"type": "oauthbearertoken", "name": "OAuth Bearer Token", + "description": "Provisioning token as an HTTP Authorization: Bearer header"}], + "meta": {"resourceType": "ServiceProviderConfig", "location": base + "/scim/v2/ServiceProviderConfig"}, + }) + + +def _filter_username(req): + """Parse a SCIM `userName eq "x"` (or displayName eq) filter -> value or None.""" + import re + f = req.query.get("filter", "") + m = re.search(r'(?:userName|displayName)\s+eq\s+"([^"]+)"', f, re.I) + return m.group(1) if m else None + + +async def scim_users_list(req): + if not _scim_auth(req): + return _scim_err(401, "invalid or missing provisioning token") + uname = _filter_username(req) + with _acct_db() as conn: + if uname: + rows = conn.execute("SELECT id,email,verified,created,COALESCE(disabled,0)," + "COALESCE(scim_ext,'') FROM users WHERE lower(email)=lower(?)", + (uname,)).fetchall() + else: + rows = conn.execute("SELECT id,email,verified,created,COALESCE(disabled,0)," + "COALESCE(scim_ext,'') FROM users ORDER BY id").fetchall() + res = [_scim_user_json(r, req) for r in rows] + return _scim_json({"schemas": [SCIM_LIST], "totalResults": len(res), + "startIndex": 1, "itemsPerPage": len(res), "Resources": res}) + + +async def scim_user_create(req): + if not _scim_auth(req): + return _scim_err(401, "invalid or missing provisioning token") + body = await _json_body(req) + email = str(body.get("userName", "")).strip().lower() + if "@" not in email: + # some IdPs put the address only in emails[] + for e in (body.get("emails") or []): + if isinstance(e, dict) and e.get("value"): + email = str(e["value"]).strip().lower(); break + if "@" not in email or "." not in email.split("@")[-1]: + return _scim_err(400, "userName must be an email address", "invalidValue") + active = body.get("active", True) + ext = str(body.get("externalId", "")).strip() + now = int(time.time()) + with _acct_db() as conn: + if _scim_user_row(conn, email=email): + return _scim_err(409, "user already exists", "uniqueness") + salt = _secrets.token_bytes(16) + pw = _secrets.token_urlsafe(24) # IdP-managed account; local login unused until reset + conn.execute("INSERT INTO users(email,pw_salt,pw_hash,verified,created,disabled,scim_ext,role) " + "VALUES(?,?,?,1,?,?,?,'member')", + (email, salt.hex(), _pbkdf(pw, salt), now, 0 if active else 1, ext)) + conn.commit() + row = _scim_user_row(conn, email=email) + log_event("scim", f"provisioned user {email}" + ("" if active else " (inactive)")) + j = _scim_user_json(row, req) + return _scim_json(j, status=201, location=j["meta"]["location"]) + + +async def scim_user_get(req): + if not _scim_auth(req): + return _scim_err(401, "invalid or missing provisioning token") + uid = _rule_int(req.match_info.get("id")) + with _acct_db() as conn: + row = _scim_user_row(conn, uid=uid) + if not row: + return _scim_err(404, "user not found") + return _scim_json(_scim_user_json(row, req)) + + +def _apply_user_active(conn, uid, active): + conn.execute("UPDATE users SET disabled=? WHERE id=?", (0 if active else 1, uid)) + if not active: # deactivation revokes live sessions + em = conn.execute("SELECT email FROM users WHERE id=?", (uid,)).fetchone() + if em: + conn.execute("DELETE FROM tokens WHERE email=?", (em[0],)) + + +async def scim_user_put(req): + if not _scim_auth(req): + return _scim_err(401, "invalid or missing provisioning token") + uid = _rule_int(req.match_info.get("id")) + body = await _json_body(req) + with _acct_db() as conn: + row = _scim_user_row(conn, uid=uid) + if not row: + return _scim_err(404, "user not found") + active = body.get("active", True) + _apply_user_active(conn, uid, active) + ext = body.get("externalId") + if ext is not None: + conn.execute("UPDATE users SET scim_ext=? WHERE id=?", (str(ext), uid)) + conn.commit() + row = _scim_user_row(conn, uid=uid) + log_event("scim", f"updated user #{uid} (active={bool(body.get('active', True))})") + return _scim_json(_scim_user_json(row, req)) + + +async def scim_user_patch(req): + if not _scim_auth(req): + return _scim_err(401, "invalid or missing provisioning token") + uid = _rule_int(req.match_info.get("id")) + body = await _json_body(req) + with _acct_db() as conn: + row = _scim_user_row(conn, uid=uid) + if not row: + return _scim_err(404, "user not found") + for op in (body.get("Operations") or []): + if not isinstance(op, dict): + continue + path = str(op.get("path", "")).strip().lower() + val = op.get("value") + # active may arrive as {"active": false} (no path) or path="active" + if path == "active": + _apply_user_active(conn, uid, _scim_truthy(val)) + elif path == "externalid": + conn.execute("UPDATE users SET scim_ext=? WHERE id=?", (str(val or ""), uid)) + elif path in ("", None) and isinstance(val, dict): + if "active" in val: + _apply_user_active(conn, uid, _scim_truthy(val.get("active"))) + if "externalId" in val: + conn.execute("UPDATE users SET scim_ext=? WHERE id=?", (str(val.get("externalId") or ""), uid)) + conn.commit() + row = _scim_user_row(conn, uid=uid) + return _scim_json(_scim_user_json(row, req)) + + +def _scim_truthy(v): + if isinstance(v, bool): + return v + return str(v).strip().lower() in ("true", "1", "yes") + + +async def scim_user_delete(req): + if not _scim_auth(req): + return _scim_err(401, "invalid or missing provisioning token") + uid = _rule_int(req.match_info.get("id")) + with _acct_db() as conn: + row = conn.execute("SELECT email FROM users WHERE id=?", (uid,)).fetchone() + if not row: + return _scim_err(404, "user not found") + em = row[0] + conn.execute("DELETE FROM users WHERE id=?", (uid,)) + conn.execute("DELETE FROM tokens WHERE email=?", (em,)) + conn.execute("DELETE FROM team_members WHERE lower(email)=lower(?)", (em,)) + conn.execute("DELETE FROM api_keys WHERE lower(owner)=lower(?)", (em,)) + conn.commit() + log_event("scim", f"deprovisioned user {em} (#{uid})") + return web.Response(status=204) + + +# ---- SCIM Groups (mapped to teams) ---- +async def scim_groups_list(req): + if not _scim_auth(req): + return _scim_err(401, "invalid or missing provisioning token") + dname = _filter_username(req) + with _acct_db() as conn: + if dname: + trows = conn.execute("SELECT id,name FROM teams WHERE name=? ORDER BY id", (dname,)).fetchall() + else: + trows = conn.execute("SELECT id,name FROM teams ORDER BY id").fetchall() + res = [_scim_group_json(conn, t, req) for t in trows] + return _scim_json({"schemas": [SCIM_LIST], "totalResults": len(res), + "startIndex": 1, "itemsPerPage": len(res), "Resources": res}) + + +def _scim_member_emails(conn, value): + """Resolve a SCIM members value list -> [emails].""" + out = [] + for mv in (value or []): + if not isinstance(mv, dict): + continue + v = str(mv.get("value", "")).strip() + if not v: + continue + ur = conn.execute("SELECT email FROM users WHERE id=?", (_rule_int(v),)).fetchone() if v.isdigit() else None + out.append(ur[0] if ur else v) + return out + + +async def scim_group_create(req): + sa = _scim_auth(req) + if not sa: + return _scim_err(401, "invalid or missing provisioning token") + body = await _json_body(req) + name = str(body.get("displayName", "")).strip() + if not name: + return _scim_err(400, "displayName required", "invalidValue") + now = int(time.time()) + with _acct_db() as conn: + cur = conn.execute("INSERT INTO teams(name,created_by,created) VALUES(?,?,?)", + (name, sa["admin"] or "scim", now)) + tid = cur.lastrowid + if sa["admin"]: + conn.execute("INSERT OR IGNORE INTO team_members(team_id,email,role,added) VALUES(?,?,?,?)", + (tid, sa["admin"], "owner", now)) + for em in _scim_member_emails(conn, body.get("members")): + conn.execute("INSERT OR IGNORE INTO team_members(team_id,email,role,added) VALUES(?,?,?,?)", + (tid, em, "member", now)) + conn.commit() + row = conn.execute("SELECT id,name FROM teams WHERE id=?", (tid,)).fetchone() + j = _scim_group_json(conn, row, req) + log_event("scim", f"provisioned group '{name}' (#{tid})") + return _scim_json(j, status=201, location=j["meta"]["location"]) + + +async def scim_group_get(req): + if not _scim_auth(req): + return _scim_err(401, "invalid or missing provisioning token") + tid = _rule_int(req.match_info.get("id")) + with _acct_db() as conn: + row = conn.execute("SELECT id,name FROM teams WHERE id=?", (tid,)).fetchone() + if not row: + return _scim_err(404, "group not found") + return _scim_json(_scim_group_json(conn, row, req)) + + +async def scim_group_put(req): + if not _scim_auth(req): + return _scim_err(401, "invalid or missing provisioning token") + tid = _rule_int(req.match_info.get("id")) + body = await _json_body(req) + now = int(time.time()) + with _acct_db() as conn: + row = conn.execute("SELECT id,name FROM teams WHERE id=?", (tid,)).fetchone() + if not row: + return _scim_err(404, "group not found") + name = str(body.get("displayName", "")).strip() + if name: + conn.execute("UPDATE teams SET name=? WHERE id=?", (name, tid)) + if "members" in body: # PUT replaces the member set (owners preserved) + conn.execute("DELETE FROM team_members WHERE team_id=? AND role<>'owner'", (tid,)) + for em in _scim_member_emails(conn, body.get("members")): + conn.execute("INSERT OR IGNORE INTO team_members(team_id,email,role,added) VALUES(?,?,?,?)", + (tid, em, "member", now)) + conn.commit() + row = conn.execute("SELECT id,name FROM teams WHERE id=?", (tid,)).fetchone() + return _scim_json(_scim_group_json(conn, row, req)) + + +async def scim_group_patch(req): + if not _scim_auth(req): + return _scim_err(401, "invalid or missing provisioning token") + import re + tid = _rule_int(req.match_info.get("id")) + body = await _json_body(req) + now = int(time.time()) + with _acct_db() as conn: + row = conn.execute("SELECT id,name FROM teams WHERE id=?", (tid,)).fetchone() + if not row: + return _scim_err(404, "group not found") + for op in (body.get("Operations") or []): + if not isinstance(op, dict): + continue + action = str(op.get("op", "")).strip().lower() + path = str(op.get("path", "")).strip() + val = op.get("value") + if path.lower() == "displayname" or (not path and isinstance(val, dict) and "displayName" in val): + nm = val if path else val.get("displayName") + if isinstance(nm, list): + nm = nm[0].get("value") if nm and isinstance(nm[0], dict) else None + if nm: + conn.execute("UPDATE teams SET name=? WHERE id=?", (str(nm), tid)) + elif path.lower().startswith("members") or (not path and isinstance(val, dict) and "members" in val): + mfilter = re.search(r'members\[\s*value\s+eq\s+"([^"]+)"\s*\]', path, re.I) + if action == "remove" and mfilter: + v = mfilter.group(1) + ur = conn.execute("SELECT email FROM users WHERE id=?", (_rule_int(v),)).fetchone() if v.isdigit() else None + em = ur[0] if ur else v + conn.execute("DELETE FROM team_members WHERE team_id=? AND lower(email)=lower(?) AND role<>'owner'", (tid, em)) + else: + members = val.get("members") if (not path and isinstance(val, dict)) else val + emails = _scim_member_emails(conn, members) + if action == "add" or action == "replace": + if action == "replace": + conn.execute("DELETE FROM team_members WHERE team_id=? AND role<>'owner'", (tid,)) + for em in emails: + conn.execute("INSERT OR IGNORE INTO team_members(team_id,email,role,added) VALUES(?,?,?,?)", + (tid, em, "member", now)) + elif action == "remove": + for em in emails: + conn.execute("DELETE FROM team_members WHERE team_id=? AND lower(email)=lower(?) AND role<>'owner'", (tid, em)) + conn.commit() + row = conn.execute("SELECT id,name FROM teams WHERE id=?", (tid,)).fetchone() + return _scim_json(_scim_group_json(conn, row, req)) + + +async def scim_group_delete(req): + if not _scim_auth(req): + return _scim_err(401, "invalid or missing provisioning token") + tid = _rule_int(req.match_info.get("id")) + with _acct_db() as conn: + if not conn.execute("SELECT 1 FROM teams WHERE id=?", (tid,)).fetchone(): + return _scim_err(404, "group not found") + conn.execute("DELETE FROM team_members WHERE team_id=?", (tid,)) + conn.execute("DELETE FROM team_devices WHERE team_id=?", (tid,)) + conn.execute("DELETE FROM teams WHERE id=?", (tid,)) + conn.commit() + log_event("scim", f"deprovisioned group #{tid}") + return web.Response(status=204) + + +# ---- SCIM provisioning-token management (admin session) ---- +async def api_scim_tokens_list(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only"}, status=403) + rows = conn.execute("SELECT token,label,created,last_used,created_by FROM scim_tokens " + "ORDER BY created DESC").fetchall() + out = [{"label": lb, "created": c, "last_used": lu, "created_by": cb, + "token_preview": tk[:10] + "…" + tk[-4:]} for (tk, lb, c, lu, cb) in rows] + return web.json_response({"tokens": out, "base": public_base(req) + "/scim/v2"}) + + +async def api_scim_token_create(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only"}, status=403) + body = await _json_body(req) + label = str(body.get("label", "")).strip()[:64] or "SCIM" + token = "scim_" + _secrets.token_urlsafe(32) + with _acct_db() as conn: + conn.execute("INSERT INTO scim_tokens(token,label,created_by,created,last_used) VALUES(?,?,?,?,0)", + (token, label, email, int(time.time()))) + conn.commit() + log_event("scim", f"{email} minted SCIM token '{label}'", email) + return web.json_response({"ok": True, "token": token, "label": label, + "base": public_base(req) + "/scim/v2"}) + + +async def api_scim_token_revoke(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only"}, status=403) + body = await _json_body(req) + k = str(body.get("token", "")).strip() + with _acct_db() as conn: + if k.startswith("scim_") and "…" not in k: + cur = conn.execute("DELETE FROM scim_tokens WHERE token=?", (k,)) + else: + cur = conn.execute("DELETE FROM scim_tokens WHERE token LIKE ?", (k.split("…")[0] + "%",)) + conn.commit() + return web.json_response({"ok": True, "revoked": cur.rowcount}) + + +# ===================== central session recording (server half) ===================== +# Policy flag record_required (pushed to hosts) tells the host to record the +# session and upload it here. The host authenticates the upload with a short- +# lived token minted at session.begin. Org-controlled storage + retention + +# admin/owner list/download/delete. +REC_DIR = os.path.join(_server_root, "recordings") +_REC_CAP = 2 * 1024 * 1024 * 1024 # 2 GB per recording + + +def _rec_retention_days(): + try: + return int(getattr(ARGS, "recording_retention_days", 0) or 0) + except Exception: + return 0 + + +def _rec_mint(host_id, owner, session_id, ttl=6 * 3600): + token = "rec_" + _secrets.token_urlsafe(24) + now = int(time.time()) + with _acct_db() as conn: + conn.execute("INSERT INTO rec_tokens(token,host_id,owner,session_id,created,exp) " + "VALUES(?,?,?,?,?,?)", (token, host_id, owner, session_id, now, now + ttl)) + conn.commit() + return token + + +def _rec_purge_expired(): + """Drop stale upload tokens and, when retention is set, old recordings.""" + now = int(time.time()) + days = _rec_retention_days() + try: + with _acct_db() as conn: + conn.execute("DELETE FROM rec_tokens WHERE exp>0 AND exp < ?", (now,)) + if days > 0: + cutoff = now - days * 86400 + for rid, path in conn.execute("SELECT id,path FROM recordings WHERE created < ?", + (cutoff,)).fetchall(): + try: + if path: + os.remove(path) + except OSError: + pass + conn.execute("DELETE FROM recordings WHERE created < ?", (cutoff,)) + conn.commit() + except Exception as e: + log.warning("recording purge: %s", e) + + +async def api_rec_upload(req): + """Host uploads a finished session recording (token-authed, streamed to disk).""" + token = req.query.get("token", "") or req.headers.get("X-Rec-Token", "") + now = int(time.time()) + with _acct_db() as conn: + row = conn.execute("SELECT host_id,owner,session_id,exp FROM rec_tokens WHERE token=?", + (token,)).fetchone() + if not row or (row[3] and row[3] < now): + return web.json_response({"error": "invalid or expired recording token"}, status=401) + host_id, owner, session_id, _exp = row + ext = "".join(c for c in (req.query.get("ext", "webm") or "webm") if c.isalnum())[:8] or "webm" + os.makedirs(REC_DIR, exist_ok=True) + name = f"rec-{host_id}-{_secrets.token_hex(6)}.{ext}" + path = os.path.join(REC_DIR, name) + tmp = path + ".part" + size = 0 + try: + with open(tmp, "wb") as f: + async for chunk in req.content.iter_chunked(256 * 1024): + size += len(chunk) + if size > _REC_CAP: + f.close(); os.remove(tmp) + return web.json_response({"error": "recording too large"}, status=413) + f.write(chunk) + except Exception as e: + try: os.remove(tmp) + except OSError: pass + return web.json_response({"error": "upload failed: %s" % e}, status=500) + if size == 0: + try: os.remove(tmp) + except OSError: pass + return web.json_response({"error": "empty upload"}, status=400) + os.replace(tmp, path) + duration = 0 + try: + duration = int(req.query.get("duration", 0) or 0) + except ValueError: + duration = 0 + with _acct_db() as conn: + cur = conn.execute("INSERT INTO recordings(owner,host_id,session_id,name,path,size,duration," + "viewer,mime,created) VALUES(?,?,?,?,?,?,?,?,?,?)", + (owner, host_id, session_id, name, path, size, duration, + (req.query.get("viewer", "") or "")[:120], "video/" + ext, now)) + rid = cur.lastrowid + # single-use token + conn.execute("DELETE FROM rec_tokens WHERE token=?", (token,)) + conn.commit() + log_event("recording", f"stored recording #{rid} for host {host_id} " + f"({size} bytes)", owner or "") + _rec_purge_expired() + return web.json_response({"ok": True, "id": rid, "size": size}) + + +async def api_recordings(req): + """List recordings for the signed-in account (admin sees all).""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + _rec_purge_expired() + with _acct_db() as conn: + if _user_role(conn, email) == "admin": + rows = conn.execute("SELECT id,owner,host_id,session_id,name,size,duration,viewer,created " + "FROM recordings ORDER BY created DESC").fetchall() + else: + rows = conn.execute("SELECT id,owner,host_id,session_id,name,size,duration,viewer,created " + "FROM recordings WHERE lower(owner)=lower(?) ORDER BY created DESC", + (email,)).fetchall() + out = [{"id": r[0], "owner": r[1], "host_id": r[2], "session_id": r[3], "name": r[4], + "size": r[5], "duration": r[6], "viewer": r[7], "created": r[8]} for r in rows] + return web.json_response({"recordings": out, "retention_days": _rec_retention_days()}) + + +def _rec_authz_row(conn, rid, email): + row = conn.execute("SELECT owner,path,name,mime FROM recordings WHERE id=?", (rid,)).fetchone() + if not row: + return None, "not found" + if (row[0] or "").lower() != email.lower() and _user_role(conn, email) != "admin": + return None, "forbidden" + return row, None + + +async def api_rec_download(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + rid = _rule_int(req.match_info.get("id")) + with _acct_db() as conn: + row, err = _rec_authz_row(conn, rid, email) + if err == "not found": + return web.json_response({"error": "recording not found"}, status=404) + if err == "forbidden": + return web.json_response({"error": "not your recording"}, status=403) + owner, path, name, mime = row + if not path or not os.path.exists(path): + return web.json_response({"error": "recording file missing"}, status=404) + return web.FileResponse(path, headers={ + "Content-Disposition": f'attachment; filename="{name}"', + "Content-Type": mime or "application/octet-stream"}) + + +async def api_rec_delete(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + rid = _rule_int(body.get("id")) + if rid is None: + return web.json_response({"error": "id required"}, status=400) + with _acct_db() as conn: + row, err = _rec_authz_row(conn, rid, email) + if err == "not found": + return web.json_response({"error": "recording not found"}, status=404) + if err == "forbidden": + return web.json_response({"error": "not your recording"}, status=403) + path = row[1] + conn.execute("DELETE FROM recordings WHERE id=?", (rid,)) + conn.commit() + try: + if path: + os.remove(path) + except OSError: + pass + log_event("recording", f"{email} deleted recording #{rid}", email) + return web.json_response({"ok": True}) + + +# ===================== SAML 2.0 SSO (Service Provider) ===================== +# A secure, spec-compliant SP: SP-initiated AuthnRequest (HTTP-Redirect), +# ServiceProviderConfig metadata, and an ACS that validates the IdP's XML-DSig +# signature (exclusive-C14N via lxml + RSA verify via cryptography), defends +# against signature-wrapping, and checks conditions/audience/issuer/recipient. +_SAML_NS = {"samlp": "urn:oasis:names:tc:SAML:2.0:protocol", + "saml": "urn:oasis:names:tc:SAML:2.0:assertion", + "ds": "http://www.w3.org/2000/09/xmldsig#", + "ec": "http://www.w3.org/2001/10/xml-exc-c14n#"} + + +class SamlError(Exception): + pass + + +def _saml_enabled(): + return bool(getattr(ARGS, "saml_idp_sso_url", "") and getattr(ARGS, "saml_idp_cert", "")) + + +def _saml_acs_url(req): + return public_base(req) + "/saml/acs" + + +def _saml_sp_entity_id(req): + return getattr(ARGS, "saml_sp_entity_id", "") or (public_base(req) + "/saml/metadata") + + +def _saml_idp_cert_pem(): + v = getattr(ARGS, "saml_idp_cert", "") or "" + try: + if os.path.exists(v): + with open(v, "rb") as f: + return f.read() + except Exception: + pass + if "BEGIN CERTIFICATE" not in v: + v = "-----BEGIN CERTIFICATE-----\n" + v + "\n-----END CERTIFICATE-----\n" + return v.encode() + + +def _xmlesc(s): + return (str(s).replace("&", "&").replace("<", "<").replace(">", ">") + .replace('"', """)) + + +def _saml_parsedt(s): + import datetime + s = (s or "").strip().replace("Z", "") + if "." in s: + s = s.split(".")[0] + return datetime.datetime.strptime(s, "%Y-%m-%dT%H:%M:%S") + + +def _saml_by_id(root, el_id): + """Find the single element with ID==el_id; reject duplicates (XSW defence).""" + found = [e for e in root.iter() if e.get("ID") == el_id] + if len(found) != 1: + raise SamlError("ambiguous or missing ID reference") + return found[0] + + +def _saml_validate(xml_bytes, acs_url, idp_cert_pem, sp_entity_id, idp_entity_id=None, skew=180): + from lxml import etree + from cryptography import x509 + from cryptography.hazmat.primitives.asymmetric import padding + from cryptography.hazmat.primitives import hashes + import base64, hashlib, hmac, copy, datetime + NS = _SAML_NS + + parser = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False, huge_tree=False) + try: + root = etree.fromstring(xml_bytes, parser) + except Exception as e: + raise SamlError("malformed SAML XML: %s" % e) + if root.getroottree().docinfo.doctype: + raise SamlError("DOCTYPE is not allowed") + + # Response-level Status must be Success (when this is a Response) + if root.tag.endswith("}Response"): + sc = root.find("./samlp:Status/samlp:StatusCode", NS) + if sc is not None and not (sc.get("Value", "").endswith(":status:Success")): + raise SamlError("IdP returned a non-Success status") + + cert = x509.load_pem_x509_certificate(idp_cert_pem) + pub = cert.public_key() + + sigs = root.findall(".//ds:Signature", NS) + if not sigs: + raise SamlError("response is not signed") + + verified_ids = set() + for sig in sigs: + si = sig.find("./ds:SignedInfo", NS) + if si is None: + continue + cm = si.find("./ds:CanonicalizationMethod", NS) + si_excl = "exc-c14n" in (cm.get("Algorithm", "") if cm is not None else "") + si_c14n = etree.tostring(si, method="c14n", exclusive=si_excl, with_comments=False) + sv_el = sig.find("./ds:SignatureValue", NS) + sig_value = base64.b64decode((sv_el.text or "").strip()) + sm = si.find("./ds:SignatureMethod", NS) + smeth = sm.get("Algorithm", "") if sm is not None else "" + if "sha256" in smeth: + halg = hashes.SHA256() + elif "sha1" in smeth: + halg = hashes.SHA1() + else: + raise SamlError("unsupported signature algorithm") + try: + pub.verify(sig_value, si_c14n, padding.PKCS1v15(), halg) + except Exception: + raise SamlError("signature verification failed (untrusted or tampered)") + + for ref in si.findall("./ds:Reference", NS): + uri = ref.get("URI", "") + if not uri.startswith("#"): + raise SamlError("only same-document references are allowed") + target = _saml_by_id(root, uri[1:]) + node = copy.deepcopy(target) + for s in node.findall(".//ds:Signature", NS): + s.getparent().remove(s) + ref_excl = True + incl = None + for tr in ref.findall("./ds:Transforms/ds:Transform", NS): + alg = tr.get("Algorithm", "") + if "exc-c14n" in alg: + ref_excl = True + elif "xml-c14n" in alg: + ref_excl = False + inc_el = tr.find("./ec:InclusiveNamespaces", NS) + if inc_el is not None and inc_el.get("PrefixList"): + incl = inc_el.get("PrefixList").split() + node_c14n = etree.tostring(node, method="c14n", exclusive=ref_excl, + with_comments=False, inclusive_ns_prefixes=incl) + dm = ref.find("./ds:DigestMethod", NS) + dmeth = dm.get("Algorithm", "") if dm is not None else "" + dv = base64.b64decode((ref.find("./ds:DigestValue", NS).text or "").strip()) + if "sha256" in dmeth: + calc = hashlib.sha256(node_c14n).digest() + elif "sha1" in dmeth: + calc = hashlib.sha1(node_c14n).digest() + else: + raise SamlError("unsupported digest algorithm") + if not hmac.compare_digest(calc, dv): + raise SamlError("digest mismatch (assertion was tampered)") + verified_ids.add(target.get("ID")) + + # Use ONLY a signed assertion (its ID was covered by a verified signature). + assertion = None + for a in root.findall(".//saml:Assertion", NS): + if a.get("ID") in verified_ids: + assertion = a + break + if assertion is None and root.tag.endswith("}Response") and root.get("ID") in verified_ids: + assertion = root.find("./saml:Assertion", NS) + if assertion is None: + raise SamlError("no signed assertion present") + + now = datetime.datetime.utcnow() + cond = assertion.find("./saml:Conditions", NS) + if cond is not None: + nb, na = cond.get("NotBefore"), cond.get("NotOnOrAfter") + if nb and _saml_parsedt(nb) - datetime.timedelta(seconds=skew) > now: + raise SamlError("assertion is not yet valid") + if na and _saml_parsedt(na) + datetime.timedelta(seconds=skew) < now: + raise SamlError("assertion has expired") + auds = [(e.text or "").strip() for e in cond.findall("./saml:AudienceRestriction/saml:Audience", NS)] + if auds and sp_entity_id not in auds: + raise SamlError("audience restriction does not match this SP") + + iss = assertion.find("./saml:Issuer", NS) + if idp_entity_id and iss is not None and (iss.text or "").strip() != idp_entity_id: + raise SamlError("assertion issuer does not match the configured IdP") + + scd = assertion.find("./saml:Subject/saml:SubjectConfirmation/saml:SubjectConfirmationData", NS) + if scd is not None: + na = scd.get("NotOnOrAfter") + if na and _saml_parsedt(na) + datetime.timedelta(seconds=skew) < now: + raise SamlError("subject confirmation has expired") + rcpt = scd.get("Recipient") + if rcpt and acs_url and rcpt.rstrip("/") != acs_url.rstrip("/"): + raise SamlError("subject confirmation recipient mismatch") + + nameid = assertion.find("./saml:Subject/saml:NameID", NS) + email = ((nameid.text or "").strip().lower()) if nameid is not None else "" + if "@" not in email: + email_attrs = ("email", "mail", "urn:oid:0.9.2342.19200300.100.1.3", + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress") + for at in assertion.findall(".//saml:Attribute", NS): + if at.get("Name", "").lower() in email_attrs: + v = at.find("./saml:AttributeValue", NS) + if v is not None and "@" in (v.text or ""): + email = v.text.strip().lower() + break + if "@" not in email: + raise SamlError("assertion carries no email NameID/attribute") + return email + + +def _saml_provision(email): + now = int(time.time()) + with _acct_db() as conn: + row = conn.execute("SELECT COALESCE(disabled,0) FROM users WHERE lower(email)=lower(?)", + (email,)).fetchone() + if row is None: + salt = _secrets.token_bytes(16) + pw = _secrets.token_urlsafe(24) + role = getattr(ARGS, "saml_default_role", "member") or "member" + conn.execute("INSERT INTO users(email,pw_salt,pw_hash,verified,created,role) " + "VALUES(?,?,?,1,?,?)", (email, salt.hex(), _pbkdf(pw, salt), now, role)) + conn.commit() + log_event("auth", f"SAML provisioned account {email}") + return True + return not bool(row[0]) # True unless disabled + + +async def saml_metadata(req): + if not _saml_enabled(): + return web.Response(status=404, text="SAML is not configured on this server") + acs, sp = _saml_acs_url(req), _saml_sp_entity_id(req) + xml = ('' + '' + '' + 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' + '' + '') % (_xmlesc(sp), _xmlesc(acs)) + return web.Response(text=xml, content_type="application/samlmetadata+xml") + + +async def saml_login(req): + if not _saml_enabled(): + return web.Response(status=404, text="SAML is not configured on this server") + import base64, zlib, urllib.parse, uuid, datetime + acs, sp = _saml_acs_url(req), _saml_sp_entity_id(req) + rid = "_" + uuid.uuid4().hex + inst = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + xml = ('' + '%s' + '') % ( + rid, inst, _xmlesc(ARGS.saml_idp_sso_url), _xmlesc(acs), _xmlesc(sp)) + co = zlib.compressobj(9, zlib.DEFLATED, -15) + deflated = co.compress(xml.encode()) + co.flush() + relay = req.query.get("RelayState", "/app") + q = urllib.parse.urlencode({"SAMLRequest": base64.b64encode(deflated).decode(), "RelayState": relay}) + sep = "&" if "?" in ARGS.saml_idp_sso_url else "?" + raise web.HTTPFound(ARGS.saml_idp_sso_url + sep + q) + + +async def saml_acs(req): + if not _saml_enabled(): + return web.Response(status=404, text="SAML is not configured on this server") + import base64 + form = await req.post() + b64 = form.get("SAMLResponse", "") + relay = form.get("RelayState", "") + if not b64: + return web.Response(status=400, text="missing SAMLResponse") + try: + xml = base64.b64decode(b64) + except Exception: + return web.Response(status=400, text="bad SAMLResponse encoding") + try: + email = _saml_validate(xml, _saml_acs_url(req), _saml_idp_cert_pem(), + _saml_sp_entity_id(req), + getattr(ARGS, "saml_idp_entity_id", "") or None) + except SamlError as e: + log_event("authfail", "SAML rejected: %s" % e) + return web.Response(status=403, text="SAML validation failed: %s" % e) + except Exception as e: + log_event("authfail", "SAML error: %s" % e) + return web.Response(status=400, text="SAML processing error") + active = _saml_provision(email) + if not active: + return web.Response(status=403, text="this account has been deactivated") + tok = _new_token(email) + log_event("auth", "signed in via SAML: " + email) + dest = relay if (relay and relay.startswith("/")) else "/app" + resp = web.HTTPFound(dest) + resp.set_cookie("nd_user", tok, httponly=True, samesite="Lax", max_age=365 * 86400) + return resp + + +async def api_auth_sso(req): + """Tell the login UI whether SSO is available (so it can show a button).""" + return web.json_response({"saml": _saml_enabled(), "login_url": "/saml/login", + "enforce": bool(getattr(ARGS, "saml_enforce", False))}) + + +# ---------------------------------------------------------------- WebSocket + +async def relay_session_end(sess, reason, source_ws): + """Tear down a session and notify both sides.""" + try: + if sess.host is not None and sess.host.ws is not source_ws and not sess.host.ws.closed: + await sess.host.ws.send_str(json.dumps({"t": "session.end", "sid": sess.sid, "reason": reason})) + except Exception: + pass + try: + if sess.viewer_ws is not source_ws and not sess.viewer_ws.closed: + await sess.viewer_ws.send_str(json.dumps({"t": "session.end", "sid": sess.sid, "reason": reason})) + except Exception: + pass + try: + fire_webhook(_device_owner(sess.host.id) if sess.host else None, "disconnect", + {"host_id": sess.host.id if sess.host else "", "reason": reason}) + except Exception: + pass + + +async def send_json(ws, obj): + try: + if not ws.closed: + await ws.send_str(json.dumps(obj)) + except Exception: + pass + + +async def ws_handler(req): + ws = web.WebSocketResponse(heartbeat=20.0, max_msg_size=64 * 1024 * 1024, autoping=True) + await ws.prepare(req) + + # behind nginx req.remote is 127.0.0.1 - prefer the forwarded client IP + peer = ((req.headers.get("X-Forwarded-For") or "").split(",")[0].strip() + or req.remote) + host: Host | None = None + viewer_sid: str | None = None + log.info("connection from %s", peer) + + try: + async for msg in ws: + if msg.type == WSMsgType.TEXT: + try: + m = json.loads(msg.data) + except Exception: + continue + if not isinstance(m, dict): + continue + t = m.get("t") + + # tunnel lifecycle JSON from a host: route to the tunnel socket + if t in ("tunnel.ok", "tunnel.err", "tunnel.closed") and TUNNELS: + tws = TUNNELS.get(m.get("tid", -1)) + if tws is not None and not tws.closed: + try: + await tws.send_str(msg.data) + except Exception: + pass + continue + + # ---- host registration -------------------------------- + if t == "reg.host": + hid = str(m.get("id", "")).strip() + if not hid or not hid.isdigit() or not (100000000 <= int(hid) <= 999999999): + await send_json(ws, {"t": "host_status", "ok": False, "msg": "bad id"}) + await ws.close() + break + if ARGS.reg_key and m.get("key") != ARGS.reg_key: + await send_json(ws, {"t": "host_status", "ok": False, "msg": "bad registration key"}) + await ws.close() + break + old = HOSTS.get(hid) + if old is not None and old.ws is not ws and not old.ws.closed: + # A live connection already holds this ID. + if old.session is not None: + # There's an active/pending session - NEVER displace it. + # This is what stops the "viewer drops 2-3s after + # connecting" flapping: a duplicate/stuck engine can + # never kill a live session. + log.info("host %s: rejecting registration from %s " + "(holder has an active session)", hid, peer) + await send_json(ws, {"t": "host_status", "ok": False, + "msg": "this ID is busy with a session"}) + await ws.close() + break + if not m.get("takeover"): + # Idle holder, plain duplicate: reject so two engines + # never ping-pong the ID. The holder keeps it. + log.info("host %s: rejecting duplicate registration from %s " + "(a live connection already holds this idle ID)", hid, peer) + await send_json(ws, {"t": "host_status", "ok": False, + "msg": "this ID is already connected elsewhere"}) + await ws.close() + break + # Idle holder + explicit takeover from a (fresh) engine: + # replace the old connection (lets a new/updated engine + # cleanly take over a stuck one without a session running). + log.info("host %s: takeover from %s replacing idle holder", hid, peer) + try: + await old.ws.close() + except Exception: + pass + elif old is not None and old.ws is not ws: + # holder's socket is already closed: clean it up and take over + if old.session: + await relay_session_end(old.session, "host reconnected", ws) + # seat / licensing admission (0 seats = unlimited; owner from + # the signed-in token, else the device's bound owner). + _tok = str(m.get("token") or "") + _ro = (email_for_token(_tok) if _tok else "") or device_owner(hid) or "" + if _ro and not _seat_ok(_ro, hid): + _sc = _owner_seats(_ro) + log_event("deny", f"host {hid} register: seat limit {_sc} reached " + f"for {_ro}") + await send_json(ws, {"t": "host_status", "ok": False, + "msg": f"seat limit reached ({_sc}) - free a " + f"device or add seats"}) + await ws.close() + break + host = Host(ws, hid, str(m.get("name", ""))[:64], str(m.get("ver", "?"))[:32]) + HOSTS[hid] = host + prev = KNOWN_HOSTS.get(hid) + KNOWN_HOSTS[hid] = { + "id": hid, "name": host.name, "ver": host.ver, + "first_seen": prev["first_seen"] if prev else int(time.time()), + "last_seen": int(time.time()), + } + # account binding: the desktop app signs in and sends its token + owner = None + if "token" in m: + owner = email_for_token(str(m.get("token") or "")) + try: + bind_device(hid, owner, host.name, host.ver, + str(m.get("mac") or "")[:32]) + except Exception as e: + log.warning("device bind failed: %s", e) + else: + touch_device(hid) + log_event("host", f"host {hid} ({host.name}) online") + log.info("host %s registered from %s (name=%s ver=%s owner=%s)", + hid, peer, host.name, host.ver, owner or "-") + _pol = _account_policy(owner) if owner else {} + await send_json(ws, {"t": "host_status", "ok": True, "msg": "registered", + "account": owner or "", "policy": _pol}) + + elif t == "session.begin" and host is not None and "sid" not in m: + # Host is never the initiator of session.begin without sid; + # this branch intentionally ignores it. + continue + + # ---- viewer: start a session -------------------------- + elif t == "session.begin": + vuser = user_from_cookie(req) + vplan, vlimit = plan_for(vuser) + hid = str(m.get("id", "")).strip() + h = HOSTS.get(hid) + if h is None or h.ws.closed: + log_event("deny", f"connect to {hid}: host offline") + await send_json(ws, {"t": "session.deny", "sid": None, "reason": "host offline"}) + continue + # Hidden host: invisible to everyone but its signed-in owner. + # Denied as "offline" so its existence isn't revealed. + if is_hidden(hid): + ow = device_owner(hid) + if not (vuser and ow and vuser.lower() == ow.lower()): + log_event("deny", f"connect to {hid}: host hidden") + await send_json(ws, {"t": "session.deny", "sid": None, "reason": "host offline"}) + continue + if h.session is not None: + # Same user hopping devices can explicitly TAKE OVER the + # existing session (TeamViewer-style) instead of being + # locked out. Without the flag we still reject. + if m.get("takeover"): + log.info("session takeover on host %s by %s", hid, peer) + await relay_session_end(h.session, "taken over from another device", None) + h.session = None + else: + log_event("deny", f"connect to {hid}: host busy") + await send_json(ws, {"t": "session.deny", "sid": None, "reason": "host busy"}) + continue + # conditional-access rules (per host-owner account): first + # matching deny stops the connection, with its reason shown + # to the viewer. Default-allow when no rule matches. + _aowner, _agroup = _device_owner_group(hid) + _aok, _areason, _arid = _eval_access( + _aowner, vuser, hid, _agroup, peer, int(time.time())) + if not _aok: + log_event("deny", f"connect to {hid} by {vuser or peer}: " + f"{_areason} (rule #{_arid})", vuser or "") + await send_json(ws, {"t": "session.deny", "sid": None, "reason": _areason}) + continue + sid = uuid.uuid4().hex + sess = Session(sid, h, ws) + if vlimit and host_is_owners(hid): + vlimit = None # owner's own PC: unlimited + sess.plan, sess.vlimit, sess.vemail = vplan, vlimit, vuser + sess.attribution = vuser or ("guest@" + peer) + sess.vip = peer or "" + h.session = sess + # Live-visitor intel: record + geo-locate the viewer's IP. + _vrec = {"ip": sess.vip, "host": hid, "host_name": h.name, + "plan": vplan, "email": vuser or "", "t": int(time.time()), "geo": {}} + RECENT_VISITORS.appendleft(_vrec) + + async def _vgeo(s=sess, r=_vrec, ip=sess.vip): + g = await geo_lookup(ip) + s.vgeo = g + r["geo"] = g + asyncio.ensure_future(_vgeo()) + viewer_sid = sid + STATS.sessions_total += 1 + log_event("session", f"viewer {peer} ({vplan}) connecting to host {hid}") + log.info("session %s: viewer %s (%s) -> host %s", sid, peer, vplan, hid) + _sbmsg = {"t": "session.begin", "sid": sid, + "peer": peer or "", "user": vuser or ""} + try: + if _aowner and _account_policy(_aowner).get("record_required"): + _rtok = _rec_mint(hid, _aowner, sid) + _sbmsg["rec"] = {"token": _rtok, "url": "/api/recordings/upload"} + log_event("recording", f"session {sid} on {hid} is policy-recorded", + _aowner) + except Exception as _e: + log.warning("rec token mint: %s", _e) + await send_json(h.ws, _sbmsg) + + async def _auth_timeout(s=sess, hh=h): + # a viewer that never finishes auth must not block the host. + # 25s (was 120): a half-open slot frees fast so a dropped + # viewer's own reconnect isn't locked out for two minutes. + await asyncio.sleep(25) + if hh.session is s and not s.active: + hh.session = None + await relay_session_end(s, "authentication timed out", None) + asyncio.ensure_future(_auth_timeout()) + + # ---- host telemetry (CPU/RAM/disk/battery) -> portal ---- + elif t == "host.telemetry" and host is not None: + host.telemetry = { + "cpu": m.get("cpu"), "ram_used": m.get("ram_used"), + "ram_total": m.get("ram_total"), "disk_used": m.get("disk_used"), + "disk_total": m.get("disk_total"), "battery": m.get("battery"), + "charging": m.get("charging"), "uptime": m.get("uptime"), + "ts": int(time.time()), + } + + # ---- everything else: relay between the two peers ----- + else: + sid = m.get("sid") + sess = None + if host is not None: + sess = host.session if (host.session and host.session.sid == sid) else None + if sess is None and not sid and host.session and t == "session.deny": + sess = host.session # older hosts deny without a sid + if sess is None and viewer_sid and sid == viewer_sid: + # find via host registry (viewer side of the link) + for h in HOSTS.values(): + if h.session and h.session.sid == sid and h.session.viewer_ws is ws: + sess = h.session + break + if sess is None: + continue + + # viewer asks for ICE servers (STUN + nearest TURN) for the + # P2P fast path - answered by the server, never relayed + if t == "rtc.config.req" and host is None and sess.viewer_ws is ws: + await send_json(ws, {"t": "rtc.config", "sid": sess.sid, + "iceServers": await ice_servers()}) + continue + + if t == "session.accept" and host is not None: + sess.active = True + log_event("session", f"session accepted on host {sess.host.id}", + actor=sess.attribution) + fire_webhook(_device_owner(sess.host.id), "connect", + {"host_id": sess.host.id, "viewer": sess.vemail or ""}) + plan_msg = {"t": "session.plan", "plan": sess.plan, + "limit": sess.vlimit or 0, "email": sess.vemail or ""} + await send_json(sess.viewer_ws, plan_msg) + if sess.vlimit: + async def _limit_killer(s=sess): + await asyncio.sleep(s.vlimit) + if s.active: + log_event("session", f"time limit on host {s.host.id}") + await relay_session_end( + s, "session time limit reached - reconnect or sign up", + None) + if s.host: + s.host.session = None + asyncio.ensure_future(_limit_killer(sess)) + await send_json(sess.viewer_ws, m) + elif t == "session.deny": + reason = m.get("reason", "denied") + if reason in ("wrong password", "bad 2FA code"): + log_event("auth-fail", f"{reason} on host {sess.host.id}") + else: + log_event("deny", f"connect to {sess.host.id}: {reason}") + sess.host.session = None + await send_json(sess.viewer_ws, m) + elif t == "session.end": + await relay_session_end(sess, m.get("reason", "closed"), ws) + log_event("session", f"session on host {sess.host.id} ended ({m.get('reason', 'closed')})") + if sess.host: + sess.host.session = None + else: + dest = sess.other(ws) + if dest is not None: + await send_json(dest, m) + + elif msg.type == WSMsgType.BINARY: + # TCP-tunnel frames from a host: [0xF3][tid u32][payload] + if msg.data and msg.data[0] == 0xF3 and len(msg.data) >= 5: + tid = int.from_bytes(msg.data[1:5], "little") + tws = TUNNELS.get(tid) + if tws is not None and not tws.closed: + try: + await tws.send_bytes(msg.data[5:]) + except Exception: + pass + continue + # Opaque relay: find session for this socket, forward bytes. + sess = None + if host is not None and host.session and host.session.active: + sess = host.session + elif viewer_sid: + for h in HOSTS.values(): + if h.session and h.session.sid == viewer_sid and h.session.viewer_ws is ws and h.session.active: + sess = h.session + break + if sess is not None: + dest = sess.other(ws) + if dest is not None: + STATS.bytes_relayed += len(msg.data) + try: + await dest.send_bytes(msg.data) + except Exception: + pass + + elif msg.type == WSMsgType.ERROR: + break + finally: + if host is not None and HOSTS.get(host.id) is host: + HOSTS.pop(host.id, None) + k = KNOWN_HOSTS.get(host.id) + if k: + k["last_seen"] = int(time.time()) + log_event("host", f"host {host.id} ({host.name}) offline") + if host.session: + await relay_session_end(host.session, "host disconnected", ws) + if viewer_sid: + for h in list(HOSTS.values()): + if h.session and h.session.sid == viewer_sid and h.session.viewer_ws is ws: + await relay_session_end(h.session, "viewer disconnected", ws) + h.session = None + log.info("connection closed from %s", peer) + return ws + + +# ---------------------------------------------------------------- self-signed cert + +def gen_selfsigned(cert_path, key_path, domain): + from cryptography import x509 + from cryptography.x509.oid import NameOID + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import ec + import datetime, ipaddress + + key = ec.generate_private_key(ec.SECP256R1()) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, domain or "netdesk")]) + now = datetime.datetime.now(datetime.timezone.utc) + san = x509.SubjectAlternativeName( + [x509.DNSName(domain)] if domain + else [x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))] + ) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=3650)) + .add_extension(san, critical=False) + .sign(key, hashes.SHA256()) + ) + with open(cert_path, "wb") as f: + f.write(cert.public_bytes(serialization.Encoding.PEM)) + with open(key_path, "wb") as f: + f.write(key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + )) + fp = cert.fingerprint(hashes.SHA256()).hex() + print(f"[netdesk] self-signed cert written: {cert_path}") + print(f"[netdesk] SHA256 fingerprint: {fp}") + print("[netdesk] Browsers will warn until you accept it (or use Let's Encrypt in production).") + + +# ---------------------------------------------------------------- main + +ARGS = None + + +def build_app(): + app = web.Application() + # public infra (protected by reg-key / cookies / tokens) + app.router.add_get("/health", http_health) + app.router.add_get("/status", http_status) + app.router.add_get("/ws", ws_handler) + app.router.add_get("/robots.txt", http_robots) + app.router.add_post("/portal/login", portal_login) + app.router.add_get("/portal/logout", portal_logout) + app.router.add_get("/portal/api/overview", portal_overview) + app.router.add_get("/portal/api/audit", portal_audit) + app.router.add_get("/portal/api/audit.csv", portal_audit_csv) + app.router.add_get("/portal/api/users", portal_users) + app.router.add_post("/portal/api/user/delete", portal_user_delete) + app.router.add_post("/portal/api/host/delete", portal_host_delete) + app.router.add_post("/portal/api/host/hide", portal_host_hide) + app.router.add_post("/portal/api/session/disconnect", portal_session_disconnect) + app.router.add_get("/dl/{name}", portal_download) + app.router.add_get("/tunnel/{hid}", tunnel_ws) + app.router.add_get("/download/{name}", public_download) + # viewer email accounts + app.router.add_post("/api/auth/signup", auth_signup) + app.router.add_post("/api/auth/login", auth_login) + app.router.add_post("/api/auth/forgot", auth_forgot) + app.router.add_post("/api/auth/reset", auth_reset) + app.router.add_get("/api/auth/verify", auth_verify) + app.router.add_get("/api/auth/me", auth_me) + app.router.add_post("/api/auth/logout", auth_logout) + app.router.add_get("/api/auth/oidc/{provider}/start", auth_oidc_start) + app.router.add_get("/api/auth/oidc/{provider}/callback", auth_oidc_callback) + app.router.add_post("/api/keys", api_keys_create) + app.router.add_get("/api/keys", api_keys_list) + app.router.add_post("/api/keys/revoke", api_keys_revoke) + app.router.add_get("/api/v1/devices", v1_devices) + app.router.add_get("/api/v1/webhooks", v1_webhooks_list) + app.router.add_post("/api/v1/webhooks", v1_webhooks_add) + app.router.add_post("/api/v1/webhooks/remove", v1_webhooks_remove) + app.router.add_post("/api/v1/webhooks/test", v1_webhooks_test) + app.router.add_get("/api/devices", api_devices) + app.router.add_post("/api/devices/remove", api_device_remove) + app.router.add_post("/api/devices/meta", api_device_meta) + app.router.add_get("/api/teams", api_teams) + app.router.add_post("/api/teams", api_team_create) + app.router.add_post("/api/teams/rename", api_team_rename) + app.router.add_post("/api/teams/delete", api_team_delete) + app.router.add_get("/api/teams/members", api_team_members) + app.router.add_post("/api/teams/members", api_team_member_add) + app.router.add_post("/api/teams/members/remove", api_team_member_remove) + app.router.add_get("/api/teams/devices", api_team_devices) + app.router.add_post("/api/teams/devices", api_team_device_add) + app.router.add_post("/api/teams/devices/remove", api_team_device_remove) + app.router.add_get("/api/access/rules", api_access_rules) + app.router.add_post("/api/access/rules", api_access_rule_set) + app.router.add_post("/api/access/rules/delete", api_access_rule_delete) + app.router.add_get("/api/access/test", api_access_test) + app.router.add_post("/api/recordings/upload", api_rec_upload) + app.router.add_get("/api/recordings", api_recordings) + app.router.add_get("/api/recordings/{id}/download", api_rec_download) + app.router.add_post("/api/recordings/delete", api_rec_delete) + app.router.add_get("/saml/metadata", saml_metadata) + app.router.add_get("/saml/login", saml_login) + app.router.add_post("/saml/acs", saml_acs) + app.router.add_get("/api/auth/sso", api_auth_sso) + # SCIM 2.0 provisioning (Bearer-token authed) + app.router.add_get("/scim/v2/ServiceProviderConfig", scim_spc) + app.router.add_get("/scim/v2/Users", scim_users_list) + app.router.add_post("/scim/v2/Users", scim_user_create) + app.router.add_get("/scim/v2/Users/{id}", scim_user_get) + app.router.add_put("/scim/v2/Users/{id}", scim_user_put) + app.router.add_route("PATCH", "/scim/v2/Users/{id}", scim_user_patch) + app.router.add_delete("/scim/v2/Users/{id}", scim_user_delete) + app.router.add_get("/scim/v2/Groups", scim_groups_list) + app.router.add_post("/scim/v2/Groups", scim_group_create) + app.router.add_get("/scim/v2/Groups/{id}", scim_group_get) + app.router.add_put("/scim/v2/Groups/{id}", scim_group_put) + app.router.add_route("PATCH", "/scim/v2/Groups/{id}", scim_group_patch) + app.router.add_delete("/scim/v2/Groups/{id}", scim_group_delete) + app.router.add_get("/api/admin/scim/tokens", api_scim_tokens_list) + app.router.add_post("/api/admin/scim/tokens", api_scim_token_create) + app.router.add_post("/api/admin/scim/tokens/revoke", api_scim_token_revoke) + app.router.add_post("/api/admin/role", api_admin_role) + app.router.add_get("/api/admin/users", api_admin_users) + app.router.add_get("/api/admin/overview", api_admin_overview) + app.router.add_get("/api/admin/teams", api_admin_teams) + app.router.add_get("/api/admin/policy", api_admin_policy) + app.router.add_post("/api/admin/policy", api_admin_policy) + app.router.add_get("/api/v1/policy", v1_policy_get) + app.router.add_post("/api/v1/policy", v1_policy_set) + app.router.add_post("/api/admin/license", api_admin_license) + app.router.add_get("/api/relays", api_relays) + app.router.add_get("/api/relay/best", api_relay_best) + app.router.add_get("/api/auth/2fa/status", auth_2fa_status) + app.router.add_post("/api/auth/2fa/setup", auth_2fa_setup) + app.router.add_post("/api/auth/2fa/enable", auth_2fa_enable) + app.router.add_post("/api/auth/2fa/disable", auth_2fa_disable) + app.router.add_post("/api/wake", api_wake) + app.router.add_get("/api/info", api_info) + app.router.add_post("/api/contact", api_contact) + app.router.add_get("/sitemap.xml", http_sitemap) + + # a static-serve helper for the marketing pages + def serve(fname): + return lambda r: serve_file(fname) + + marketing = ["features", "pricing", "security", "download", "contact", + "about", "terms", "privacy", "refund"] + + # UI pages: marketing site at root, web app under /app + pages = [("/", http_index), ("/index.html", http_index), + ("/app", http_app), ("/app.html", http_app), + ("/app.js", serve("app.js")), ("/host.js", serve("host.js")), + ("/app.css", serve("app.css")), + ("/site.css", serve("site.css")), ("/site.js", serve("site.js")), + ("/admin", serve("admin.html")), ("/admin.js", serve("admin.js")), + ("/portal", portal_page), + ("/portal.js", serve("portal.js")), + ("/manifest.webmanifest", serve("manifest.webmanifest")), + ("/ndicon.svg", serve("ndicon.svg"))] + for slug in marketing: + pages.append(("/" + slug, serve(slug + ".html"))) + pages.append(("/" + slug + ".html", serve(slug + ".html"))) + if ARGS.access_path: + for path, handler in pages: + app.router.add_get("/" + ARGS.access_path + ("" if path == "/" else path), handler) + else: + for path, handler in pages: + app.router.add_get(path, handler) + return app + + +def main(): + global ARGS + ap = argparse.ArgumentParser(description="NetDesk signaling/relay server") + ap.add_argument("--port", type=int, default=8443) + ap.add_argument("--bind", default="0.0.0.0") + ap.add_argument("--cert", help="TLS certificate PEM") + ap.add_argument("--key", help="TLS private key PEM") + ap.add_argument("--gen-cert", action="store_true", help="generate a self-signed cert and use it") + ap.add_argument("--domain", help="DNS name for --gen-cert SAN") + ap.add_argument("--reg-key", help="secret hosts must present to register (recommended)") + ap.add_argument("--status-key", help="secret required for /status details") + ap.add_argument("--portal-password", dest="portal_password", + help="password for the web management portal") + ap.add_argument("--access-path", dest="access_path", + help="secret URL segment hosting the UI (random hash); root serves 404") + ap.add_argument("--owner-email", dest="owner_email", default="", + help="email with unrestricted access + admin panel") + # TURN tier of the hybrid transport (P2P -> TURN -> WSS relay). + # Cloudflare Realtime TURN is anycast: peers relay via their NEAREST city. + ap.add_argument("--turn-key-id", dest="turn_key_id", + default=os.environ.get("ND_TURN_KEY_ID", ""), + help="Cloudflare Realtime TURN key id (mints short-lived creds)") + ap.add_argument("--turn-token", dest="turn_token", + default=os.environ.get("ND_TURN_TOKEN", ""), + help="Cloudflare Realtime TURN key API token") + ap.add_argument("--turn-urls", dest="turn_urls", + default=os.environ.get("ND_TURN_URLS", ""), + help="static TURN urls (comma sep), e.g. turn:host:3478?transport=udp") + ap.add_argument("--turn-user", dest="turn_user", default=os.environ.get("ND_TURN_USER", "")) + ap.add_argument("--turn-pass", dest="turn_pass", default=os.environ.get("ND_TURN_PASS", "")) + # Force every session onto the WSS relay (no ICE handed out -> no P2P). A + # privacy/compliance control: peer IPs are never exposed and all traffic + # flows through this server. (RustDesk's ALWAYS_USE_RELAY.) + ap.add_argument("--always-use-relay", dest="always_use_relay", action="store_true", + default=os.environ.get("ND_ALWAYS_USE_RELAY", "") not in ("", "0", "false", "no"), + help="hand out no STUN/TURN so all sessions stay on the relay") + # self-hosted coturn with use-auth-secret: the server mints 12h HMAC creds + ap.add_argument("--turn-host", dest="turn_host", default=os.environ.get("ND_TURN_HOST", ""), + help="hostname of our own coturn (3478 udp/tcp + 5349 tls)") + ap.add_argument("--turn-secret", dest="turn_secret", default=os.environ.get("ND_TURN_SECRET", ""), + help="coturn static-auth-secret (TURN REST API credentials)") + ap.add_argument("--oidc", default=os.environ.get("ND_OIDC", ""), + help='SSO providers as JSON, e.g. ' + '{"google":{"issuer":"https://accounts.google.com","client_id":"..","client_secret":".."}}') + ap.add_argument("--mail-from", dest="mail_from", default="", + help="sender address enabling email accounts, e.g. noreply@netdesk.space") + ap.add_argument("--smtp-host", dest="smtp_host", default="127.0.0.1", + help="SMTP relay for verification mail (default localhost postfix)") + ap.add_argument("--public-url", dest="public_url", default="", + help="public base URL used in email links, e.g. https://netdesk.space") + ap.add_argument("--portal-totp", dest="portal_totp", default=os.environ.get("ND_PORTAL_TOTP", ""), + help="base32 TOTP secret to also require at portal login (optional 2FA)") + ap.add_argument("--relays", default=os.environ.get("ND_RELAYS", ""), + help="JSON array of relay nodes [{id,region,url,lat,lon,tier}] " + "for nearest-relay geo selection (default: a single self-entry)") + ap.add_argument("--ldap-url", dest="ldap_url", default=os.environ.get("ND_LDAP_URL", ""), + help="ldap://host:389 or ldaps://host:636 to enable LDAP/AD login") + ap.add_argument("--ldap-bind-template", dest="ldap_bind_template", + default=os.environ.get("ND_LDAP_BIND", "{email}"), + help="bind DN template; {user}=local part, {email}=full, {domain}=domain " + "(AD UPN: '{email}'; OpenLDAP: 'uid={user},ou=people,dc=ex,dc=com')") + ap.add_argument("--ldap-default-role", dest="ldap_default_role", + default=os.environ.get("ND_LDAP_ROLE", "member")) + ap.add_argument("--ldap-timeout", dest="ldap_timeout", type=int, + default=int(os.environ.get("ND_LDAP_TIMEOUT", "6"))) + ap.add_argument("--ldap-insecure", dest="ldap_insecure", action="store_true", + default=bool(os.environ.get("ND_LDAP_INSECURE")), + help="skip TLS cert validation for ldaps:// (internal CA self-signed only)") + ap.add_argument("--recording-retention-days", dest="recording_retention_days", type=int, + default=int(os.environ.get("ND_REC_RETENTION", "0")), + help="auto-delete central recordings older than N days (0 = keep forever)") + ap.add_argument("--saml-idp-sso-url", dest="saml_idp_sso_url", + default=os.environ.get("ND_SAML_SSO_URL", ""), + help="IdP SSO (SingleSignOnService) URL to enable SAML 2.0 SSO") + ap.add_argument("--saml-idp-cert", dest="saml_idp_cert", + default=os.environ.get("ND_SAML_IDP_CERT", ""), + help="IdP X.509 signing certificate (PEM file path or inline PEM)") + ap.add_argument("--saml-idp-entity-id", dest="saml_idp_entity_id", + default=os.environ.get("ND_SAML_IDP_ENTITY", ""), + help="expected IdP entityID (Issuer) to validate (optional)") + ap.add_argument("--saml-sp-entity-id", dest="saml_sp_entity_id", + default=os.environ.get("ND_SAML_SP_ENTITY", ""), + help="this SP's entityID (default: /saml/metadata)") + ap.add_argument("--saml-default-role", dest="saml_default_role", + default=os.environ.get("ND_SAML_ROLE", "member")) + ap.add_argument("--saml-enforce", dest="saml_enforce", action="store_true", + default=bool(os.environ.get("ND_SAML_ENFORCE")), + help="advertise SSO as mandatory to the login UI") + ap.add_argument("--verbose", action="store_true") + ARGS = ap.parse_args() + + logging.basicConfig( + level=logging.DEBUG if ARGS.verbose else logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + ) + + ssl_ctx = None + if ARGS.gen_cert: + d = os.path.dirname(os.path.abspath(__file__)) + cert_path, key_path = os.path.join(d, "selfsigned.crt"), os.path.join(d, "selfsigned.key") + gen_selfsigned(cert_path, key_path, ARGS.domain) + ARGS.cert, ARGS.key = cert_path, key_path + if ARGS.cert and ARGS.key: + ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ssl_ctx.load_cert_chain(ARGS.cert, ARGS.key) + print(f"[netdesk] TLS enabled ({ARGS.cert})") + else: + print("[netdesk] WARNING: running without TLS - fine on localhost/LAN, " + "NOT for the public internet. Use --cert/--key or --gen-cert.") + + if ARGS.reg_key: + print("[netdesk] host registration key required (good).") + if ARGS.portal_password: + os.makedirs(FILES_DIR, exist_ok=True) + print(f"[netdesk] web portal enabled at /portal (downloads: {FILES_DIR})") + + _load_audit() + app = build_app() + scheme = "https/wss" if ssl_ctx else "http/ws" + print(f"[netdesk] server v{VERSION} listening on {scheme}://{ARGS.bind}:{ARGS.port}") + print(f"[netdesk] web viewer: {'https' if ssl_ctx else 'http'}://:{ARGS.port}/") + web.run_app(app, host=ARGS.bind, port=ARGS.port, ssl_context=ssl_ctx, print=None, access_log=None) + + +if __name__ == "__main__": + main() diff --git a/server/web/about.html b/server/web/about.html new file mode 100644 index 0000000..901ead6 --- /dev/null +++ b/server/web/about.html @@ -0,0 +1,42 @@ + + + + + +About — NetDesk + + + + + + + + + + + + + + +
+

About NetDesk

Building remote access that respects your ownership.

+

NetDesk exists because remote access shouldn't mean handing your screen, your files and your trust to someone else's +cloud. Tools like TeamViewer and AnyDesk are powerful, but they route your sessions through their servers and charge +per seat. RustDesk proved a self-hosted model works — NetDesk takes it further with a first-class web client, an +account and device system, and a polished desktop app.

+

Our principles

+
    +
  • You own it. Your server, your keys, your data. No NetDesk cloud in the path.
  • +
  • Private by default. End-to-end encryption is on out of the box, not an upsell.
  • +
  • Open source. Every line is auditable. Trust is earned by transparency.
  • +
  • Free for individuals. The core is free forever; teams pay for convenience, not for the right to connect.
  • +
+

Built for scale

+

NetDesk runs a stateless relay you can deploy on a $5 VPS or a fleet of them. It handles NAT traversal without port +forwarding, survives reconnections, and streams hardware-accelerated H.264 at up to 1080p60.

+

We're just getting started. Talk to us or browse the full source.

+
+ + + + \ No newline at end of file diff --git a/server/web/admin.html b/server/web/admin.html new file mode 100644 index 0000000..d37ee59 --- /dev/null +++ b/server/web/admin.html @@ -0,0 +1,230 @@ + + + + + +NetDesk — Admin Console + + + + + + + + +
Checking access…
+ + +
+ + + diff --git a/server/web/admin.js b/server/web/admin.js new file mode 100644 index 0000000..341f484 --- /dev/null +++ b/server/web/admin.js @@ -0,0 +1,447 @@ +/* NetDesk admin console — manages users/roles, devices, MDM policy, API keys, + relays. Account admin-role gated (the /api/admin/* endpoints return 403 for + non-admins; the server is the authority, this UI just reacts). */ +(function () { + "use strict"; + var $ = function (s) { return document.querySelector(s); }; + function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; }); } + function fmtTs(t) { return t ? new Date(t * 1000).toLocaleDateString() : "—"; } + function fmtAgo(t) { if (!t) return "never"; var s = Date.now() / 1000 - t; + if (s < 60) return "just now"; if (s < 3600) return Math.floor(s / 60) + "m ago"; + if (s < 86400) return Math.floor(s / 3600) + "h ago"; return Math.floor(s / 86400) + "d ago"; } + var toastT; + function toast(msg, bad) { var t = $("#toast"); t.textContent = msg; t.style.borderColor = bad ? "#ef4444" : ""; + t.classList.add("show"); clearTimeout(toastT); toastT = setTimeout(function () { t.classList.remove("show"); }, 2400); } + + function getJSON(p) { return fetch(p, { credentials: "same-origin", cache: "no-store" }).then(function (r) { + return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }).catch(function () { return { ok: r.ok, status: r.status, d: {} }; }); }); } + function post(p, body) { return fetch(p, { method: "POST", credentials: "same-origin", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}) }).then(function (r) { + return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }).catch(function () { return { ok: r.ok, status: r.status, d: {} }; }); }); } + + /* ---- access gate ---- */ + getJSON("/api/admin/overview").then(function (r) { + if (r.status === 401) { location.href = "/app"; return; } + if (r.status === 403) { + $("#gate").innerHTML = '

Admin access required

Your account is signed in but is not an administrator. Ask an admin to grant you the admin role, then reload.

Back to app'; + return; + } + if (!r.ok) { $("#gate").innerHTML = '

Could not reach the server.

'; return; } + $("#gate").hidden = true; $("#shell").hidden = false; + renderOverview(r.d); + wireNav(); loadUsersForPolicy(); + }); + + /* ---- nav ---- */ + var loaded = {}; + function wireNav() { + $("#nav").querySelectorAll("a").forEach(function (a) { + a.onclick = function () { + $("#nav").querySelectorAll("a").forEach(function (x) { x.classList.remove("on"); }); + a.classList.add("on"); + var p = a.dataset.p; + ["overview", "users", "devices", "policy", "keys", "relays", "teams", "access", "recordings"].forEach(function (s) { + var el = $("#p-" + s); if (el) el.hidden = s !== p; }); + if (p === "users" && !loaded.users) loadUsers(); + if (p === "devices" && !loaded.devices) loadDevices(); + if (p === "keys" && !loaded.keys) loadKeys(); + if (p === "relays" && !loaded.relays) loadRelays(); + if (p === "teams" && !loaded.teams) loadTeams(); + if (p === "access" && !loaded.access) loadAccess(); + if (p === "recordings" && !loaded.recordings) loadRecordings(); + }; + }); + } + + /* ---- overview ---- */ + function renderOverview(d) { + var items = [["users", "Accounts"], ["admins", "Administrators"], ["devices", "Registered devices"], + ["hosts_online", "Hosts online"], ["in_session", "In session now"], ["api_keys", "API keys"], ["relays", "Relay nodes"]]; + $("#ovGrid").innerHTML = items.map(function (i) { + return '
' + (d[i[0]] != null ? d[i[0]] : "—") + '' + i[1] + '
'; }).join(""); + } + + /* ---- users + roles + seats ---- */ + var USERS = []; + function loadUsers() { + loaded.users = true; + getJSON("/api/admin/users").then(function (r) { + if (!r.ok) { toast(r.d.error || "failed", true); return; } + USERS = r.d.users || []; + $("#uCount").textContent = USERS.length + " account" + (USERS.length === 1 ? "" : "s"); + $("#uBody").innerHTML = USERS.map(function (u) { + var rsel = ''; + var seat = ''; + return '' + esc(u.email) + (u.email === (r.d.me || "") ? ' (you)' : "") + '' + + '' + rsel + '' + seat + '' + (u.devices || 0) + '' + + '' + (u.twofa ? "on" : "off") + '' + + '' + (u.verified ? "yes" : "no") + ''; + }).join(""); + $("#uBody").querySelectorAll("[data-role]").forEach(function (sel) { + sel.onchange = function () { post("/api/admin/role", { email: sel.dataset.role, role: sel.value }) + .then(function (x) { toast(x.ok ? "Role updated" : (x.d.error || "failed"), !x.ok); }); }; + }); + $("#uBody").querySelectorAll("[data-seat]").forEach(function (inp) { + inp.onchange = function () { post("/api/admin/license", { email: inp.dataset.seat, seats: parseInt(inp.value, 10) || 0 }) + .then(function (x) { toast(x.ok ? "Seat limit saved" : (x.d.error || "failed"), !x.ok); }); }; + }); + }); + } + + /* ---- devices ---- */ + function loadDevices() { + loaded.devices = true; + getJSON("/api/devices?scope=all").then(function (r) { + if (!r.ok) { toast(r.d.error || "failed", true); return; } + var ds = r.d.devices || []; + $("#dCount").textContent = ds.length + " device" + (ds.length === 1 ? "" : "s"); + $("#dBody").innerHTML = ds.map(function (dv) { + var st = dv.online ? '' + (dv.busy ? "in session" : "online") : 'offline'; + var tags = (dv.tags || []).map(function (t) { return '' + esc(t) + ''; }).join("") || '—'; + return '' + esc(dv.name) + '
' + esc(dv.id) + '
' + + '' + esc(dv.owner || "—") + '' + st + '' + + '' + + '' + + ' '; + }).join("") || 'No devices yet.'; + $("#dBody").querySelectorAll("tr[data-id]").forEach(function (tr) { + var id = tr.dataset.id; + var save = tr.querySelector("[data-save]"); if (save) save.onclick = function () { + post("/api/devices/meta", { id: id, group: tr.querySelector("[data-grp]").value, tags: tr.querySelector("[data-tags]").value }) + .then(function (x) { toast(x.ok ? "Saved" : (x.d.error || "failed"), !x.ok); }); }; + var del = tr.querySelector("[data-del]"); if (del) del.onclick = function () { + if (!confirm("Remove " + id + "? It must re-register to return.")) return; + post("/api/devices/remove", { id: id }).then(function (x) { if (x.ok) { tr.remove(); toast("Removed"); } else toast(x.d.error || "failed", true); }); }; + }); + }); + } + + /* ---- MDM policy editor ---- */ + var STRKEYS = [["server", "Relay server (wss://…)"], ["reg_key", "Registration key"], ["allow_ips", "Viewer IP allowlist (CIDR, comma-sep)"], ["totp", "2FA secret (base32)"]]; + function loadUsersForPolicy() { + getJSON("/api/admin/users").then(function (r) { + var sel = $("#polAcct"); if (!sel) return; + sel.innerHTML = (r.d.users || []).map(function (u) { return ''; }).join(""); + renderPolicyForm({}); + }); + $("#polLoad").onclick = function () { + getJSON("/api/admin/policy?email=" + encodeURIComponent($("#polAcct").value)).then(function (r) { renderPolicyForm(r.d.policy || {}); toast("Loaded"); }); }; + $("#polSave").onclick = function () { + post("/api/admin/policy", { email: $("#polAcct").value, policy: collectPolicy() }) + .then(function (x) { toast(x.ok ? "Policy saved & will push on host registration" : (x.d.error || "failed"), !x.ok); if (x.ok) renderPolicyForm(x.d.policy || {}); }); }; + $("#polClear").onclick = function () { + post("/api/admin/policy", { email: $("#polAcct").value, policy: {} }).then(function (x) { toast(x.ok ? "Policy cleared" : "failed", !x.ok); renderPolicyForm({}); }); }; + } + function renderPolicyForm(pol) { + var perms = pol.perms || {}; + var h = STRKEYS.map(function (k) { + return '
'; }).join(""); + function chk(key, lbl, on) { return ''; } + h += '
' + chk("lock_on_end", "Lock the PC when the session ends", !!pol.lock_on_end) + chk("approve_click", "Require the host user to approve each connect", !!pol.approve_click) + chk("record_required", "Force central session recording (upload to the server)", !!pol.record_required) + '
'; + h += '
' + + chk("perm_input", "Keyboard / mouse input", perms.input !== false) + chk("perm_clipboard", "Clipboard sync", perms.clipboard !== false) + + chk("perm_files", "File transfer", perms.files !== false) + chk("perm_audio", "Audio", perms.audio !== false) + '
'; + $("#polForm").innerHTML = h; + } + function collectPolicy() { + var pol = {}, perms = {}; + $("#polForm").querySelectorAll("[data-pk]").forEach(function (i) { if (i.value.trim()) pol[i.dataset.pk] = i.value.trim(); }); + $("#polForm").querySelectorAll("[data-pb]").forEach(function (c) { + var k = c.dataset.pb; + if (k.indexOf("perm_") === 0) perms[k.slice(5)] = c.checked; else if (c.checked) pol[k] = true; + }); + pol.perms = perms; + return pol; + } + + /* ---- API keys ---- */ + function loadKeys() { + loaded.keys = true; + getJSON("/api/keys").then(function (r) { + var ks = (r.d && r.d.keys) || []; + $("#kBody").innerHTML = ks.map(function (k) { + return '' + esc(k.name) + '' + esc(k.key_preview) + '' + fmtTs(k.created) + '' + fmtAgo(k.last_used) + '' + + ''; }).join("") || + 'No API keys yet.'; + $("#kBody").querySelectorAll("[data-rev]").forEach(function (b) { + b.onclick = function () { if (!confirm("Revoke this key?")) return; + post("/api/keys/revoke", { key: b.dataset.rev }).then(function (x) { toast(x.ok ? "Revoked" : "failed", !x.ok); loaded.keys = false; loadKeys(); }); }; + }); + }); + $("#kNew").onclick = function () { + var name = $("#kName").value.trim() || "key"; + post("/api/keys", { name: name }).then(function (x) { + if (!x.ok) { toast(x.d.error || "failed", true); return; } + $("#kName").value = ""; $("#kNewBox").hidden = false; + $("#kNewBox").innerHTML = 'New key (copy it now — shown once): ' + esc(x.d.key) + ''; + loaded.keys = false; loadKeys(); + }); + }; + loadScim(); + $("#scNew").onclick = function () { + var label = $("#scName").value.trim() || "SCIM"; + post("/api/admin/scim/tokens", { label: label }).then(function (x) { + if (!x.ok) { toast(x.d.error || "failed", true); return; } + $("#scName").value = ""; $("#scNewBox").hidden = false; + $("#scNewBox").innerHTML = 'New SCIM token (copy now — shown once): ' + esc(x.d.token) + ''; + loadScim(); + }); + }; + } + function loadScim() { + getJSON("/api/admin/scim/tokens").then(function (r) { + if (!r.ok) return; + var ts = (r.d && r.d.tokens) || []; + if (r.d.base) $("#scBase").innerHTML = 'Point your IdP at ' + esc(r.d.base) + ' with the token as an HTTP Bearer header. Provisions accounts (Users) & teams (Groups).'; + $("#scBody").innerHTML = ts.map(function (t) { + return '' + esc(t.label) + '' + esc(t.token_preview) + '' + fmtTs(t.created) + '' + fmtAgo(t.last_used) + '' + + ''; }).join("") || + 'No provisioning tokens yet.'; + $("#scBody").querySelectorAll("[data-screv]").forEach(function (b) { + b.onclick = function () { if (!confirm("Revoke this SCIM token? The IdP will stop provisioning.")) return; + post("/api/admin/scim/tokens/revoke", { token: b.dataset.screv }).then(function (x) { toast(x.ok ? "Revoked" : "failed", !x.ok); loadScim(); }); }; + }); + }); + } + + /* ---- relays ---- */ + function loadRelays() { + loaded.relays = true; + getJSON("/api/relays").then(function (r) { + var rs = (r.d && r.d.relays) || []; + $("#rBody").innerHTML = rs.map(function (x) { + return '' + esc(x.id) + '' + esc(x.region || "—") + '' + esc(x.url) + '' + esc(x.tier || "—") + '' + x.lat + ", " + x.lon + ''; }).join(""); + }); + } + + /* ---- teams (shared address books) ---- */ + var TEAMS = [], curTeam = null, ALLDEV = []; + function refreshCounts() { loaded.teams = false; loadTeams(); } + function loadTeams() { + loaded.teams = true; + getJSON("/api/admin/teams").then(function (r) { + if (!r.ok) { toast(r.d.error || "failed", true); return; } + TEAMS = r.d.teams || []; + $("#tCount").textContent = TEAMS.length + " team" + (TEAMS.length === 1 ? "" : "s"); + $("#tList").innerHTML = TEAMS.map(function (t) { + return '
' + esc(t.name) + '' + + '
' + t.members + ' member' + (t.members === 1 ? "" : "s") + ' · ' + t.devices + ' device' + (t.devices === 1 ? "" : "s") + '
' + + '#' + t.id + '
'; + }).join("") || '
No teams yet — create one above.
'; + $("#tList").querySelectorAll("[data-tid]").forEach(function (el) { + el.onclick = function () { selectTeam(parseInt(el.dataset.tid, 10)); }; + }); + if (curTeam && TEAMS.some(function (t) { return t.id === curTeam; })) selectTeam(curTeam); + else { curTeam = null; $("#tDetail").hidden = true; } + }); + } + function selectTeam(tid) { + curTeam = tid; + var t = TEAMS.filter(function (x) { return x.id === tid; })[0]; + $("#tList").querySelectorAll("[data-tid]").forEach(function (el) { el.classList.toggle("on", parseInt(el.dataset.tid, 10) === tid); }); + $("#tDetail").hidden = false; + $("#tdName").textContent = t ? t.name : ("Team #" + tid); + loadMembers(tid); loadTeamDevices(tid); + } + function loadMembers(tid) { + getJSON("/api/teams/members?team_id=" + tid).then(function (r) { + var ms = (r.d && r.d.members) || []; + $("#tmList").innerHTML = ms.map(function (m) { + return '
' + esc(m.email) + ' ' + esc(m.role) + '' + + '
'; + }).join("") || '
No members.
'; + $("#tmList").querySelectorAll("[data-rm]").forEach(function (b) { + b.onclick = function () { + post("/api/teams/members/remove", { team_id: tid, email: b.dataset.rm }).then(function (x) { + toast(x.ok ? "Removed" : (x.d.error || "failed"), !x.ok); if (x.ok) refreshCounts(); }); + }; + }); + }); + } + function loadTeamDevices(tid) { + getJSON("/api/teams/devices?team_id=" + tid).then(function (r) { + var ds = (r.d && r.d.devices) || [], shared = {}; + $("#tdDevList").innerHTML = ds.map(function (d) { + shared[d.id] = true; + return '
' + (d.online ? '' : '') + esc(d.name) + + ' ' + esc(d.owner || "") + '
'; + }).join("") || '
No shared devices.
'; + $("#tdDevList").querySelectorAll("[data-unshare]").forEach(function (b) { + b.onclick = function () { + post("/api/teams/devices/remove", { team_id: tid, device_id: b.dataset.unshare }).then(function (x) { + toast(x.ok ? "Unshared" : (x.d.error || "failed"), !x.ok); if (x.ok) refreshCounts(); }); + }; + }); + var fill = function () { + $("#tdDev").innerHTML = ALLDEV.filter(function (d) { return !shared[d.id]; }).map(function (d) { + return ''; + }).join("") || ''; + }; + if (ALLDEV.length) fill(); + else getJSON("/api/devices?scope=all").then(function (rr) { ALLDEV = (rr.d && rr.d.devices) || []; fill(); }); + }); + } + (function wireTeams() { + var nw = $("#tNew"); if (nw) nw.onclick = function () { + var name = $("#tName").value.trim(); if (!name) { toast("Enter a team name", true); return; } + post("/api/teams", { name: name }).then(function (x) { + if (!x.ok) { toast(x.d.error || "failed", true); return; } + $("#tName").value = ""; toast("Team created"); curTeam = x.d.id; refreshCounts(); }); + }; + var ad = $("#tmAdd"); if (ad) ad.onclick = function () { + if (!curTeam) return; var em = $("#tmEmail").value.trim(); if (!em) { toast("Enter an account email", true); return; } + post("/api/teams/members", { team_id: curTeam, email: em, role: $("#tmRole").value }).then(function (x) { + toast(x.ok ? "Member added" : (x.d.error || "failed"), !x.ok); if (x.ok) { $("#tmEmail").value = ""; refreshCounts(); } }); + }; + var sh = $("#tdShare"); if (sh) sh.onclick = function () { + if (!curTeam) return; var id = $("#tdDev").value; if (!id) { toast("No device selected", true); return; } + post("/api/teams/devices", { team_id: curTeam, device_id: id }).then(function (x) { + toast(x.ok ? "Device shared" : (x.d.error || "failed"), !x.ok); if (x.ok) refreshCounts(); }); + }; + var rn = $("#tdRename"); if (rn) rn.onclick = function () { + if (!curTeam) return; + var cur = (TEAMS.filter(function (x) { return x.id === curTeam; })[0] || {}).name || ""; + var name = prompt("Rename team", cur); if (name == null || !name.trim()) return; + post("/api/teams/rename", { team_id: curTeam, name: name.trim() }).then(function (x) { + toast(x.ok ? "Renamed" : (x.d.error || "failed"), !x.ok); if (x.ok) refreshCounts(); }); + }; + var dl = $("#tdDelete"); if (dl) dl.onclick = function () { + if (!curTeam) return; + if (!confirm("Delete this team? Members lose the shared list (the devices themselves are not deleted).")) return; + post("/api/teams/delete", { team_id: curTeam }).then(function (x) { + toast(x.ok ? "Team deleted" : (x.d.error || "failed"), !x.ok); + if (x.ok) { curTeam = null; $("#tDetail").hidden = true; refreshCounts(); } }); + }; + })(); + + /* ---- session recordings ---- */ + function fmtSize(b) { b = +b || 0; if (b < 1024) return b + " B"; if (b < 1048576) return (b / 1024).toFixed(1) + " KB"; if (b < 1073741824) return (b / 1048576).toFixed(1) + " MB"; return (b / 1073741824).toFixed(2) + " GB"; } + function fmtDur(s) { s = +s || 0; if (!s) return "—"; var m = Math.floor(s / 60), ss = s % 60; return m + ":" + ("" + ss).padStart(2, "0"); } + function loadRecordings() { + loaded.recordings = true; + getJSON("/api/recordings").then(function (r) { + if (!r.ok) { toast(r.d.error || "failed", true); return; } + var rs = r.d.recordings || []; + $("#recRetention").textContent = r.d.retention_days > 0 ? ("Auto-deleted after " + r.d.retention_days + " days.") : "Kept indefinitely (no retention limit set)."; + $("#recCount").textContent = rs.length + " recording" + (rs.length === 1 ? "" : "s"); + $("#recBody").innerHTML = rs.map(function (x) { + return '' + x.id + '' + esc(x.host_id) + '' + esc(x.owner || "—") + '' + + '' + esc(x.viewer || "—") + '' + fmtSize(x.size) + '' + fmtDur(x.duration) + '' + + '' + fmtTs(x.created) + '' + + 'Download '; + }).join("") || 'No recordings yet. Set a record_required MDM policy on an account, then its hosts upload session recordings here.'; + $("#recBody").querySelectorAll("[data-recdel]").forEach(function (b) { + b.onclick = function () { if (!confirm("Delete this recording permanently?")) return; + post("/api/recordings/delete", { id: +b.dataset.recdel }).then(function (x) { toast(x.ok ? "Deleted" : (x.d.error || "failed"), !x.ok); loadRecordings(); }); }; + }); + }); + } + + /* ---- conditional access rules ---- */ + var DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + var editRuleId = null; + function minsToHHMM(m) { if (m == null || m === "") return ""; m = +m; return (("" + Math.floor(m / 60)).padStart(2, "0")) + ":" + (("" + (m % 60)).padStart(2, "0")); } + function hhmmToMins(s) { s = (s || "").trim(); if (!s) return null; var p = s.split(":"); if (p.length !== 2) return null; var h = +p[0], m = +p[1]; if (isNaN(h) || isNaN(m)) return null; return h * 60 + m; } + function acOwner() { var s = $("#acAcct"); return (s && s.value) || ""; } + function loadAccess() { + loaded.access = true; + // day checkboxes + $("#acDays").innerHTML = DAYS.map(function (d, i) { return ''; }).join(""); + // account selector (reuse users list) + getJSON("/api/admin/users").then(function (r) { + var us = (r.d && r.d.users) || [], me = (r.d && r.d.me) || ""; + $("#acAcct").innerHTML = us.map(function (u) { return ''; }).join(""); + loadRules(); + }); + var lb = $("#acLoad"); if (lb) lb.onclick = loadRules; + } + function condText(r) { + var bits = []; + if (r.cidr) bits.push("from " + esc(r.cidr)); + if (r.weekdays) bits.push(r.weekdays.split(",").map(function (i) { return DAYS[+i] || i; }).join(" ")); + if (r.time_start != null && r.time_end != null) bits.push(minsToHHMM(r.time_start) + "–" + minsToHHMM(r.time_end) + (r.tz_offset ? " (UTC" + (r.tz_offset >= 0 ? "+" : "") + (r.tz_offset / 60) + ")" : " UTC")); + return bits.join(" · ") || 'any'; + } + function subjText(r) { + if (r.subject_type === "any") return 'any connection'; + return esc(r.subject_type) + ": " + esc(r.subject || "—") + ""; + } + function loadRules() { + var owner = acOwner(); + getJSON("/api/access/rules?owner=" + encodeURIComponent(owner)).then(function (r) { + if (!r.ok) { toast(r.d.error || "failed", true); return; } + var rs = r.d.rules || []; + $("#acBody").innerHTML = rs.map(function (x) { + return '' + x.priority + '' + + '' + x.action + '' + + '' + subjText(x) + '' + condText(x) + '' + + '' + esc(x.reason || "") + '' + + '' + + ' '; + }).join("") || 'No rules — every connection is allowed. Add a rule below.'; + $("#acBody").querySelectorAll("[data-del]").forEach(function (b) { + b.onclick = function () { if (!confirm("Delete this rule?")) return; + post("/api/access/rules/delete", { id: +b.dataset.del }).then(function (x) { toast(x.ok ? "Deleted" : "failed", !x.ok); loadRules(); }); }; + }); + $("#acBody").querySelectorAll("[data-toggle]").forEach(function (cb) { + cb.onchange = function () { var rule = rs.filter(function (z) { return z.id === +cb.dataset.toggle; })[0]; if (!rule) return; + post("/api/access/rules", ruleBody(rule, { enabled: cb.checked })).then(function (x) { toast(x.ok ? (cb.checked ? "Enabled" : "Disabled") : "failed", !x.ok); }); }; + }); + $("#acBody").querySelectorAll("[data-edit]").forEach(function (b) { + b.onclick = function () { var rule = rs.filter(function (z) { return z.id === +b.dataset.edit; })[0]; if (rule) fillRuleForm(rule); }; + }); + }); + } + function ruleBody(base, over) { + var b = { owner: acOwner(), id: base.id, priority: base.priority, action: base.action, subject_type: base.subject_type, + subject: base.subject, cidr: base.cidr, weekdays: base.weekdays, time_start: base.time_start, time_end: base.time_end, + tz_offset: base.tz_offset, reason: base.reason, enabled: base.enabled }; + for (var k in (over || {})) b[k] = over[k]; + return b; + } + function fillRuleForm(r) { + editRuleId = r.id; + $("#acFormTitle").textContent = "Edit rule #" + r.id; + $("#acReset").hidden = false; + $("#acPrio").value = r.priority; $("#acAction").value = r.action; $("#acSubType").value = r.subject_type; + $("#acSubject").value = r.subject || ""; $("#acCidr").value = r.cidr || ""; $("#acReason").value = r.reason || ""; + $("#acTimeStart").value = minsToHHMM(r.time_start); $("#acTimeEnd").value = minsToHHMM(r.time_end); $("#acTz").value = r.tz_offset || 0; + var days = (r.weekdays || "").split(","); + $("#acDays").querySelectorAll("[data-day]").forEach(function (cb) { cb.checked = days.indexOf(cb.dataset.day) >= 0; }); + try { $("#acFormTitle").scrollIntoView({ behavior: "smooth", block: "center" }); } catch (e) {} + } + function resetRuleForm() { + editRuleId = null; $("#acFormTitle").textContent = "Add a rule"; $("#acReset").hidden = true; + $("#acPrio").value = 100; $("#acAction").value = "deny"; $("#acSubType").value = "any"; + $("#acSubject").value = ""; $("#acCidr").value = ""; $("#acReason").value = ""; $("#acTimeStart").value = ""; $("#acTimeEnd").value = ""; $("#acTz").value = 0; + $("#acDays").querySelectorAll("[data-day]").forEach(function (cb) { cb.checked = false; }); + } + (function wireAccess() { + var sv = $("#acSave"); if (sv) sv.onclick = function () { + var days = []; + $("#acDays").querySelectorAll("[data-day]").forEach(function (cb) { if (cb.checked) days.push(cb.dataset.day); }); + var body = { owner: acOwner(), priority: +$("#acPrio").value || 100, action: $("#acAction").value, + subject_type: $("#acSubType").value, subject: $("#acSubject").value.trim(), cidr: $("#acCidr").value.trim(), + reason: $("#acReason").value.trim(), weekdays: days.join(","), tz_offset: +$("#acTz").value || 0, + time_start: hhmmToMins($("#acTimeStart").value), time_end: hhmmToMins($("#acTimeEnd").value), enabled: true }; + if (editRuleId) body.id = editRuleId; + post("/api/access/rules", body).then(function (x) { + toast(x.ok ? "Rule saved" : (x.d.error || "failed"), !x.ok); + if (x.ok) { resetRuleForm(); loadRules(); } }); + }; + var rs = $("#acReset"); if (rs) rs.onclick = resetRuleForm; + var tr = $("#tRun"); if (tr) tr.onclick = function () { + var q = new URLSearchParams({ owner: acOwner(), viewer: $("#tViewer").value.trim(), host: $("#tHost").value.trim(), group: $("#tGroup").value.trim(), ip: $("#tIp").value.trim() }); + getJSON("/api/access/test?" + q.toString()).then(function (x) { + if (!x.ok) { $("#tResult").innerHTML = '' + esc(x.d.error || "error") + ''; return; } + var d = x.d; + $("#tResult").innerHTML = d.allowed + ? '✓ ALLOWED' + (d.rule_id ? ' by rule #' + d.rule_id + '' : ' (no rule matched — default allow)') + : '✕ DENIED rule #' + esc(d.rule_id) + ' — ' + esc(d.reason) + ''; + }); + }; + })(); +})(); diff --git a/server/web/app.css b/server/web/app.css new file mode 100644 index 0000000..5bb9203 --- /dev/null +++ b/server/web/app.css @@ -0,0 +1,1302 @@ +/* ================================================================ + NetDesk design system — enterprise dark, RustDesk/TeamViewer-style + ================================================================ */ +:root { + --bg: #0b0e14; + --bg2: #11151f; + --bg3: #171d2a; + --bg4: #1d2536; + --fg: #e8edf6; + --muted: #8b95a9; + --faint: #5d6777; + --accent: #3d7eff; + --accent-h: #5c93ff; + --accent-dim: rgba(61, 126, 255, .14); + --ok: #2fbf71; + --warn: #f5a524; + --danger: #e5484d; + --danger-h: #f2666b; + --border: #222b3d; + --border-soft: #1a2233; + --shadow: 0 12px 40px rgba(0, 0, 0, .5); + --r-sm: 6px; + --r-md: 10px; + --r-lg: 14px; +} +/* light (corporate) theme - TeamViewer-style */ +:root[data-theme="light"] { + --bg: #eef1f6; + --bg2: #ffffff; + --bg3: #f0f3f8; + --bg4: #e4e9f2; + --fg: #17202f; + --muted: #5b6577; + --faint: #98a1b3; + --border: #d9e0ea; + --border-soft: #e7ebf2; + --accent-dim: rgba(61, 126, 255, .10); + --shadow: 0 12px 40px rgba(23, 32, 47, .12); +} +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body { height: 100%; } +body { + font-family: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, "Inter", sans-serif; + background: var(--bg); + color: var(--fg); + font-size: 13.5px; + line-height: 1.45; + -webkit-font-smoothing: antialiased; +} +svg.ic { width: 16px; height: 16px; display: block; } + +/* ================= connect screen : split layout ================= */ +#screenConnect { min-height: 100vh; } + +.brand-pane { + flex: 1.1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + padding: 48px 56px; + background: + radial-gradient(900px 500px at -10% -10%, rgba(61, 126, 255, .16), transparent 60%), + radial-gradient(700px 500px at 110% 115%, rgba(47, 191, 113, .10), transparent 60%), + var(--bg2); + border-right: 1px solid var(--border-soft); +} +.brand-logo { display: flex; align-items: center; gap: 14px; margin-bottom: 26px; } +.brand-logo h1 { font-size: 26px; font-weight: 600; letter-spacing: .2px; } +.brand-tag { font-size: 22px; font-weight: 600; line-height: 1.3; max-width: 460px; margin-bottom: 10px; } +.brand-sub { color: var(--muted); max-width: 440px; margin-bottom: 30px; } +.brand-feats { display: flex; flex-direction: column; gap: 12px; } +.feat { display: flex; gap: 12px; align-items: flex-start; color: var(--muted); max-width: 440px; } +.feat .fic { + flex-shrink: 0; + width: 30px; height: 30px; + border-radius: 8px; + background: var(--accent-dim); + color: var(--accent-h); + display: flex; align-items: center; justify-content: center; +} +.feat b { color: var(--fg); font-weight: 600; display: block; font-size: 13.5px; } +.feat span { font-size: 12.5px; } +.brand-foot { margin-top: 40px; color: var(--faint); font-size: 12px; } + +.form-pane { + flex: 1; + min-width: 400px; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + overflow-y: auto; +} +.card { + width: 400px; + max-width: 96vw; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: var(--r-lg); + padding: 26px; + box-shadow: var(--shadow); +} +.card .logo { display: none; } +.card h2 { font-size: 17px; font-weight: 600; margin-bottom: 2px; } +.card .sub { color: var(--muted); margin-bottom: 18px; font-size: 13px; } + +label { display: block; margin: 12px 0 6px; color: var(--muted); font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .7px; } +input[type=text], input[type=password], input:not([type]) { + width: 100%; + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: var(--r-sm); + padding: 9px 12px; + font-size: 14px; + outline: none; + transition: border-color .12s, box-shadow .12s; +} +input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); } +input::placeholder { color: var(--faint); } +.pw-wrap { display: flex; gap: 6px; } +.pw-wrap input { flex: 1; } +.row { display: flex; gap: 10px; } +.grow { flex: 1; } +#inId { font-size: 17px; letter-spacing: 2px; font-variant-numeric: tabular-nums; font-weight: 600; } +.chk { + display: flex; align-items: center; gap: 8px; + color: var(--muted); text-transform: none; letter-spacing: 0; + font-size: 13px; font-weight: 400; margin: 10px 0; cursor: pointer; user-select: none; +} +.chk input { accent-color: var(--accent); width: 14px; height: 14px; } + +button.primary { + width: 100%; + margin-top: 12px; + background: linear-gradient(180deg, #4b88ff, #2f6ef2); + color: #fff; + border: 0; + border-radius: var(--r-sm); + padding: 11px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + box-shadow: 0 4px 14px rgba(61, 126, 255, .35); + transition: background .12s, filter .12s, transform .06s; +} +button.primary:hover { filter: brightness(1.07); } +button.primary:active { transform: translateY(1px); } +button.primary:disabled { opacity: .55; cursor: wait; } + +.ghost { + background: var(--bg3); + color: var(--muted); + border: 1px solid var(--border); + border-radius: var(--r-sm); + padding: 8px 10px; + cursor: pointer; + display: flex; align-items: center; justify-content: center; +} +.ghost:hover { color: var(--fg); border-color: var(--accent); } + +.error { + margin-top: 12px; + background: rgba(229, 72, 77, .10); + border: 1px solid rgba(229, 72, 77, .45); + color: #f6a9ac; + padding: 9px 12px; + border-radius: var(--r-sm); + font-size: 12.5px; +} +.hint { margin-top: 16px; color: var(--faint); font-size: 12px; line-height: 1.55; } +.hint code { background: var(--bg3); padding: 1px 5px; border-radius: 4px; } +.foot { color: var(--faint); font-size: 12px; } +.form-foot { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border-soft); display: flex; justify-content: space-between; align-items: center; } +.theme-toggle { + background: transparent; border: 1px solid var(--border); color: var(--muted); + border-radius: var(--r-sm); padding: 4px 8px; cursor: pointer; font-size: 12px; + display: inline-flex; align-items: center; gap: 6px; +} +.theme-toggle:hover { color: var(--fg); border-color: var(--accent); } +body, .card, #toolbar, .panel { transition: background-color .18s, border-color .18s, color .18s; } +#toolbar { background: var(--bg2); } + +/* address book */ +#abookWrap { margin-top: 16px; border-top: 1px solid var(--border-soft); padding-top: 12px; } +.ab-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; color: var(--muted); font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .7px; } +.ab-row { + display: flex; align-items: center; gap: 10px; + padding: 7px 8px; border-radius: var(--r-sm); cursor: pointer; +} +.ab-row:hover { background: var(--bg3); } +.ab-ava { + width: 28px; height: 28px; border-radius: 7px; flex-shrink: 0; + background: var(--accent-dim); color: var(--accent-h); + display: flex; align-items: center; justify-content: center; + font-weight: 600; font-size: 13px; +} +.ab-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ab-id { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 12px; } +.ab-shared { display: inline-block; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .4px; padding: 1px 6px; border-radius: 999px; background: rgba(77,139,255,.16); color: #9cc0ff; vertical-align: 1px; } +.ab-row .tb { padding: 3px 7px; } + +/* ================= session screen ================= */ +#screenSession { height: 100vh; display: flex; flex-direction: column; } +#toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-wrap: wrap; + background: linear-gradient(180deg, #151a26, #10141d); + border-bottom: 1px solid var(--border); + padding: 6px 10px; + min-height: 46px; + z-index: 30; +} +.tb-group { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; } +.sep { width: 1px; height: 20px; background: var(--border); margin: 0 4px; flex-shrink: 0; } +.tb-title { font-weight: 600; margin-right: 4px; font-size: 13.5px; } +.state { color: var(--muted); font-size: 12px; display: flex; align-items: center; gap: 6px; } +.state::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); } +.state.ok::before { background: var(--ok); box-shadow: 0 0 6px var(--ok); } +.state.warn::before { background: var(--warn); } +.state.err::before { background: var(--danger); } +.tb { + height: 30px; + display: inline-flex; + align-items: center; + gap: 6px; + background: transparent; + color: var(--fg); + border: 1px solid transparent; + border-radius: var(--r-sm); + padding: 0 9px; + font-size: 13px; + font-family: inherit; + cursor: pointer; + transition: background .1s, border-color .1s, color .1s; + white-space: nowrap; +} +.tb:hover { background: var(--bg3); border-color: var(--border); } +.tb:active { background: var(--bg4); } +.tb.active { background: var(--accent-dim); color: var(--accent-h); border-color: rgba(61, 126, 255, .45); } +.tb.danger:hover { background: rgba(229, 72, 77, .12); color: var(--danger-h); border-color: rgba(229, 72, 77, .4); } +.tb.rec-on { color: var(--danger-h); } +.tb.rec-on:hover { background: rgba(229, 72, 77, .12); } +select { + height: 30px; + background: var(--bg3); + color: var(--fg); + border: 1px solid var(--border); + border-radius: var(--r-sm); + padding: 0 8px; + font-size: 12.5px; + font-family: inherit; + outline: none; + cursor: pointer; +} +select:hover { border-color: var(--accent); } +.stats { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; } +#tbStats { background: var(--bg3); border: 1px solid var(--border-soft); border-radius: var(--r-sm); padding: 4px 10px; } +:root[data-theme="light"] .brand-pane { + background: + radial-gradient(900px 500px at -10% -10%, rgba(61, 126, 255, .12), transparent 60%), + radial-gradient(700px 500px at 110% 115%, rgba(47, 191, 113, .08), transparent 60%), + var(--bg2); +} +:root[data-theme="light"] #stage { background: #10141d; } + +#stage { position: relative; flex: 1; overflow: hidden; background: #05070c; } +#canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + outline: none; + cursor: none; +} +#canvas.viewonly { cursor: default; } +.banner { + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + background: rgba(229, 72, 77, .14); + border: 1px solid rgba(229, 72, 77, .5); + color: #fecaca; + padding: 8px 16px; + border-radius: var(--r-md); + font-size: 13px; + z-index: 20; + max-width: 80%; + backdrop-filter: blur(6px); +} +.banner.info { background: rgba(61, 126, 255, .14); border-color: rgba(61, 126, 255, .5); color: #cfe1ff; } +#dropHint { + position: absolute; + inset: 14px; + border: 2px dashed var(--accent); + border-radius: var(--r-lg); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: var(--accent-h); + background: rgba(61, 126, 255, .08); + z-index: 25; + pointer-events: none; +} + +/* ================= panels ================= */ +.panel { + position: absolute; + top: 54px; + right: 12px; + width: 470px; + max-width: calc(100vw - 24px); + max-height: calc(100% - 70px); + background: var(--bg2); + border: 1px solid var(--border); + border-radius: var(--r-md); + display: flex; + flex-direction: column; + box-shadow: var(--shadow); + z-index: 40; + overflow: hidden; +} +.panel-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + border-bottom: 1px solid var(--border); + font-weight: 600; + font-size: 13.5px; +} +.file-path { display: flex; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--border-soft); } +.file-path input { flex: 1; font-size: 12.5px; } +.file-list { flex: 1; overflow-y: auto; padding: 4px 0; } +.file-row { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 12px; + cursor: pointer; + font-size: 13px; +} +.file-row:hover { background: var(--bg3); } +.file-row .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.file-row .meta { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; } +.file-row .acts { display: none; gap: 2px; } +.file-row:hover .acts { display: flex; } +.file-row .acts button { background: none; border: 0; color: var(--muted); cursor: pointer; font-size: 13px; padding: 2px 5px; border-radius: 4px; } +.file-row .acts button:hover { color: var(--fg); background: var(--bg4); } +.panel-foot { display: flex; gap: 8px; padding: 8px 10px; border-top: 1px solid var(--border-soft); align-items: center; } +.panel-foot input { flex: 1; min-width: 0; font-size: 12.5px; } +#fileStatus { white-space: nowrap; } + +.chat-log { flex: 1; overflow-y: auto; padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; min-height: 220px; } +.chat-msg { + max-width: 85%; + background: var(--bg3); + border-radius: 10px 10px 10px 3px; + padding: 7px 11px; + font-size: 13px; + word-wrap: break-word; +} +.chat-msg.me { align-self: flex-end; background: var(--accent-dim); border-radius: 10px 10px 3px 10px; } +.chat-msg .who { font-size: 10.5px; color: var(--muted); margin-bottom: 2px; text-transform: uppercase; letter-spacing: .5px; } +.chat-input { display: flex; gap: 8px; padding: 8px 10px; border-top: 1px solid var(--border-soft); } + +.sys-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 12px; } +.sys-grid .tb { background: var(--bg3); border-color: var(--border); height: 36px; justify-content: center; } +.sys-grid .tb:hover { border-color: var(--accent); } + +.proc-head { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px 4px; color: var(--muted); font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .7px; border-top: 1px solid var(--border-soft); margin-top: 4px; } +#procList { max-height: 200px; } + +/* collapsible panel sections (TeamViewer-style) */ +details.sec { border-top: 1px solid var(--border-soft); } +details.sec:first-of-type { border-top: 0; } +details.sec > summary { + list-style: none; + display: flex; + align-items: center; + gap: 8px; + padding: 9px 12px; + cursor: pointer; + user-select: none; + color: var(--muted); + font-size: 11.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .7px; + transition: color .12s, background .12s; +} +details.sec > summary::-webkit-details-marker { display: none; } +details.sec > summary:hover { color: var(--fg); background: var(--bg3); } +details.sec[open] > summary { color: var(--accent-h); } +details.sec .chev { margin-left: auto; transition: transform .15s; color: var(--faint); } +details.sec[open] .chev { transform: rotate(180deg); } +details.sec summary .tb { padding: 2px 8px; font-size: 12px; } +#sysPanel { width: 380px; } +.panel-head { cursor: grab; } +.panel-head:active { cursor: grabbing; } + +/* ================= portal ================= */ +body.portal { min-height: 100vh; } +body.console { display: flex; min-height: 100vh; } +.side { + width: 230px; + flex-shrink: 0; + background: var(--bg2); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + padding: 16px 12px; + position: sticky; + top: 0; + height: 100vh; +} +.side-brand { display: flex; align-items: center; gap: 10px; padding: 4px 8px 18px; } +.side-sub { color: var(--muted); font-size: 11px; } +.side-nav { display: flex; flex-direction: column; gap: 2px; flex: 1; } +.nav-it { + display: flex; align-items: center; gap: 10px; + padding: 9px 12px; border-radius: var(--r-sm); + color: var(--muted); text-decoration: none; font-size: 13.5px; + transition: background .1s, color .1s; border: 0; background: transparent; + font-family: inherit; cursor: pointer; text-align: left; width: 100%; +} +.nav-it:hover { background: var(--bg3); color: var(--fg); } +.nav-it.on { background: var(--accent-dim); color: var(--accent-h); } +.side-foot { display: flex; flex-direction: column; gap: 2px; border-top: 1px solid var(--border-soft); padding-top: 10px; } +.mainc { flex: 1; min-width: 0; padding: 22px 24px 48px; max-width: 1100px; } +.keyrow { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border-soft); } +.keyrow:last-child { border-bottom: 0; } +.key-l { color: var(--muted); font-size: 12px; margin-bottom: 2px; } +.key-v { font-size: 13px; word-break: break-all; } +@media (max-width: 900px) { .side { display: none; } } +.portal-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 22px; + background: var(--bg2); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 10; +} +.portal-main { max-width: 1060px; margin: 0 auto; padding: 22px 16px 48px; } +.stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 12px; margin-bottom: 20px; } +.stat { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--r-md); padding: 14px; text-align: center; } +.stat-n { font-size: 24px; font-weight: 600; font-variant-numeric: tabular-nums; } +.stat-l { color: var(--muted); font-size: 11.5px; margin-top: 2px; text-transform: uppercase; letter-spacing: .6px; } +.p-card { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--r-md); padding: 16px; margin-bottom: 20px; } +.p-card h2 { font-size: 12.5px; margin-bottom: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .7px; } +.p-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } +@media (max-width: 800px) { + .p-cols { grid-template-columns: 1fr; } + .brand-pane { display: none; } + .form-pane { min-width: 0; } +} +.tbl { width: 100%; border-collapse: collapse; font-size: 13px; } +.tbl th { text-align: left; color: var(--muted); font-weight: 500; padding: 7px 8px; border-bottom: 1px solid var(--border); font-size: 11.5px; text-transform: uppercase; letter-spacing: .5px; } +.tbl td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); } +.tbl tr:hover td { background: var(--bg3); } +.mono { font-variant-numeric: tabular-nums; letter-spacing: .5px; } +.dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 8px; } +.dot.on { background: var(--ok); box-shadow: 0 0 6px var(--ok); } +.dot.off { background: var(--faint); } +a.connect { text-decoration: none; color: var(--accent-h); border: 1px solid rgba(61, 126, 255, .45); background: var(--accent-dim); } +a.tb { color: var(--fg); text-decoration: none; } +.feed { max-height: 300px; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; } +.feed-row { display: flex; gap: 10px; font-size: 13px; } +.feed-row .stats { flex-shrink: 0; } +.feed-row.kind-auth-fail, .feed-row.kind-deny { color: #f6a9ac; } +.feed-row.kind-session { color: #a9c9ff; } +.dl-row { display: flex; gap: 12px; align-items: center; padding: 8px; border-radius: var(--r-sm); color: var(--fg); text-decoration: none; } +.dl-row:hover { background: var(--bg3); } +.dl-row .name { flex: 1; } +.muted { color: var(--muted); font-size: 13px; line-height: 1.5; } +.muted code { background: var(--bg3); padding: 1px 5px; border-radius: 4px; } + +/* ================= scrollbars ================= */ +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 5px; border: 2px solid var(--bg2); } +::-webkit-scrollbar-thumb:hover { background: var(--border); } +::-webkit-scrollbar-track { background: transparent; } + +@media (max-width: 760px) { + #screenConnect { flex-direction: column; } + .brand-pane { display: none; } + .form-pane { padding: 16px; } +} + +/* auth modal + account chip + fullscreen pill */ +.modal-wrap { position: fixed; inset: 0; background: rgba(5,7,12,.7); backdrop-filter: blur(4px); + z-index: 100; display: flex; align-items: center; justify-content: center; } +.modal-wrap[hidden] { display: none; } +.modal-card { width: 360px; max-width: 94vw; background: var(--bg2); border: 1px solid var(--border); + border-radius: var(--r-lg); padding: 22px; position: relative; box-shadow: var(--shadow); } +.modal-x { position: absolute; top: 10px; right: 12px; background: none; border: 0; + color: var(--muted); font-size: 15px; cursor: pointer; } +.auth-tabs { display: flex; gap: 4px; margin-bottom: 14px; } +.atab { flex: 1; background: var(--bg3); border: 1px solid var(--border); color: var(--muted); + padding: 8px 4px; border-radius: var(--r-sm); cursor: pointer; font-size: 12.5px; font-family: inherit; } +.atab.on { background: var(--accent-dim); color: var(--accent-h); border-color: rgba(61,126,255,.45); } +.apane { display: flex; flex-direction: column; gap: 10px; } +.apane[hidden] { display: none; } +.apane .primary { margin-top: 2px; } +.auok { margin-top: 12px; background: rgba(47,191,113,.12); border: 1px solid rgba(47,191,113,.45); + color: #8fe6bd; padding: 9px 12px; border-radius: var(--r-sm); font-size: 12.5px; } +#authChip { display: inline-flex; align-items: center; gap: 6px; } +#authChip .tb { padding: 2px 8px; font-size: 11.5px; } +.fs-pill { position: absolute; top: 60px; left: 50%; transform: translateX(-50%); z-index: 45; + background: var(--accent); color: #fff; border: 0; border-radius: 999px; padding: 12px 26px; + font-size: 15px; font-weight: 600; cursor: pointer; box-shadow: 0 8px 30px rgba(0,0,0,.5); + font-family: inherit; } +#toolbar.autohide { transform: translateY(-110%); transition: transform .25s ease; } +#toolbar { transition: transform .25s ease; } + +.plan-strip { display: flex; gap: 8px; align-items: center; justify-content: center; + margin-top: 12px; font-size: 12px; color: var(--muted); flex-wrap: wrap; } +.dotsep { color: var(--faint); } +.linkbtn { background: none; border: 0; color: var(--accent-h); cursor: pointer; + font-size: 12px; padding: 0; text-decoration: underline; font-family: inherit; } +#tbStats.limit-warn { color: #f6a9ac; } + +/* ================= RustDesk-style landing ================= */ +.topnote { background: #eaf2ff; color: #1e3a6e; text-align: center; + padding: 9px 16px; font-size: 13.5px; border-bottom: 1px solid #d6e4ff; } +:root[data-theme="dark"] .topnote, body[data-theme="dark"] .topnote { background: #16233d; color: #b9d2ff; border-color: #1d3054; } +.nav { display: flex; align-items: center; justify-content: space-between; + padding: 14px 5vw; background: var(--bg2); border-bottom: 1px solid var(--border); } +.nav-brand { display: flex; align-items: center; gap: 9px; font-size: 20px; } +.nav-links { display: flex; align-items: center; gap: 22px; } +.nav-links a { color: var(--fg); text-decoration: none; font-size: 14.5px; opacity: .85; } +.nav-links a:hover { opacity: 1; color: var(--accent-h); } +.hero { display: flex; gap: 40px; align-items: center; justify-content: center; + padding: 64px 5vw 48px; flex-wrap: wrap; + background: + radial-gradient(900px 500px at 8% 0%, rgba(61,126,255,.10), transparent 55%), + radial-gradient(700px 500px at 95% 100%, rgba(61,126,255,.07), transparent 55%); +} +.hero-copy h1 { font-size: 52px; line-height: 1.08; letter-spacing: -1.2px; font-weight: 800; } +.hero-sub { font-size: 17px; color: var(--muted); max-width: 560px; margin: 18px 0 26px; line-height: 1.6; } +.hero-cta { display: flex; gap: 14px; } +.btn-big { display: inline-flex; align-items: center; gap: 8px; padding: 13px 28px; + border-radius: 999px; font-size: 15.5px; font-weight: 600; text-decoration: none; + transition: transform .08s, filter .15s, background .15s; } +.btn-blue { background: var(--accent); color: #fff; box-shadow: 0 6px 22px rgba(61,126,255,.4); } +.btn-blue:hover { filter: brightness(1.08); transform: translateY(-1px); } +.btn-ghost { border: 1.5px solid var(--border); color: var(--fg); background: var(--bg2); } +.btn-ghost:hover { border-color: var(--accent); color: var(--accent-h); } +.dl-ic { font-size: 13px; } +.hero-card { width: 400px; max-width: 94vw; } +.hero-card .card { padding: 22px; } +.feat-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: 18px; padding: 30px 5vw 40px; max-width: 1250px; margin: 0 auto; } +.dl-band { display: flex; align-items: center; justify-content: space-between; gap: 24px; + flex-wrap: wrap; background: var(--bg2); border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); padding: 34px 5vw; } +.dl-band h2 { font-size: 24px; margin-bottom: 6px; } +.dl-band p { color: var(--muted); max-width: 560px; } +.foot-band { display: flex; align-items: center; justify-content: space-between; + gap: 16px; flex-wrap: wrap; padding: 18px 5vw; color: var(--muted); font-size: 12.5px; + border-top: 1px solid var(--border); } +:root[data-theme="dark"] .hero { background: + radial-gradient(900px 500px at 8% 0%, rgba(61,126,255,.16), transparent 55%), + radial-gradient(700px 500px at 95% 100%, rgba(61,126,255,.08), transparent 55%); } +:root[data-theme="light"] .hero { background: + radial-gradient(900px 500px at 8% 0%, rgba(61,126,255,.10), transparent 55%), + radial-gradient(700px 500px at 95% 100%, rgba(61,126,255,.05), transparent 55%); } + +/* docked (minimized to right edge) panels */ +.panel.docked { width: 42px !important; min-height: 170px; cursor: pointer; } +.panel.docked > *:not(.panel-head) { display: none !important; } +.panel.docked .panel-head { padding: 12px 0; justify-content: center; cursor: pointer; } +.panel.docked .panel-head span { display: none; } +.panel.docked .panel-head button { display: none; } +.panel.docked::after { + content: attr(data-title); + writing-mode: vertical-rl; + color: var(--accent-h); + font-size: 12.5px; + font-weight: 600; + position: absolute; + top: 44px; + left: 50%; + transform: translateX(-50%); + letter-spacing: 1px; +} +.panel.docked .panel-head .dock-arrow { display: block; color: var(--accent-h); } + +/* session tab: landing fully hidden, pure dark stage */ +body.nw #screenConnect { display: none !important; } +body.nw { background: #05070c; } +#screenConnect.launched { opacity: .35; pointer-events: none; filter: saturate(.4); } +#screenConnect { transition: opacity .3s, filter .3s; } + +.nav-auth-btn { background: var(--accent); color: #fff; border: 0; border-radius: 999px; + padding: 8px 18px; font-size: 13.5px; font-weight: 600; cursor: pointer; font-family: inherit; } +.nav-auth-btn:hover { filter: brightness(1.1); } +.nav-auth-email { color: var(--muted); font-size: 13px; } + +/* ---- fixes: hidden must always win; letterboxed canvas with a visible cursor ---- */ +[hidden] { display: none !important; } +#canvas { object-fit: contain; cursor: default; } +/* my computers (account device list) */ +#myDevsWrap { margin-top: 16px; border-top: 1px solid var(--border-soft); padding-top: 12px; } +.dev-dot { width: 8px; height: 8px; border-radius: 50%; background: #8b95a5; flex-shrink: 0; } +.dev-dot.on { background: #2fbf71; box-shadow: 0 0 6px #2fbf71; } +.dev-dot.busy { background: #e0a53a; } + +/* ================= premium polish (v21) ================= */ +/* toolbar: soft depth + crisper separators */ +#toolbar { box-shadow: 0 1px 0 rgba(255,255,255,.03) inset, 0 6px 20px rgba(0,0,0,.28); } +.tb { transition: background .12s, border-color .12s, color .12s, transform .06s, box-shadow .12s; } +.tb:hover { box-shadow: 0 2px 8px rgba(0,0,0,.25); } +.tb:active { transform: translateY(1px); } +.tb.active { box-shadow: 0 0 0 1px rgba(61,126,255,.25), 0 2px 10px rgba(61,126,255,.18); } +.tb svg.ic { transition: transform .12s; } +.tb:hover svg.ic { transform: scale(1.06); } + +/* live connection-quality dot on the stats pill */ +#tbStats { display: inline-flex; align-items: center; gap: 7px; font-weight: 500; } +#tbStats::before { + content: ""; width: 8px; height: 8px; border-radius: 50%; + background: var(--faint); flex-shrink: 0; transition: background .3s, box-shadow .3s; +} +#tbStats[data-q="good"]::before { background: var(--ok); box-shadow: 0 0 7px var(--ok); } +#tbStats[data-q="ok"]::before { background: var(--warn); box-shadow: 0 0 7px var(--warn); } +#tbStats[data-q="bad"]::before { background: var(--danger); box-shadow: 0 0 7px var(--danger); } + +/* selects: match the premium button feel */ +select { transition: border-color .12s, box-shadow .12s; } +select:hover, select:focus { box-shadow: 0 0 0 3px var(--accent-dim); } + +/* floating panels: deeper, softer, glassy */ +.panel { + box-shadow: 0 20px 60px rgba(0,0,0,.55), 0 0 0 1px var(--border); + border-radius: var(--r-lg); + backdrop-filter: blur(10px) saturate(120%); +} +:root[data-theme="light"] .panel { backdrop-filter: blur(10px) saturate(105%); } +.panel-head { border-top-left-radius: var(--r-lg); border-top-right-radius: var(--r-lg); } + +/* connect card: subtle premium lift + accent top edge */ +.hero-card .card, #connect { + box-shadow: 0 24px 70px rgba(0,0,0,.35), 0 0 0 1px var(--border); + position: relative; overflow: hidden; +} +.hero-card .card::before { + content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; + background: linear-gradient(90deg, var(--accent), #7aa8ff 45%, var(--ok)); + opacity: .9; +} +.primary { + background: linear-gradient(180deg, var(--accent-h), var(--accent)); + box-shadow: 0 6px 18px rgba(61,126,255,.30); + transition: filter .12s, transform .06s, box-shadow .12s; +} +.primary:hover { filter: brightness(1.06); box-shadow: 0 8px 22px rgba(61,126,255,.38); } +.primary:active { transform: translateY(1px); } + +/* state label: pill-ify while live for a pro look */ +.state.ok { color: var(--ok); } + +/* smooth canvas cursor feel */ +#canvas { image-rendering: auto; } + +/* remote cursor overlay (RustDesk-style: the real remote cursor shape) */ +#remoteCursor { + position: absolute; pointer-events: none; z-index: 5; + image-rendering: auto; will-change: left, top; +} +#stage.remote-cursor #canvas { cursor: none; } + +/* ================================================================ + FIX: the live-session screen is ALWAYS dark (the toolbar bg is dark), + so pin every toolbar/panel control color explicitly. Before this, light + theme set --fg to a dark color -> dark icons on the dark toolbar = + "invisible buttons / no disconnect button". + ================================================================ */ +#screenSession { color-scheme: dark; } +#toolbar { color: #e8edf6; } +#toolbar .tb { color: #e8edf6; } +#toolbar .tb:hover { background: #1d2637; border-color: #2c3a55; color: #ffffff; } +#toolbar .tb.active { background: rgba(61,126,255,.16); color: #6aa1ff; border-color: rgba(61,126,255,.5); } +#toolbar .tb.danger { background: rgba(229,72,77,.14); border: 1px solid rgba(229,72,77,.45); color: #f2868b; } +#toolbar .tb.danger:hover { background: rgba(229,72,77,.26); color: #ffb3b6; } +#toolbar .tb-title { color: #e8edf6; } +#toolbar .state { color: #9aa4b9; } +#toolbar .sep { background: #26314a; } +#toolbar select { background: #171d2a; color: #e8edf6; border-color: #26314a; } +#toolbar #tbStats { background: #171d2a; border-color: #202a40; color: #aab4c8; } +#toolbar svg.ic { stroke: currentColor; } + +/* app page responsiveness: stack the connect hero on narrow screens */ +@media (max-width: 980px) { + .hero { flex-direction: column; gap: 22px; padding: 24px 16px; } + .hero-copy h1 { font-size: clamp(26px, 7vw, 40px); } + .hero-card, .hero-card .card { width: 100%; max-width: 520px; } +} + +/* ===== RustDesk-style floating session toolbar (1.5) ===== */ +#screenSession { position: relative; } +#toolbar { + position: absolute; top: 0; left: 50%; transform: translateX(-50%); + width: auto; max-width: calc(100vw - 20px); + border: 1px solid #26314a; border-top: none; + border-radius: 0 0 14px 14px; + background: rgba(13,17,26,.95); + backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); + padding: 6px 12px; justify-content: center; + box-shadow: 0 10px 34px rgba(0,0,0,.5); +} +#toolbar.autohide { transform: translate(-50%, -110%); } +#toolbar.collapsed { transform: translate(-50%, -100%); } +#toolbar.collapsed.autohide { transform: translate(-50%, -110%); } +#stage { position: absolute; inset: 0; } +#tbTab { + position: absolute; top: 100%; left: 50%; transform: translateX(-50%); + width: 52px; height: 17px; border: 1px solid #26314a; border-top: none; + border-radius: 0 0 11px 11px; background: rgba(13,17,26,.95); + color: #9aa4b9; cursor: pointer; font-size: 10px; line-height: 13px; + padding: 0; text-align: center; +} +#tbTab:hover { color: #fff; background: #1d2637; } +@media (max-width: 900px) { #toolbar { max-width: calc(100vw - 8px); padding: 5px 8px; } } + +/* portal dashboard layout: sidebar + main side by side */ +#viewDash { display: flex; flex: 1; min-width: 0; } + +/* ===== TeamViewer-style session menus (1.5) ===== */ +.menu-btn { font-weight: 600; } +.menu-btn.open { background: #1d2637; border-color: #2c3a55; } +.tmenu { + position: absolute; top: 100%; margin-top: 4px; z-index: 60; + min-width: 230px; max-height: 62vh; overflow-y: auto; + background: rgba(13,17,26,.98); border: 1px solid #26314a; border-radius: 10px; + padding: 6px; box-shadow: 0 16px 44px rgba(0,0,0,.55); + backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); +} +.tmenu .mi { + display: flex; width: 100%; align-items: center; justify-content: space-between; + gap: 14px; background: transparent; border: 0; border-radius: 7px; + color: #dbe3f0; font-size: 12.5px; padding: 7px 10px; cursor: pointer; + text-align: left; font-family: inherit; +} +.tmenu .mi:hover { background: #1d2637; color: #fff; } +.tmenu .mhead { + font-size: 10px; font-weight: 700; letter-spacing: 1.2px; text-transform: uppercase; + color: #6b7891; padding: 8px 10px 4px; +} +.tmenu .msep { height: 1px; background: #222c42; margin: 5px 4px; } +.tmenu .mtick { visibility: hidden; color: #4ade80; font-weight: 700; } +.tmenu .mi.on .mtick { visibility: visible; } + +/* Original (1:1) scaling: real pixels + scrollbars, like TeamViewer's "Original" */ +#stage.orig { overflow: auto; } +#stage.orig #canvas { position: static; inset: auto; width: auto; height: auto; } + +/* ===== premium account modal (1.5) ===== */ +.auth-card.modal-card { + width: 408px; max-width: 94vw; padding: 26px 28px 18px; border-radius: 18px; + background: linear-gradient(180deg, #141b26, #0e131c); + border: 1px solid #27334a; + box-shadow: 0 40px 110px -20px rgba(0,0,0,.85), 0 0 0 1px rgba(255,255,255,.02) inset, + 0 24px 60px -30px rgba(61,126,255,.35); +} +.auth-head { display: flex; align-items: center; gap: 11px; margin-bottom: 18px; } +.auth-logo { + width: 38px; height: 38px; border-radius: 10px; flex-shrink: 0; + background: linear-gradient(180deg, #25d386, #16a765); + display: inline-flex; align-items: center; justify-content: center; + box-shadow: 0 6px 18px rgba(37,211,134,.35); +} +.auth-brand { font-size: 18px; font-weight: 800; color: #e8edf6; letter-spacing: -.3px; } +.auth-brand i { font-style: normal; font-weight: 500; color: #64748b; } +.auth-card .auth-tabs { + display: flex; gap: 0; margin-bottom: 18px; background: #0b0f16; + border: 1px solid #202b3f; border-radius: 11px; padding: 4px; +} +.auth-card .atab { + flex: 1; background: transparent; border: 0; border-radius: 8px; + color: #8b96a6; font-size: 12.5px; font-weight: 600; padding: 8px 4px; + cursor: pointer; transition: color .15s, background .15s; +} +.auth-card .atab:hover { color: #cbd5e1; } +.auth-card .atab.on { + background: linear-gradient(180deg, #3d7eff, #2f66db); color: #fff; + box-shadow: 0 4px 14px rgba(61,126,255,.4); +} +.auth-title { font-size: 19px; font-weight: 750; color: #f1f5fb; margin: 0 0 4px; letter-spacing: -.3px; } +.auth-sub { font-size: 12.5px; color: #7d8a9d; margin: 0 0 12px; line-height: 1.55; } +.auth-lbl { + font-size: 10.5px; font-weight: 700; letter-spacing: 1.1px; text-transform: uppercase; + color: #6b7891; margin: 2px 0 -3px; +} +.auth-card .apane input { + height: 43px; background: #0d1219; border: 1px solid #263241; border-radius: 10px; + color: #e8edf6; font-size: 13.5px; padding: 0 14px; outline: none; + transition: border-color .15s, box-shadow .15s; width: 100%; +} +.auth-card .apane input:focus { + border-color: #3d7eff; box-shadow: 0 0 0 3px rgba(61,126,255,.18); +} +/* Chrome autofill was painting the field WHITE - pin it to the dark theme */ +.auth-card input:-webkit-autofill, +.auth-card input:-webkit-autofill:hover, +.auth-card input:-webkit-autofill:focus { + -webkit-box-shadow: 0 0 0 60px #0d1219 inset !important; + -webkit-text-fill-color: #e8edf6 !important; + caret-color: #e8edf6; + border: 1px solid #263241; +} +.auth-card .auth-cta { + height: 43px; margin-top: 8px; border: 0; border-radius: 10px; cursor: pointer; + background: linear-gradient(180deg, #4d8dff, #2f6ae0); color: #fff; + font-size: 14px; font-weight: 700; letter-spacing: .2px; + box-shadow: 0 10px 26px -8px rgba(61,126,255,.55); + transition: transform .12s, box-shadow .15s, filter .15s; +} +.auth-card .auth-cta:hover { filter: brightness(1.08); transform: translateY(-1px); + box-shadow: 0 14px 30px -8px rgba(61,126,255,.65); } +.auth-card .auth-cta:active { transform: translateY(0); } +.auth-card .foot { + font-size: 11.5px; color: #6b7891; line-height: 1.55; margin-top: 10px; + background: rgba(61,126,255,.06); border: 1px solid rgba(61,126,255,.14); + border-radius: 9px; padding: 9px 12px; +} +.auth-secure { + margin-top: 16px; padding-top: 12px; border-top: 1px solid #1c2534; + font-size: 10.5px; color: #56637a; text-align: center; letter-spacing: .2px; +} +.auth-card .modal-x { top: 14px; right: 14px; width: 30px; height: 30px; border-radius: 8px; + color: #6b7891; font-size: 13px; transition: background .15s, color .15s; } +.auth-card .modal-x:hover { background: #1b2434; color: #fff; } +.auth-card .error, .auth-card .auok { margin-top: 12px; } + +/* ===== docked slim menu-bar toolbar (TeamViewer style, 1.5.1) ===== + The bar sits ABOVE the video in normal flow - it never covers the screen. + ▲ collapses it to a tiny top-center tab; ▼ brings it back. */ +#screenSession { display: flex; flex-direction: column; position: relative; } +#toolbar { + position: relative; top: auto; left: auto; transform: none; + width: 100%; max-width: none; + border: 0; border-bottom: 1px solid #232e45; border-radius: 0; + background: linear-gradient(180deg, #101623, #0c111b); + backdrop-filter: none; -webkit-backdrop-filter: none; + padding: 4px 10px; min-height: 42px; + flex-wrap: nowrap; justify-content: flex-start; gap: 6px; + box-shadow: 0 4px 18px rgba(0,0,0,.35); +} +#toolbar.autohide, #toolbar.collapsed.autohide { transform: none; } +#toolbar.collapsed { display: none; } +#stage { position: relative; inset: auto; flex: 1; min-height: 0; } + +/* single-row discipline: groups never wrap; the stats text yields first */ +#toolbar .tb-group { flex-wrap: nowrap; flex-shrink: 0; } +#toolbar .tb-group:last-of-type { flex-shrink: 1; min-width: 0; margin-left: auto; } +#tbStats { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-size: 11px; } +.tb-title { max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-size: 12.5px; } +#toolbar .tb { height: 29px; padding: 0 8px; font-size: 12px; } +#toolbar select { height: 29px; font-size: 11.5px; max-width: 160px; } +#toolbar .menu-btn { padding: 0 10px; } +#toolbar .sep { height: 18px; margin: 0 2px; } + +/* the ▲ collapse control lives IN the bar, far right */ +#toolbar #tbTab { + position: static; top: auto; left: auto; transform: none; + width: 30px; height: 29px; flex-shrink: 0; margin-left: 2px; + border: 1px solid #26314a; border-radius: 7px; + background: transparent; color: #9aa4b9; font-size: 10px; line-height: 1; +} +#toolbar #tbTab:hover { background: #1d2637; color: #fff; } + +/* tiny reopen tab when collapsed - hangs at top-center, TeamViewer style */ +#tbShow { + position: absolute; top: 0; left: 50%; transform: translateX(-50%); z-index: 55; + height: 22px; padding: 0 14px; border: 1px solid #26314a; border-top: 0; + border-radius: 0 0 12px 12px; background: rgba(13,17,26,.94); + color: #9aa4b9; font-size: 11px; font-weight: 600; cursor: pointer; + box-shadow: 0 8px 22px rgba(0,0,0,.45); +} +#tbShow:hover { color: #fff; background: #1a2334; } +#tbShow[hidden] { display: none; } + +/* progressive slimming on narrow windows - never a second row */ +@media (max-width: 1300px) { #tbState { display: none; } } +@media (max-width: 1180px) { #selQuality { display: none; } .tb-title { max-width: 110px; } } +@media (max-width: 1020px) { #btnViewOnly span, #btnKbdLock span { display: none; } + #toolbar .menu-btn { padding: 0 7px; font-size: 11.5px; } } +@media (max-width: 880px) { #tbStats { display: none; } #selMonitor { max-width: 120px; } } + +/* ===== touch / mobile support (1.5.1) ===== */ +/* without touch-action:none the browser pans/zooms instead of sending + pointer events - THE reason touch "didn't work" on tablets/phones */ +#canvas { touch-action: none; } +#stage { touch-action: none; } +.touch-only { display: none !important; } +@media (pointer: coarse) { + .touch-only { display: inline-flex !important; } +} +/* invisible input that summons the phone/tablet on-screen keyboard */ +#oskInput { + position: fixed; bottom: 0; left: 0; width: 2px; height: 2px; + opacity: 0.01; border: 0; padding: 0; background: transparent; color: transparent; + pointer-events: none; z-index: -1; +} + +/* ===== 3D polish for the docked control bar (1.5.2) ===== */ +#toolbar { + min-height: 46px; padding: 6px 12px; gap: 8px; + background: linear-gradient(180deg, #1b2434 0%, #141b29 45%, #0e141f 100%); + border-bottom: 1px solid #05080f; + box-shadow: inset 0 1px 0 rgba(255,255,255,.07), + inset 0 -1px 0 rgba(255,255,255,.02), + 0 8px 26px rgba(0,0,0,.55); +} +#toolbar .tb-group { gap: 6px; } + +/* raised 3D buttons with a real pressed state */ +#toolbar .tb { + height: 31px; padding: 0 10px; border-radius: 8px; + background: linear-gradient(180deg, #253044, #1a2333); + border: 1px solid #0b111d; + box-shadow: inset 0 1px 0 rgba(255,255,255,.09), 0 1px 3px rgba(0,0,0,.45); + color: #c9d4e3; + transition: background .12s, box-shadow .12s, transform .06s, color .12s; +} +#toolbar .tb:hover { + background: linear-gradient(180deg, #2d3a54, #222d42); + color: #fff; border-color: #0b111d; + box-shadow: inset 0 1px 0 rgba(255,255,255,.13), 0 3px 8px rgba(0,0,0,.5); +} +#toolbar .tb:active { transform: translateY(1px); + background: #18202f; box-shadow: inset 0 2px 6px rgba(0,0,0,.55); } +#toolbar .tb.active { + background: linear-gradient(180deg, #2d5290, #234070); + border-color: #142643; color: #d9e7ff; + box-shadow: inset 0 1px 0 rgba(255,255,255,.16), inset 0 0 12px rgba(61,126,255,.3); +} +#toolbar .tb.danger { + background: linear-gradient(180deg, #93323a, #701f26); + border-color: #400f13; color: #ffdcde; + box-shadow: inset 0 1px 0 rgba(255,255,255,.14), 0 2px 10px rgba(229,72,77,.35); +} +#toolbar .tb.danger:hover { + background: linear-gradient(180deg, #aa3b45, #86262e); color: #fff; +} + +/* Actions / View / Extras behave like real app menus: flat, raise on hover */ +#toolbar .menu-btn { + background: transparent; border-color: transparent; box-shadow: none; + font-weight: 650; color: #d5dde9; +} +#toolbar .menu-btn:hover, #toolbar .menu-btn.open { + background: linear-gradient(180deg, #2b3852, #202b3f); + border-color: #0b111d; + box-shadow: inset 0 1px 0 rgba(255,255,255,.11), 0 2px 6px rgba(0,0,0,.45); +} + +/* selects match the 3D buttons, custom chevron */ +#toolbar select { + height: 31px; border-radius: 8px; padding: 0 26px 0 11px; + -webkit-appearance: none; appearance: none; + background: linear-gradient(180deg, #253044, #1a2333) + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238b96a6' stroke-width='1.6' fill='none' stroke-linecap='round'/%3E%3C/svg%3E") + no-repeat right 9px center; + border: 1px solid #0b111d; color: #c9d4e3; + box-shadow: inset 0 1px 0 rgba(255,255,255,.09), 0 1px 3px rgba(0,0,0,.45); + cursor: pointer; +} +#toolbar select:hover { color: #fff; + background-image: linear-gradient(180deg, #2d3a54, #222d42), + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23cfd8e6' stroke-width='1.6' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); } + +/* engraved separators */ +#toolbar .sep { + width: 1px; height: 24px; margin: 0 3px; background: + linear-gradient(180deg, transparent, #05080f 25%, #05080f 75%, transparent); + box-shadow: 1px 0 0 rgba(255,255,255,.05); +} + +/* title + live pill + inset stats chip */ +#toolbar .tb-title { font-weight: 700; text-shadow: 0 1px 2px rgba(0,0,0,.6); } +#tbState { + background: #0c1320; border: 1px solid #080d17; border-radius: 999px; + padding: 3px 11px; box-shadow: inset 0 1px 3px rgba(0,0,0,.45), 0 1px 0 rgba(255,255,255,.04); +} +#tbStats { + background: #0b101b; border: 1px solid #070c15; border-radius: 8px; + padding: 6px 11px; + box-shadow: inset 0 1px 3px rgba(0,0,0,.5), 0 1px 0 rgba(255,255,255,.04); +} + +/* dropdown menus + reopen tab share the depth */ +.tmenu { + background: linear-gradient(180deg, #171f2f, #0f1522); + border-color: #0b111d; + box-shadow: 0 20px 54px rgba(0,0,0,.65), inset 0 1px 0 rgba(255,255,255,.06); +} +#tbShow { + background: linear-gradient(180deg, #1b2434, #10161f); + border-color: #0b111d; + box-shadow: 0 10px 26px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.08); +} + +/* single-row overflow guards: Disconnect and the collapse tab must NEVER clip; + the stats chip yields first, then labels, then stats hides entirely */ +#toolbar .tb-group:last-of-type > .tb, #toolbar #tbTab { flex-shrink: 0; } +#tbStats { flex: 0 1 auto; max-width: 300px; } +#toolbar .menu-btn { padding: 0 8px; } +#toolbar .tb-title { max-width: 128px; } +#toolbar select { max-width: 150px; } +@media (max-width: 1520px) { + #btnViewOnly span, #btnKbdLock span { display: none; } +} +@media (max-width: 1400px) { #tbStats { display: none; } } +@media (max-width: 1080px) { #btnDisconnect span { display: none; } #tbState { display: none; } } + +/* ===== control bar: TeamViewer-grade flat premium (1.5.3, final pass) ===== + Calm flat surface, ghost controls that light up on hover, one bold red + Disconnect pill. Overrides the heavier 3D pass above. */ +#toolbar { + min-height: 50px; padding: 8px 14px; gap: 10px; + background: linear-gradient(180deg, #121926, #0d1219); + border-bottom: 1px solid rgba(255,255,255,.06); + box-shadow: 0 10px 30px rgba(0,0,0,.45); +} +#toolbar .tb-group { gap: 4px; } + +/* ghost buttons: quiet until you need them */ +#toolbar .tb { + height: 34px; min-width: 34px; padding: 0 11px; border-radius: 9px; + background: transparent; border: 1px solid transparent; box-shadow: none; + color: #b9c6d8; font-size: 12.5px; justify-content: center; +} +#toolbar .tb:hover { + background: rgba(255,255,255,.08); border-color: rgba(255,255,255,.06); + color: #fff; box-shadow: none; +} +#toolbar .tb:active { transform: none; background: rgba(0,0,0,.35); + box-shadow: inset 0 2px 5px rgba(0,0,0,.45); } +#toolbar .tb.active { + background: rgba(61,126,255,.16); border-color: rgba(61,126,255,.4); + color: #7fb0ff; box-shadow: none; +} +#toolbar .menu-btn { font-weight: 650; color: #dbe3ee; padding: 0 12px; + background: transparent; border-color: transparent; box-shadow: none; } +#toolbar .menu-btn:hover, #toolbar .menu-btn.open { + background: rgba(255,255,255,.08); border-color: rgba(255,255,255,.06); box-shadow: none; } + +/* selects as quiet inset wells */ +#toolbar select { + height: 34px; border-radius: 9px; padding: 0 28px 0 12px; + background-color: rgba(255,255,255,.05); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238b96a6' stroke-width='1.6' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; background-position: right 10px center; + border: 1px solid rgba(255,255,255,.09); color: #c6d2e2; box-shadow: none; +} +#toolbar select:hover { background-color: rgba(255,255,255,.09); color: #fff; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23cfd8e6' stroke-width='1.6' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); } + +/* hairline separators */ +#toolbar .sep { width: 1px; height: 22px; margin: 0 6px; + background: rgba(255,255,255,.08); box-shadow: none; } + +/* identity cluster: bold name + breathing live dot, no boxes */ +#toolbar .tb-title { font-size: 13px; font-weight: 700; color: #eef3fa; text-shadow: none; } +#tbState { background: transparent; border: 0; box-shadow: none; padding: 0 4px; + font-size: 12px; color: #8ea3bd; } +#tbState.ok::before { animation: livepulse 2.2s ease-in-out infinite; } +@keyframes livepulse { 0%,100% { box-shadow: 0 0 4px var(--ok); } 50% { box-shadow: 0 0 11px var(--ok); } } + +/* stats: quiet telemetry text, no box */ +#tbStats { background: transparent; border: 0; box-shadow: none; padding: 0 6px; + font-size: 11.5px; color: #7f93ad; letter-spacing: .2px; } + +/* THE red pill - always fully visible, unmistakable */ +#toolbar .tb.danger { + height: 34px; padding: 0 18px; border-radius: 999px; flex-shrink: 0; + background: linear-gradient(180deg, #ef5158, #d63a41); + border: 1px solid #b02a30; color: #fff; font-weight: 650; + box-shadow: 0 4px 16px rgba(229,72,77,.4), inset 0 1px 0 rgba(255,255,255,.22); +} +#toolbar .tb.danger:hover { + background: linear-gradient(180deg, #ff5f66, #e2444b); + box-shadow: 0 6px 20px rgba(229,72,77,.55), inset 0 1px 0 rgba(255,255,255,.25); + color: #fff; +} +#toolbar #tbTab { width: 34px; height: 34px; border-radius: 9px; + border: 1px solid transparent; background: transparent; color: #8ea3bd; } +#toolbar #tbTab:hover { background: rgba(255,255,255,.08); color: #fff; } + +/* stats only when there is truly room; never a crushed "1920×…" fragment */ +@media (max-width: 1620px) { #tbStats { display: none; } } +#toolbar .tb-title { max-width: 175px; } + +/* exact TeamViewer right-edge order: … [icons] | ▲ collapse | [red end button] */ +#toolbar #tbTab { order: 8; margin-left: auto; } +#toolbar .tb-group:last-of-type { order: 9; margin-left: 0; } +#toolbar { padding-right: 10px; } + +/* ===== TeamViewer light skin for the session bar (reference-matched) ===== */ +#toolbar { + margin: 8px 10px 0; border-radius: 11px; width: auto; + min-height: 46px; padding: 5px 10px; + background: linear-gradient(180deg, #ffffff, #f2f4f7); + border: 1px solid #d8dde4; border-bottom: 1px solid #c9cfd8; + box-shadow: 0 1px 0 rgba(255,255,255,.9) inset, + 0 10px 28px rgba(0,0,0,.45), 0 2px 6px rgba(0,0,0,.25); +} +#toolbar .tb { + color: #3d4756; background: transparent; border: 1px solid transparent; + height: 34px; border-radius: 8px; +} +#toolbar .tb:hover { background: #e8ecf1; border-color: #d6dbe2; color: #1d2530; + box-shadow: 0 1px 0 rgba(255,255,255,.8) inset; } +#toolbar .tb:active { background: #dde2e9; box-shadow: inset 0 2px 4px rgba(0,0,0,.12); } +#toolbar .tb.active { background: #e3edff; border-color: #bdd3f5; color: #1e64d8; box-shadow: none; } +#toolbar .menu-btn { color: #2c3644; font-weight: 600; } +#toolbar .menu-btn:hover, #toolbar .menu-btn.open { + background: #e8ecf1; border-color: #d6dbe2; box-shadow: none; } +#toolbar select { + background-color: #fbfcfd; color: #2c3644; border: 1px solid #ccd3dc; + box-shadow: inset 0 1px 2px rgba(0,0,0,.05); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%235b6878' stroke-width='1.6' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); +} +#toolbar select:hover { background-color: #fff; color: #10151d; border-color: #b9c2cd; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%232c3644' stroke-width='1.6' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); } +#toolbar .sep { background: #d8dde4; box-shadow: 1px 0 0 #ffffff; height: 24px; } +#toolbar .tb-title { color: #16202e; } +#tbState { color: #5b6878; } +#tbStats { color: #6a7686; } + +/* the red end button: compact, filled, unmistakable - like the reference */ +#toolbar .tb.danger { + padding: 0 12px; border-radius: 8px; + background: linear-gradient(180deg, #ee4048, #d92730); + border: 1px solid #b21e26; color: #fff; + box-shadow: 0 1px 0 rgba(255,255,255,.3) inset, 0 3px 10px rgba(217,39,48,.4); +} +#toolbar .tb.danger:hover { background: linear-gradient(180deg, #f9525a, #e63039); } +#toolbar #tbTab { color: #5b6878; } +#toolbar #tbTab:hover { background: #e8ecf1; color: #1d2530; } + +/* dropdown menus + info panel go light to match */ +.tmenu { + background: #ffffff; border: 1px solid #d8dde4; + box-shadow: 0 16px 44px rgba(0,0,0,.35), 0 2px 8px rgba(0,0,0,.15); +} +.tmenu .mi { color: #2c3644; } +.tmenu .mi:hover { background: #eef2f7; color: #10151d; } +.tmenu .mhead { color: #7c8898; } +.tmenu .msep { background: #e5e9ee; } +.tmenu .mtick { color: #1e64d8; } + +/* reopen tab matches the light bar */ +#tbShow { + background: linear-gradient(180deg, #ffffff, #eef1f5); + border: 1px solid #ccd3dc; border-top: 0; color: #3d4756; + box-shadow: 0 8px 22px rgba(0,0,0,.35); +} +#tbShow:hover { background: #fff; color: #10151d; } + +/* ===== reference-exact: airy sparse bar, menus-first (v41) ===== */ +/* identity + dropdown boxes leave the bar - like the reference image */ +#toolbar .tb-title, #tbState, #selMonitor, #selQuality { display: none !important; } +/* icon-only everywhere */ +#btnViewOnly span, #btnKbdLock span, #btnDisconnect span { display: none !important; } +/* generous breathing room between groups */ +#toolbar { gap: 16px; padding: 6px 12px; } +#toolbar .tb-group { gap: 10px; } +#toolbar .sep { margin: 0 6px; } +#toolbar .menu-btn { padding: 0 13px; font-size: 13px; } +/* compact square-ish red end button, icon only */ +#toolbar .tb.danger { padding: 0 12px; min-width: 40px; } +#toolbar .tb.danger svg { width: 15px; height: 15px; } +/* menus can hold the new Monitor / Quality sections comfortably */ +.tmenu { min-width: 250px; } + +/* monitor cycle label + disabled menu items (v44) */ +#monCycleLbl { font-size: 11px; font-weight: 700; margin-left: 5px; letter-spacing: .3px; } +.tmenu .mi[disabled] { opacity: .45; cursor: default; } +.tmenu .mi[disabled]:hover { background: transparent; color: #2c3644; } + +/* iOS: no long-press callout / text-selection bubbles over the session */ +#stage, #canvas, #toolbar, .tmenu { + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; +} + +/* ===== mobile session layout (phones) ===== */ +.tmenu { position: fixed; } /* JS sets top/left, clamped to the viewport */ +@media (max-width: 700px) { + #toolbar { + min-height: 44px; margin: 6px 6px 0; padding: 5px 6px; gap: 5px; + border-radius: 10px; + overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; + } + #toolbar::-webkit-scrollbar { display: none; } + #toolbar .tb { height: 36px; min-width: 36px; } + #toolbar .menu-btn { padding: 0 10px; font-size: 12.5px; } + /* secondary controls live inside the menus on phones - keep the bar clean */ + #btnViewOnly, #btnKbdLock, #btnClipboard, #btnPrivacy, #btnRec, + #btnSys, #btnChat, #btnFiles, #toolbar .sep { display: none !important; } + .tmenu { max-height: 68vh; overflow-y: auto; min-width: 230px; max-width: 92vw; } + #tbShow { height: 26px; padding: 0 16px; font-size: 12px; } +} + +/* phones: consolidate to the essentials - everything else stays in menus */ +@media (max-width: 700px) { + .menu-btn[data-menu="mComm"] { display: none !important; } /* chat lives in Extras */ + #btnFullscreen { display: none !important; } /* no Fullscreen API on iPhone */ + #btnOsk span { display: none !important; } + #monCycleLbl { font-size: 10px; margin-left: 3px; } + #toolbar { gap: 3px; } + #toolbar .menu-btn { padding: 0 8px; font-size: 12px; } + #toolbar .tb.danger { min-width: 38px; padding: 0 10px; } +} + +/* inside the desktop app's viewer window: no website chrome */ +body.embed .nav-links, body.embed a.btn-ghost[href="/portal"] { display: none !important; } + +/* ========================================================================= + ELEVATION LAYER v2 — glass, gradient accents, refined depth. Matches the + marketing site's elevation pass. Component-scoped overrides only; the base + design above is untouched so behaviour/layout stay identical. + ====================================================================== */ +:root{ + --accent-grad: linear-gradient(135deg,#4d8bff,#6aa1ff 44%,#34d399); + --glass: rgba(18,24,36,.72); + --hair: rgba(255,255,255,.07); + --hair-strong: rgba(255,255,255,.13); + --shadow-lg: 0 24px 70px -18px rgba(0,0,0,.72); + --ring: 0 0 0 3px rgba(77,139,255,.26); +} +:root[data-theme="light"]{ + --glass: rgba(255,255,255,.76); + --hair: rgba(16,22,34,.08); + --hair-strong: rgba(16,22,34,.14); + --shadow-lg: 0 24px 70px -22px rgba(23,32,47,.22); + --ring: 0 0 0 3px rgba(61,126,255,.18); +} + +/* primary buttons: gradient fill + sheen sweep (sheen sits behind the text) */ +button.primary, .btn-blue{ + position:relative; overflow:hidden; z-index:0; + background:var(--accent-grad) !important; background-size:160% 160% !important; + box-shadow:0 8px 22px -6px rgba(61,126,255,.5), inset 0 1px 0 rgba(255,255,255,.22) !important; +} +button.primary::before, .btn-blue::before{ + content:""; position:absolute; inset:0; z-index:-1; border-radius:inherit; + background:linear-gradient(100deg,transparent 22%,rgba(255,255,255,.4) 50%,transparent 78%); + transform:translateX(-120%); transition:transform .6s ease; +} +button.primary:hover::before, .btn-blue:hover::before{ transform:translateX(120%); } +button.primary:hover, .btn-blue:hover{ filter:brightness(1.05); transform:translateY(-1px); } + +/* toolbar: frosted glass + a gradient hairline underline */ +#toolbar{ + position:relative; + background:var(--glass) !important; + backdrop-filter:blur(16px) saturate(1.3); -webkit-backdrop-filter:blur(16px) saturate(1.3); + border-bottom:1px solid var(--hair) !important; + box-shadow:0 1px 0 var(--hair) inset, 0 10px 26px -14px rgba(0,0,0,.55) !important; +} +#toolbar::after{ + content:""; position:absolute; left:0; right:0; bottom:-1px; height:1px; pointer-events:none; + background:linear-gradient(90deg,transparent,rgba(77,139,255,.55),rgba(52,211,153,.4),transparent); + opacity:.75; +} + +/* connect card + modals: gradient hairline border + deeper shadow */ +.card, .modal-card{ position:relative; border-color:var(--hair-strong) !important; box-shadow:var(--shadow-lg) !important; } +.card::before, .modal-card::before{ + content:""; position:absolute; inset:0; border-radius:inherit; padding:1px; pointer-events:none; opacity:.85; + background:linear-gradient(135deg,rgba(77,139,255,.5),transparent 42%,transparent 58%,rgba(52,211,153,.32)); + -webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0); + -webkit-mask-composite:xor; mask-composite:exclude; +} +.modal-wrap{ background:rgba(5,7,12,.62) !important; backdrop-filter:blur(9px) saturate(1.2) !important; -webkit-backdrop-filter:blur(9px) saturate(1.2) !important; } + +/* inputs: softer gradient-tinted focus ring */ +input:focus{ box-shadow:var(--ring) !important; } + +/* active states get the gradient tint */ +.atab.on{ background:linear-gradient(135deg,rgba(77,139,255,.22),rgba(52,211,153,.12)) !important; border-color:rgba(77,139,255,.5) !important; color:#bcd4ff !important; } +#toolbar .tb.active{ background:linear-gradient(135deg,rgba(77,139,255,.24),rgba(52,211,153,.12)) !important; border-color:rgba(77,139,255,.5) !important; } + +/* operator-console (portal) cards: refined hairline + depth */ +.p-card{ border-color:var(--hair-strong) !important; box-shadow:0 1px 2px rgba(0,0,0,.22), 0 12px 30px -16px rgba(0,0,0,.55) !important; } +:root[data-theme="light"] .p-card{ box-shadow:0 1px 2px rgba(23,32,47,.06), 0 12px 30px -18px rgba(23,32,47,.18) !important; } diff --git a/server/web/app.html b/server/web/app.html new file mode 100644 index 0000000..6938f04 --- /dev/null +++ b/server/web/app.html @@ -0,0 +1,351 @@ + + + + + +NetDesk — The Fast Self-Hosted Remote Desktop + + + + + + + + +
+
ⓘ netdesk.space is the only official domain for this NetDesk server. No third-party cloud ever sees your screen.
+ + + +
+
+

The Fast Self-Hosted
Remote Desktop

+

Your own remote access and support software — unattended, silent in the + background, end-to-end encrypted, with your own server. The alternative to TeamViewer, + AnyDesk and RustDesk that you fully control.

+ +
+ +
+
+

Connect to a computer

+

Enter the remote ID shown by the NetDesk agent.

+ + + +
+ + +
+ + +
+ Advanced — direct connection + + +
+ + + + + + +
+
— OR —
+

Share THIS screen

+

Let someone view this computer right from their browser — no app to install.
(View-only: they can see & hear it, not control it.)

+
ID —  ·  Password —
+ + +
+
+ +
+
Address book
+ +
+
+ Direct connection (no server): put ws://IP:8901 in the Server + field when the host runs with --listen on your LAN or via port-forward. +
+
+ NetDesk viewer + + + +
+
+
+
+ + + +
+ + + + + + + + + diff --git a/server/web/app.js b/server/web/app.js new file mode 100644 index 0000000..16bef3b --- /dev/null +++ b/server/web/app.js @@ -0,0 +1,2995 @@ +/* NetDesk web viewer - WebCodecs H.264 decode, E2E (AES-GCM), input, files, chat. */ +"use strict"; + +const $ = (s) => document.querySelector(s); +window.__ndErr = []; +window.addEventListener("error", (e) => window.__ndErr.push("err: " + e.message)); +window.addEventListener("unhandledrejection", (e) => window.__ndErr.push("rej: " + e.reason)); + +/* ------------------------------------------------------------- utils */ +const enc = new TextEncoder(); +const dec = new TextDecoder(); +function toHex(buf) { + return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} +function b64ToBytes(b64) { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} +async function sha256Hex(str) { + return toHex(await crypto.subtle.digest("SHA-256", enc.encode(str))); +} +function fmtBytes(n) { + if (n > 1e9) return (n / 1e9).toFixed(2) + " GB"; + if (n > 1e6) return (n / 1e6).toFixed(1) + " MB"; + if (n > 1e3) return (n / 1e3).toFixed(1) + " KB"; + return n + " B"; +} +function now() { return performance.now(); } + +// Resolution stays NATIVE on every rung (crisp text, like TeamViewer): +// a bad link costs fps and bitrate, never sharpness. Scale changes also +// force an encoder rebuild + keyframe - that was the visible "break". +const QUALITY = { + min: { fps: 8, bitrate: 700, scale: 1.0 }, // very slow links: still sharp, fewer frames + low: { fps: 15, bitrate: 2500, scale: 1.0 }, + balanced: { fps: 30, bitrate: 6000, scale: 1.0 }, + high: { fps: 30, bitrate: 12000, scale: 1.0 }, + extreme: { fps: 60, bitrate: 20000, scale: 1.0 }, +}; + +/* ------------------------------------------------------------- state */ +const S = { + ws: null, + sid: null, + direct: false, + serverUrl: "", + hostId: "", + viewOnly: false, + dims: { w: 0, h: 0 }, + monitors: [], + e2e: false, + e2eKey: null, // CryptoKey AES-GCM once derived + decoder: null, + audioDecoder: null, + audioCtx: null, + sps: null, + pps: null, + started: false, // got first keyframe + latestFrame: null, + drawing: false, + bytesVideo: 0, + framesDecoded: 0, + fpsShown: 0, + lastFpsTick: now(), + rtt: 0, + buttons: 0, + pendingMouse: null, + clipSync: false, + lastRemoteClip: "", + transfers: Object.create(null), // fid -> download state + nextFid: 1, + chatOpen: false, + kbdLocked: false, + dead: false, + _timers: [], + heldKeys: new Set(), + curShapes: {}, curId: null, curPos: null, +}; +let decodeQueue = Promise.resolve(); // preserve chunk order across async decrypt + +/* ------------------------------------------------------------- connect screen */ +$("#btnPwEye").onclick = () => { + const i = $("#inPw"); + i.type = i.type === "password" ? "text" : "password"; +}; +$("#inServer").value = localStorage.getItem("nd.server") || ""; +$("#inId").value = localStorage.getItem("nd.id") || ""; +if (localStorage.getItem("nd.remember") === "1") { + $("#inRemember").checked = true; + $("#inPw").value = localStorage.getItem("nd.pw") || ""; +} +// deep link from the portal: /?id=123456789[&server=...] +{ + const qs = new URLSearchParams(location.search); + const qid = (qs.get("id") || "").replace(/\D/g, ""); + // opened inside the desktop app's own viewer window (no site chrome there) + if (qs.get("embed")) document.body.classList.add("embed"); + if (qid) { + $("#inId").value = qid; + if (qs.get("server")) $("#inServer").value = qs.get("server"); + setTimeout(() => $("#inPw").focus(), 100); + } + // Reload while a session was live (and Disconnect was never clicked): + // resume it automatically - with the remembered password it reconnects by + // itself; otherwise one keystroke away. + try { + const rs = JSON.parse(localStorage.getItem("nd.resume") || "null"); + if (!qid && rs && rs.id && Date.now() - rs.t < 12 * 3600e3) { + $("#inId").value = rs.id; + if ($("#inPw").value) { + connectErr("You were in a session — resuming…"); + setTimeout(() => connect(), 300); + } else { + connectErr("You were in a session — enter the password to resume."); + setTimeout(() => $("#inPw").focus(), 150); + } + } + } catch {} +} + +$("#btnConnect").onclick = () => connect(); +$("#inPw").addEventListener("keydown", (e) => { if (e.key === "Enter") connect(); }); +$("#inId").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#inPw").focus(); }); + +/* ------------------------------------------------------------- viewer account */ +const QS = new URLSearchParams(location.search); +const URLTOKEN = QS.get("reset"); +const VERIFIED = QS.get("verified") === "1"; +let myEmail = null; + +async function refreshAuth() { + try { + const r = await fetch("/api/auth/me", { credentials: "same-origin" }); + if (r.ok) { const d = await r.json(); setSignedIn(d.email, d.owner); } + else setSignedOut(); + } catch { setSignedOut(); } +} +async function signOut() { + await fetch("/api/auth/logout", { method: "POST" }).catch(() => {}); + window.__isOwner = false; + setSignedOut(); +} +function renderNavAuth() { + const nav = $("#navAuth"); + if (nav) { + nav.innerHTML = ""; + if (myEmail) { + const t = document.createElement("span"); + t.className = "nav-auth-email"; + t.textContent = (window.__isOwner ? "⭐ " : "") + myEmail; + const b = document.createElement("button"); + b.className = "nav-auth-btn"; + b.textContent = "Sign out"; + b.onclick = signOut; + nav.append(t, b); + } else { + const b = document.createElement("button"); + b.className = "nav-auth-btn"; + b.textContent = "Sign in / Sign up"; + b.onclick = () => openAuth("in"); + nav.append(b); + } + } + const fb = $("#btnAuthOpen"); + if (fb) { + fb.textContent = myEmail ? "Sign out" : "Sign in / Sign up"; + fb.onclick = myEmail ? signOut : () => openAuth("in"); + } +} +function setSignedIn(email, owner) { + myEmail = email; + window.__isOwner = !!owner; + renderNavAuth(); + document.querySelectorAll("#authChip").forEach((c) => { + c.hidden = false; c.textContent = email; + c.style.cursor = "pointer"; c.title = "Account & two-factor"; + c.onclick = () => openAuth("2fa"); + }); + const a2 = $("#atab2fa"); if (a2) a2.hidden = false; + loadDevices(); +} +function setSignedOut() { + myEmail = null; + const a2 = $("#atab2fa"); if (a2) a2.hidden = true; + renderNavAuth(); + document.querySelectorAll("#authChip").forEach((c) => { c.hidden = true; }); + const w = $("#myDevsWrap"); + if (w) w.hidden = true; +} + +/* my computers: devices bound to this account from the desktop app */ +async function loadDevices() { + const wrap = $("#myDevsWrap"), box = $("#myDevs"); + if (!wrap || !myEmail) return; + let list = []; + try { + const r = await fetch("/api/devices"); + if (!r.ok) return; + list = (await r.json()).devices || []; + } catch { return; } + wrap.hidden = false; + box.innerHTML = ""; + if (!list.length) { + const e = document.createElement("div"); + e.className = "foot"; + e.textContent = "No computers yet — sign in with this account in the NetDesk app on a PC and it shows up here."; + box.append(e); + return; + } + for (const d of list) { + const row = document.createElement("div"); + row.className = "ab-row"; + const dot = document.createElement("span"); + dot.className = "dev-dot" + (d.online ? (d.busy ? " busy" : " on") : ""); + dot.title = d.online ? (d.busy ? "in a session" : "online") : "offline"; + const lbl = document.createElement("span"); + lbl.className = "ab-label"; + lbl.textContent = d.name; + if (d.shared) { + const sh = document.createElement("span"); + sh.className = "ab-shared"; + sh.textContent = "shared" + (d.team ? " · " + d.team : ""); + sh.title = "Shared with you via team" + (d.team ? " “" + d.team + "”" : "") + (d.owner ? " by " + d.owner : ""); + lbl.append(" ", sh); + } + const id = document.createElement("span"); + id.className = "ab-id"; + id.textContent = d.id; + row.append(dot, lbl, id); + if (!d.shared) { + // only devices you own can be removed from here; team-shared ones are managed by their owner + const del = document.createElement("button"); + del.className = "tb"; + del.textContent = "🗑"; + del.title = "Remove from my computers"; + del.onclick = async (ev) => { + ev.stopPropagation(); + if (!confirm("Remove " + d.name + " from your computers?")) return; + await fetch("/api/devices/remove", { method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: d.id }) }); + loadDevices(); + }; + row.append(del); + } + row.onclick = () => { $("#inId").value = d.id; $("#inPw").focus(); }; + box.append(row); + } +} +setInterval(() => { if (myEmail && $("#screenSession").hidden) loadDevices(); }, 15000); + +function authMsg(msg, ok) { + const m = $("#auMsg"), o = $("#auOk"); + o.hidden = true; + m.hidden = !msg; + m.textContent = msg || ""; + if (ok) { m.hidden = true; o.hidden = false; o.textContent = msg; } +} +function showAuthPane(t) { + for (const id of ["in", "up", "fp", "rs", "2fa"]) { const p = $("#pane-" + id); if (p) p.hidden = id !== t; } + document.querySelectorAll(".atab").forEach(b => b.classList.toggle("on", b.dataset.t === t)); + $("#auMsg").hidden = true; $("#auOk").hidden = true; + if (t === "2fa") tf2Refresh(); +} +function openAuth(defaultTab) { + $("#authModal").hidden = false; + showAuthPane(defaultTab || "in"); +} +$("#btnAuthClose").onclick = () => { $("#authModal").hidden = true; }; +async function authPost(path, body, okMsg, closeAfter) { + let r, d; + try { + r = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) }); + d = await r.json().catch(() => ({})); + } catch { authMsg("Server unreachable"); return false; } + if (!r.ok) { authMsg(d.error || "failed"); return false; } + if (d.email) setSignedIn(d.email, d.owner); + authMsg(okMsg || d.msg || "done", true); + if (closeAfter) setTimeout(() => { $("#authModal").hidden = true; }, 1200); + return true; +} +document.querySelectorAll(".atab").forEach(b => b.onclick = () => showAuthPane(b.dataset.t)); +const onEnter = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("keydown", (e) => { if (e.key === "Enter") fn(); }); }; +const doLogin = async () => { + const body = { email: $("#auEmail").value, password: $("#auPass").value }; + const totp = ($("#auTotp") && $("#auTotp").value || "").trim(); + if (totp) body.totp = totp; + let r, d; + try { + r = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) }); + d = await r.json().catch(() => ({})); + } catch { authMsg("Server unreachable"); return; } + if (!r.ok) { + if (d.twofa) { + $("#auTotpLbl").hidden = false; $("#auTotp").hidden = false; $("#auTotp").focus(); + authMsg(totp ? "Invalid 2FA code — try again." : "Enter your 6-digit authenticator code."); + } else { authMsg(d.error || "failed"); } + return; + } + if (d.email) setSignedIn(d.email, d.owner); + authMsg("Signed in.", true); + $("#auTotp").value = ""; $("#auTotpLbl").hidden = true; $("#auTotp").hidden = true; + setTimeout(() => { $("#authModal").hidden = true; }, 1200); +}; +const doSignup = () => authPost("/api/auth/signup", + { email: $("#auEmail2").value, password: $("#auPass2").value }, + "Account created — you are signed in.", true); +const doForgot = () => authPost("/api/auth/forgot", + { email: $("#auEmail3").value }, "If that account exists, a reset email was sent.", false); +const doReset = async () => { + if (await authPost("/api/auth/reset", { token: URLTOKEN, password: $("#auPass4").value }, + "Password changed — sign in with the new password.", false)) { + history.replaceState(null, "", "/"); + setTimeout(() => showAuthPane("in"), 1400); + } +}; +$("#auGo").onclick = doLogin; onEnter("#auPass", doLogin); +$("#auGo2").onclick = doSignup; onEnter("#auPass2", doSignup); +$("#auGo3").onclick = doForgot; +$("#auGo4").onclick = doReset; onEnter("#auPass4", doReset); + +/* ----------------------------------------------- account 2FA (TOTP) */ +let tf2Secret = ""; +async function tf2Refresh() { + tf2Secret = ""; + const st = $("#tf2Status"), en = $("#tf2Enable"), enr = $("#tf2Enroll"), dis = $("#tf2DisableWrap"); + if (enr) enr.hidden = true; if (dis) dis.hidden = true; if (en) en.hidden = true; + if (!myEmail) { if (st) st.textContent = "Sign in first."; return; } + try { + const r = await fetch("/api/auth/2fa/status", { credentials: "same-origin" }); + const d = await r.json().catch(() => ({})); + if (d.enabled) { st.textContent = "✅ Two-factor is ON for this account."; dis.hidden = false; } + else { st.textContent = "Two-factor is off — enable it to require a code at sign-in."; en.hidden = false; } + } catch { st.textContent = "Could not load 2FA status."; } +} +async function tf2BeginEnroll() { + try { + const r = await fetch("/api/auth/2fa/setup", { method: "POST", credentials: "same-origin" }); + const d = await r.json().catch(() => ({})); + if (!r.ok || !d.secret) { authMsg(d.error || "setup failed"); return; } + tf2Secret = d.secret; + $("#tf2Secret").value = d.secret; + $("#tf2Uri").textContent = d.otpauth || ""; + $("#tf2Enable").hidden = true; $("#tf2Enroll").hidden = false; + $("#tf2Code").value = ""; $("#tf2Code").focus(); + } catch { authMsg("Server unreachable"); } +} +async function tf2Confirm() { + const code = ($("#tf2Code").value || "").trim(); + if (!/^\d{6}$/.test(code)) { authMsg("Enter the 6-digit code."); return; } + const r = await fetch("/api/auth/2fa/enable", { method: "POST", credentials: "same-origin", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ secret: tf2Secret, code }) }); + const d = await r.json().catch(() => ({})); + if (!r.ok) { authMsg(d.error || "invalid code"); return; } + authMsg("Two-factor enabled.", true); tf2Refresh(); +} +async function tf2Disable() { + const code = ($("#tf2DisCode").value || "").trim(); + const r = await fetch("/api/auth/2fa/disable", { method: "POST", credentials: "same-origin", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code }) }); + const d = await r.json().catch(() => ({})); + if (!r.ok) { authMsg(d.error || "invalid code"); return; } + authMsg("Two-factor disabled.", true); tf2Refresh(); +} +{ const b = $("#tf2Enable"); if (b) b.onclick = tf2BeginEnroll; } +{ const b = $("#tf2Confirm"); if (b) b.onclick = tf2Confirm; } +{ const b = $("#tf2Disable"); if (b) b.onclick = tf2Disable; } +onEnter("#auTotp", doLogin); +onEnter("#tf2Code", tf2Confirm); +onEnter("#tf2DisCode", tf2Disable); +refreshAuth(); +if (VERIFIED) { openAuth("in"); authMsg("Email verified - thank you.", true); history.replaceState(null, "", "/"); } +if (URLTOKEN) { openAuth("rs"); } + +/* ------------------------------------------------------------- theme */ +function applyTheme(t) { + document.documentElement.dataset.theme = t; + const btns = [document.querySelector("#btnTheme"), document.querySelector("#btnThemeP")]; + btns.forEach(b => { if (b) b.textContent = t === "light" ? "☾ Dark" : "☀ Light"; }); +} +applyTheme(localStorage.getItem("nd.theme") || "dark"); +$("#btnTheme").onclick = () => { + const t = document.documentElement.dataset.theme === "light" ? "dark" : "light"; + localStorage.setItem("nd.theme", t); + applyTheme(t); +}; + +/* ------------------------------------------------------------- address book */ +function abookGet() { + try { return JSON.parse(localStorage.getItem("nd.abook") || "[]"); } catch { return []; } +} +function abookSet(list) { localStorage.setItem("nd.abook", JSON.stringify(list)); renderAbook(); } +function renderAbook() { + const list = abookGet(); + $("#abook").hidden = list.length === 0; + const box = $("#abook"); + box.innerHTML = ""; + for (const e of list) { + const row = document.createElement("div"); + row.className = "ab-row"; + const lbl = document.createElement("span"); + lbl.className = "ab-label"; + lbl.textContent = e.label; + const id = document.createElement("span"); + id.className = "ab-id"; + id.textContent = e.id; + const del = document.createElement("button"); + del.className = "tb"; + del.textContent = "🗑"; + del.onclick = (ev) => { ev.stopPropagation(); abookSet(abookGet().filter((x) => x.id !== e.id)); }; + row.append(lbl, id, del); + row.onclick = () => { + $("#inServer").value = e.server || $("#inServer").value; + $("#inId").value = e.id; + $("#inPw").focus(); + }; + box.append(row); + } +} +$("#btnAbAdd").onclick = () => { + const id = $("#inId").value.replace(/\D/g, ""); + if (!id) { connectErr("Enter an ID first, then save it."); return; } + const label = prompt("Name for this connection:", "My PC") || id; + const list = abookGet().filter((x) => x.id !== id); + list.push({ id, label, server: $("#inServer").value.trim() }); + abookSet(list); +}; +renderAbook(); + +function connectErr(msg) { + const el = $("#connectErr"); + el.textContent = msg; + el.hidden = false; + $("#btnConnect").disabled = false; + $("#btnConnect").textContent = "Connect"; +} +function connectErrHide() { $("#connectErr").hidden = true; } + +async function connect() { + if (!window.VideoDecoder) { + connectErr("This browser has no WebCodecs video decoder. Use Chrome, Edge or Safari 16.4+."); + return; + } + if (!$("#inId").value.replace(/\D/g, "") && !/^wss?:\/\//i.test($("#inServer").value.trim())) { + connectErr("Enter the remote computer's ID."); + return; + } + if (S.ws) { const old = S.ws; S.ws = null; try { old.close(); } catch {} } + resetSessionState(); + $("#connectErr").hidden = true; + $("#btnConnect").disabled = true; + $("#btnConnect").textContent = "Connecting…"; + + let server = $("#inServer").value.trim(); + const id = $("#inId").value.replace(/\D/g, ""); + const pw = $("#inPw").value; + S.viewOnly = $("#inViewOnly").checked; + + localStorage.setItem("nd.server", server); + localStorage.setItem("nd.id", id); + if ($("#inRemember").checked) { + localStorage.setItem("nd.remember", "1"); + localStorage.setItem("nd.pw", pw); + } else { + localStorage.removeItem("nd.remember"); + localStorage.removeItem("nd.pw"); + } + + let wsUrl; + if (/^wss?:\/\//i.test(server)) { + // Direct connection to a host running --listen + S.direct = true; + wsUrl = server.replace(/\/+$/, "") + "/ws"; + } else { + S.direct = false; + let loc = location; + if (server) { + try { loc = new URL(/^https?:\/\//i.test(server) ? server : "https://" + server); } + catch { connectErr("Invalid server address: " + server); return; } + } + const scheme = loc.protocol === "https:" ? "wss" : "ws"; + wsUrl = `${scheme}://${loc.host}/ws`; + } + S.serverUrl = server; + + let ws; + try { + ws = new WebSocket(wsUrl); + ws.binaryType = "arraybuffer"; + await new Promise((res, rej) => { + ws.onopen = res; + ws.onerror = () => rej(new Error("cannot reach server")); + setTimeout(() => rej(new Error("connection timeout")), 10000); + }); + } catch (e) { + connectErr("Could not connect: " + e.message); + return; + } + S.ws = ws; + S.hostId = id; + S.dead = false; + ws.onmessage = (ev) => { if (ws === S.ws) onMessage(ev); }; + ws.onclose = onWsClose; + ws.onerror = () => {}; + + // Keep the password for the challenge (cleared after auth) + S._pw = pw; + send({ t: "session.begin", id: S.direct ? "0" : id, direct: S.direct, + takeover: !!S._takeover }); + S._takeover = false; +} + +function resetSessionState() { + teardownMedia(); + for (const t of S._timers || []) clearInterval(t); + S._timers = []; + Object.assign(S, { + sid: null, e2e: false, e2eKey: null, e2ePending: null, info: null, + dims: { w: 0, h: 0 }, monitors: [], sps: null, pps: null, started: false, + plan: null, limit: 0, startedAt: 0, _denyReason: null, _challenge: null, + _privacyNoted: false, _logonShown: false, buttons: 0, pendingMouse: null, + transfers: Object.create(null), autoLevel: null, rtt: 0, fpsShown: 0, + _rttBase: 0, _localCurTs: 0, _lastMoveSend: 0, + heldKeys: new Set(), curShapes: {}, curId: null, curPos: null, + }); + wheelAccY = 0; wheelAccX = 0; + decodeQueue = Promise.resolve(); + const rc = document.getElementById('remoteCursor'); if (rc) rc.hidden = true; + const stg = document.getElementById('stage'); if (stg) stg.classList.remove('remote-cursor'); + // menus are per-host: forget the previous host's resolution list & wallpaper state + window.__modesLoaded = false; + const mm = document.getElementById('miModes'); + if (mm) mm.innerHTML = ''; + const wp = document.getElementById('miWallpaper'); if (wp) wp.classList.remove('on'); +} + +function onWsClose(ev) { + if (ev && ev.target && ev.target !== S.ws) return; + if (S.dead) return; + S.dead = true; + teardownMedia(); + const st = $("#tbState"); + if (!$("#screenSession").hidden) { + setState("disconnected", "err"); + showBanner("Connection closed. Returning to connect screen…", true); + setTimeout(showConnectScreen, 600); + } else { + connectErr("Connection closed (" + (S._denyReason || "unexpectedly") + ")"); + } + scheduleReconnect(); +} + +// The user never clicked Disconnect, so a drop (network blip, relay restart, +// host reconnect, page glitch) must heal itself: retry with backoff until the +// session is back. Credential problems never loop. +function scheduleReconnect() { + if (S.userEnd || S._recPending) return; + if (!$("#inPw").value || !$("#inId").value.replace(/\D/g, "")) return; + const reason = (S._denyReason || "").toLowerCase(); + // credential / policy problems never loop + if (/password|2fa|denied|allowlist|not allowed|bad id/.test(reason)) return; + const busy = /busy|another|elsewhere/.test(reason); + S._recN = (S._recN || 0) + 1; + // Busy = the session is open on another device/tab. Don't storm: poll + // gently and, after a couple of tries, offer to take it over. + if (busy) { + if (S._recN >= 3) { offerTakeover(); return; } + scheduleIn(4000, "This computer is in use on another device — retrying…"); + return; + } + if (S._recN > 10) { // give up storming; let the user decide + connectErr("Couldn't reconnect after several tries. Press Connect to retry."); + return; + } + // floor at 2s so even a fast accept→drop cycle can't hammer the relay + scheduleIn(Math.min(2000 + 1000 * S._recN, 8000), + "Connection lost — reconnecting (attempt " + S._recN + ")…"); +} +function scheduleIn(delay, msg) { + S._recPending = true; + setTimeout(() => { + S._recPending = false; + if (S.userEnd || !$("#screenSession").hidden) return; + connectErr(msg); + connect(); + }, delay); +} +function offerTakeover() { + const box = $("#connectErr"); + connectErr("This computer is being controlled on another device."); + if (box && !$("#btnTakeover")) { + const b = document.createElement("button"); + b.id = "btnTakeover"; b.className = "primary"; b.style.marginTop = "10px"; + b.textContent = "Take over the session here"; + b.onclick = () => { b.remove(); S._takeover = true; S._recN = 0; connect(); }; + box.appendChild(b); + } +} + +function showConnectScreen() { + for (const t of S._timers || []) clearInterval(t); + S._timers = []; + if (document.fullscreenElement) document.exitFullscreen().catch(() => {}); + $("#screenSession").hidden = true; + $("#screenConnect").hidden = false; + $("#btnConnect").disabled = false; + $("#btnConnect").textContent = "Connect"; + S._pw = null; + S._challenge = null; + hideBanner(); +} + +/* ------------------------------------------------------------- send helpers */ +function send(obj) { + obj.sid = obj.sid || S.sid; + if (S.ws && S.ws.readyState === 1) S.ws.send(JSON.stringify(obj)); +} +async function sendBinary(bytes) { + if (!S.e2eKey) { + if (S.ws && S.ws.readyState === 1) S.ws.send(bytes); + return; + } + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, S.e2eKey, bytes)); + const out = new Uint8Array(12 + ct.length); + out.set(nonce, 0); + out.set(ct, 12); + if (S.ws && S.ws.readyState === 1) S.ws.send(out); +} + +/* ------------------------------------------------------------- message dispatch */ +function onMessage(ev) { + if (typeof ev.data === "string") { + let m; + try { m = JSON.parse(ev.data); } catch { return; } + handleJson(m); + } else { + const bytes = new Uint8Array(ev.data); + decodeQueue = decodeQueue.then(() => handleBinary(bytes)).catch((e) => console.error("binary error", e)); + } +} + +async function handleBinary(bytes) { + if (S.e2eKey) { + if (bytes.length < 29) return; + try { + const pt = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: bytes.subarray(0, 12) }, S.e2eKey, bytes.subarray(12)); + bytes = new Uint8Array(pt); + } catch { + return; // drop undecryptable chunk + } + } + const chan = bytes[0]; + if (chan === 0x01) onVideo(bytes); + else if (chan === 0xa1) onAudio(bytes); + else if (chan === 0xf1) onFileData(bytes, false); + else if (chan === 0xf2) onFileData(bytes, true); +} + +async function handleJson(m) { + const t = m.t; + // Host RTT probe: echo it back with our measured frame jitter so the host's + // adaptive QoS gets a real end-to-end delay (not just RTT). Also keeps the + // host's loss estimate honest (an unanswered ping now counts as loss). + if (t === "net.ping") { send({ t: "net.pong", sid: m.sid, id: m.id, jit: frameJitterMs() }); return; } + if (t === "session.challenge") return doAuth(m); + if (t === "session.deny") { + S._denyReason = m.reason || "denied"; + if (m.reason === "signin") { openAuth("in"); authMsg("Sign in with your email to connect."); } + else { + connectErr("Connection refused: " + (m.reason || "denied") + + (m.reason === "host offline" ? " — is the host agent running and registered?" : "")); + } + try { S.ws.close(); } catch {} + return; + } + if (t === "session.accept") { + // session is live: remember it so a page reload resumes automatically + S.userEnd = false; + S._recN = 0; + try { + localStorage.setItem("nd.resume", JSON.stringify( + { id: (S.hostId || $("#inId").value).replace(/\D/g, ""), t: Date.now() })); + } catch {} + return onAccept(m); + } + if (t === "session.info") return onInfo(m.info); + if (t === "session.end") { + teardownMedia(); + showBanner("Session ended: " + (m.reason || "closed"), true); + setState("ended", "err"); + S._denyReason = m.reason || S._denyReason; + setTimeout(showConnectScreen, 600); + scheduleReconnect(); + return; + } + if (t === "pong") { S.rtt = Math.round(now() - m.c); updateStats(); return; } + if (t === "rtc.answer") { + if (S._rtc && S._rtc.pc) { + S._rtc.pc.setRemoteDescription({ type: "answer", sdp: m.sdp }) + .catch(() => { S._rtc = null; }); + } + return; + } + if (t === "rtc.unavailable") { S._rtc = null; return; } // relay keeps working + if (t === "rtc.config") { if (window.__onRtcConfig) window.__onRtcConfig(m); return; } + if (t === "session.plan") { + S.plan = m.plan; S.limit = m.limit || 0; S.startedAt = now(); + S.planEmail = m.email || ""; + if (m.email) setSignedIn(m.email, m.plan === "owner"); + return; + } + if (t === "cursor.shape") return onCursorShape(m); + if (t === "cursor") return onCursorPos(m); + if (t === "clip.set") return onRemoteClip(m); + if (t === "clip.image") return onRemoteClipImage(m); + if (t === "chat") return onChat(m.text, false); + if (t === "file.roots.res" || t === "file.list.res") return onFileList(m); + if (t === "file.done") return onFileDone(m); + if (t === "file.ok") return onFileOk(m); + if (t === "file.err") { setFileStatus("Error: " + m.msg, true); return; } + if (t === "sys.res") { + if (m.kind === "processes") return renderProcesses(m.items); + if (m.kind === "privacy") { + $("#btnPrivacy").classList.toggle("active", !!m.on); + return setFileStatus(m.msg || "ok"); + } + // menu-driven results (modes / setmode / wallpaper / cad) handled below + if (window.onSysRes && window.onSysRes(m)) return; + if (m.msg) showBanner((m.ok ? "" : "Failed: ") + m.msg, m.ok, 2600); + return setFileStatus(m.ok ? (m.msg || "done") : "failed: " + m.msg, !m.ok); + } +} + +/* ------------------------------------------------------------- auth + E2E */ +async function doAuth(ch) { + S.sid = ch.sid; + if (ch.totp && !$("#inTotp").value.trim()) { + // wait for the user's authenticator code before proving the password + S._challenge = ch; + $("#lblTotp").hidden = false; + $("#inTotp").hidden = false; + $("#btnConnect").disabled = false; + $("#btnConnect").textContent = "Enter 2FA code…"; + connectErr("This host requires a 2FA code — type it and press Enter."); + $("#inTotp").focus(); + return; + } + await doAuthSend(ch); +} + +$("#inTotp").addEventListener("keydown", (e) => { + if (e.key === "Enter" && S._challenge) { + const ch = S._challenge; + S._challenge = null; + connectErrHide(); + $("#btnConnect").textContent = "Connecting…"; + doAuthSend(ch); + } +}); + +async function doAuthSend(ch) { + let pub = null; + if (ch.e2e) { + try { + const kp = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]); + pub = btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.exportKey("raw", kp.publicKey)))); + const hostPub = await crypto.subtle.importKey( + "raw", b64ToBytes(ch.pub), { name: "ECDH", namedCurve: "P-256" }, false, []); + const shared = new Uint8Array(await crypto.subtle.deriveBits({ name: "ECDH", public: hostPub }, kp.privateKey, 256)); + const ctx = new Uint8Array(shared.length + 3); + ctx.set(shared, 0); + ctx.set(enc.encode("e2e"), shared.length); + const keyBytes = new Uint8Array(await crypto.subtle.digest("SHA-256", ctx)); + S.e2ePending = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt", "decrypt"]); + } catch (e) { + window.__ndErr.push("doAuth: " + e); + console.warn("E2E unavailable, falling back to transport-only encryption", e); + pub = null; + } + } + // verifier = PBKDF2(password, salt, iters) hex ; proof = SHA256(nonce + verifier) hex + const salt = b64ToBytes(ch.salt); + const bits = await crypto.subtle.importKey("raw", enc.encode(S._pw || ""), "PBKDF2", false, ["deriveBits"]); + const dk = await crypto.subtle.deriveBits( + { name: "PBKDF2", hash: "SHA-256", salt, iterations: ch.iters }, bits, 256); + const verifier = toHex(dk); + const proof = await sha256Hex(ch.nonce + verifier); + // Channel-bound proof (MITM hardening): when E2E is active, bind the host's + // exact ephemeral pubkey (ch.pub) into the proof so a relay that swapped it is + // detected host-side. Sent alongside the plain proof; non-E2E omits it. + const proof_bind = (ch.e2e && ch.pub && pub) + ? await sha256Hex(ch.nonce + verifier + ch.pub) + : undefined; + S._pw = null; // discard + send({ t: "session.auth", proof, proof_bind, pub, totp: $("#inTotp").value.trim() }); + // clear the code field for next time; hide unless still needed + $("#inTotp").value = ""; + if (!ch.totp) { $("#lblTotp").hidden = true; $("#inTotp").hidden = true; } +} + +async function onAccept(m) { + S._pw = null; + if (S.e2ePending && m.info && m.info.e2e) { + S.e2eKey = S.e2ePending; + S.e2ePending = null; + S.e2e = true; + } + $("#screenConnect").hidden = true; + $("#screenSession").hidden = false; + setState("live", "ok"); + onInfo(m.info); + $("#canvas").focus(); + + // clipboard sync ON by default (RustDesk-style): text copied on the remote + // appears in your local clipboard automatically; copying locally pushes to + // the remote once you've granted the browser clipboard permission + S.clipSync = true; + $("#btnClipboard").classList.add("active"); + + // restore host-enforced view-only across reconnects (the engine resets the + // flag per session, so a reconnect must re-assert it) + if (S.viewOnly) send({ t: "view.only", on: true }); + + // ping loop + S._timers.push(setInterval(() => { if (!S.dead) send({ t: "ping", c: now() }); }, 2000)); + // mouse coalesce flush loop (~120 Hz for smooth, low-latency dragging) + S._timers.push(setInterval(flushMouse, 8)); + drawLoop(); +} + +function onInfo(info) { + S.info = Object.assign({}, S.info || {}, info); // later partial infos merge, not replace + info = S.info; + const reconfigure = S.dims.w && (S.dims.w !== info.w || S.dims.h !== info.h); + S.dims = { w: info.w, h: info.h }; + S.monitors = info.monitors || []; + const sel = $("#selMonitor"); + sel.innerHTML = ""; + for (const mon of S.monitors) { + const o = document.createElement("option"); + o.value = mon.i; + o.textContent = mon.i < 0 + ? `All displays (${mon.w}×${mon.h})` + : `Display ${mon.i} (${mon.w}×${mon.h})`; + sel.appendChild(o); + } + // restore last-viewed monitor + quality on reconnect (RustDesk 1.4.9 parity) + if (S.restoring !== false) { + const lastMon = localStorage.getItem("nd.lastmon"); + const lastQ = localStorage.getItem("nd.lastq"); + if (lastMon && [...sel.options].some(o => o.value === lastMon)) sel.value = lastMon; + if (lastQ && $("#selQuality").querySelector('option[value="' + lastQ + '"]')) + $("#selQuality").value = lastQ; + } + sel.value = sel.value || info.mon; + if (parseInt(sel.value, 10) !== info.mon) { + setTimeout(() => send({ t: "monitor.switch", mon: parseInt(sel.value, 10) }), 300); + } + $("#tbTitle").textContent = `NetDesk — ${info.host_name || S.hostId}` + (info.e2e ? " 🔒" : ""); + // Host-enforced input permission: when the host disables input entirely, + // reflect it — force view-only and lock the toggle (the engine drops input + // regardless; this keeps the UI truthful). + if (info.perms && info.perms.input === false) { + S.inputDeniedByHost = true; + if (typeof setViewOnly === "function" && !S.viewOnly) setViewOnly(true); + $("#btnViewOnly").title = "Input is disabled by the host's permissions"; + $("#btnViewOnly").classList.add("disabled"); + } + if (typeof info.privacy_active === "boolean") { + $("#btnPrivacy").classList.toggle("active", info.privacy_active); + if (info.privacy_active && !S._privacyNoted) { + S._privacyNoted = true; + showBanner("Privacy mode active — the host's monitor is off.", true, 3000); + } + } + if (!info.privacy_active) S._privacyNoted = false; + if (info.logon_screen) showBanner("Host is at the Windows logon screen — you can type credentials to log in.", true); + else if (S.info && S._logonShown && !info.logon_screen) hideBanner(); + if (info.logon_screen) S._logonShown = true; + if (info.audio && !S.audioDecoder) setupAudio(); + // Remote sound ON by default (like TeamViewer/RDP): the moment the host says + // it has audio, request SYSTEM sound. Playback stays muted by the browser + // until a user gesture resumes the AudioContext — but you're controlling the + // host, so your next click/keypress on the canvas does that (gesture resumer + // below). Net effect: audio just plays once you interact. + if (info.audio && !S._audioAutoStarted) { + S._audioAutoStarted = true; + S.audioSrc = 1; + send({ t: "audio.set", src: 1 }); + try { if (S.audioCtx && S.audioCtx.state !== "running") S.audioCtx.resume(); } catch (e) {} + const lbl = $("#audioLbl"); if (lbl) lbl.textContent = "🔊 System"; + const b = $("#btnAudio"); if (b) b.classList.add("active"); + } + // Reconnected with unfinished downloads? Re-request only the missing ranges. + try { resumeTransfers(); } catch (e) {} + if (reconfigure) { + // new dimensions: rebuild decoder and wait for the next keyframe + S.started = false; + S.sps = null; + S.pps = null; + if (S.decoder) { try { S.decoder.close(); } catch {} S.decoder = null; } + send({ t: "keyframe" }); + } +} + +/* ------------------------------------------------------------- video decode */ +function splitNals(bytes) { + // find 00 00 01 start codes (00 00 00 01 tolerated); return NAL bodies + const nals = []; + let i = 0, starts = []; + while (i < bytes.length - 2) { + if (bytes[i] === 0 && bytes[i + 1] === 0 && bytes[i + 2] === 1) { + starts.push(i); + i += 3; + } else i++; + } + for (let k = 0; k < starts.length; k++) { + let s = starts[k]; + let bodyStart = s + 3; + let bodyEnd = k + 1 < starts.length ? starts[k + 1] : bytes.length; + // strip extra leading zero from 4-byte start code of the *next* nal + while (bodyEnd > bodyStart && bytes[bodyEnd - 1] === 0) bodyEnd--; // keep zero-padding out + if (bodyEnd > bodyStart) nals.push(bytes.subarray(bodyStart, bodyEnd)); + } + return nals; +} + +/* ------ frame-arrival jitter -> reported to the host in net.pong for QoS ---- */ +function recordFrameJitter() { + const now = performance.now(); + if (!S.jitIv) S.jitIv = []; + if (S.jitLast) { + const dt = Math.min(now - S.jitLast, 2000); + S.jitIv.push(dt); + if (S.jitIv.length > 30) S.jitIv.shift(); + } + S.jitLast = now; +} +function frameJitterMs() { + const iv = S.jitIv || []; + if (iv.length < 2) return 0; + const mean = iv.reduce((a, b) => a + b, 0) / iv.length; + const mad = iv.reduce((a, b) => a + Math.abs(b - mean), 0) / iv.length; + return Math.round(mad); +} + +function onVideo(bytes) { + recordFrameJitter(); + const kf = bytes[1] === 1; + const nals = splitNals(bytes.subarray(2)); + const au = []; + for (const n of nals) { + const t = n.length ? n[0] & 31 : 0; + if (t === 7) S.sps = n; + else if (t === 8) S.pps = n; + if (t === 1 || t === 5) au.push(n); + } + if (!au.length) return; + if (!S.sps || !S.pps) return; + + if (!S.decoder) { + const codec = "avc1." + toHex(S.sps.subarray(1, 4)); + try { + S.decoder = new VideoDecoder({ + output: (frame) => { + if (S.latestFrame) S.latestFrame.close(); + S.latestFrame = frame; + S.framesDecoded++; + }, + error: (e) => { + // self-heal (vital on the lossy P2P path): rebuild + ask for an IDR + console.error("decoder", e); + try { S.decoder.close(); } catch {} + S.decoder = null; S.started = false; + send({ t: "keyframe" }); + }, + }); + S.decoder.configure({ + codec, + codedWidth: S.dims.w, + codedHeight: S.dims.h, + optimizeForLatency: true, + hardwareAcceleration: "prefer-hardware", + avc: { format: "avc" }, + description: avccDescription(S.sps, S.pps), + }); + } catch (e) { + showBanner("Cannot start decoder: " + e.message, false); + return; + } + } + if (!S.started && !kf) { // waiting for an IDR: keep asking (rate-limited) + if (now() - (S._kfReqAt || 0) > 400) { S._kfReqAt = now(); send({ t: "keyframe" }); } + return; + } + // Backpressure: if the decoder queue is deep, drop this delta - BUT a + // dropped delta breaks the reference chain, and decoding the next deltas + // against a frame we never saw is exactly the "pixels break" artifact. + // So: resync - stop decoding deltas and request a fresh keyframe. + if ((S.decoder.decodeQueueSize || 0) > 10 && !kf) { + S.started = false; + S._kfReqAt = now(); + send({ t: "keyframe" }); + return; + } + + const data = new Uint8Array(au.reduce((a, n) => a + 4 + n.length, 0)); + let o = 0; + for (const n of au) { + data[o++] = (n.length >>> 24) & 255; data[o++] = (n.length >>> 16) & 255; + data[o++] = (n.length >>> 8) & 255; data[o++] = n.length & 255; + data.set(n, o); o += n.length; + } + try { + S.decoder.decode(new EncodedVideoChunk({ + type: kf ? "key" : "delta", + timestamp: Math.round(now()), + data, + })); + if (kf) S.started = true; + S.bytesVideo += bytes.length; + } catch (e) { + if (kf) { // config mismatch maybe; rebuild on next keyframe + try { S.decoder.close(); } catch {} + S.decoder = null; S.started = false; send({ t: "keyframe" }); + } + } +} + +function avccDescription(sps, pps) { + const d = new Uint8Array(11 + sps.length + pps.length); + d[0] = 1; + d[1] = sps[1]; d[2] = sps[2]; d[3] = sps[3]; + d[4] = 0xfc | 3; + d[5] = 0xe0 | 1; + d[6] = (sps.length >> 8) & 255; d[7] = sps.length & 255; + d.set(sps, 8); + const p = 8 + sps.length; + d[p] = 1; + d[p + 1] = (pps.length >> 8) & 255; d[p + 2] = pps.length & 255; + d.set(pps, p + 3); + return d; +} + +function drawLoop() { + if (S.dead) return; + const c = $("#canvas"); + if (S.latestFrame && c.width !== S.dims.w) { c.width = S.dims.w; c.height = S.dims.h; } + if (S.latestFrame) { + const ctx = c.getContext("2d"); + ctx.drawImage(S.latestFrame, 0, 0, c.width, c.height); + } + // fps accounting + if (now() - S.lastFpsTick > 1000) { + S.fpsShown = S.framesDecoded; + S.framesDecoded = 0; + S.lastFpsTick = now(); + updateStats(); + } + requestAnimationFrame(drawLoop); +} + +/* ------------------------------------------------------------- audio */ +function setupAudio() { + if (!window.AudioDecoder) return; + try { + // Fresh jitter-buffer state for this (re)connect. + audioNextTime = 0; + audioLead = 0.12; + S._audioUnderruns = 0; + // Reuse an existing (possibly already-resumed) context on a decoder rebuild — + // creating a fresh one would start suspended again and lose the gesture. + if (!S.audioCtx) S.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + S.audioDecoder = new AudioDecoder({ + output: playAudio, + // A single bad/truncated Opus packet closes the decoder; drop it so the + // next packet rebuilds it (see onAudio) instead of killing audio silently + // for the whole session. + error: () => { try { S.audioDecoder = null; } catch (e) {} }, + }); + S.audioDecoder.configure({ codec: "opus", sampleRate: 48000, numberOfChannels: 2 }); + } catch { S.audioDecoder = null; } +} + +let audioNextTime = 0; +let audioLead = 0.12; // adaptive jitter-buffer depth (s); grows on underrun +const AUDIO_LEAD_MAX = 0.45; +function playAudio(ad) { + const ctx = S.audioCtx; + if (!ctx) { ad.close?.(); return; } + if (ctx.state === "suspended") ctx.resume(); + const n = ad.numberOfChannels, len = ad.numberOfFrames, rate = ad.sampleRate; + const buf = ctx.createBuffer(n, len, rate); + const planar = (ad.format || "").includes("planar"); + for (let ch = 0; ch < n; ch++) { + const dst = buf.getChannelData(ch); + if (planar) ad.copyTo(dst, { planeIndex: ch, format: "f32-planar" }); + else { + // planeIndex is REQUIRED by AudioData.copyTo even for interleaved formats; + // omitting it throws "Required member planeIndex is undefined" on EVERY + // frame, which silently killed all web-viewer audio. Chromium decodes opus + // to interleaved "f32", so this branch is the common path. + const tmp = new Float32Array(len * n); + ad.copyTo(tmp, { planeIndex: 0, format: "f32" }); + for (let i = 0; i < len; i++) dst[i] = tmp[i * n + ch]; + } + } + ad.close?.(); + // Jitter buffer: schedule each 20 ms frame on a running clock that leads the + // context by `audioLead`. Relay/network timing is uneven, so too small a lead + // underruns (gaps/crackle). We keep frames seamlessly back-to-back and, on an + // underrun, GROW the lead so the stream self-heals toward smooth; if latency + // creeps too far ahead we resync down. + const now = ctx.currentTime; + if (audioNextTime === 0) { + audioNextTime = now + audioLead; // first frame: prime + } else if (audioNextTime < now) { + audioLead = Math.min(audioLead + 0.04, AUDIO_LEAD_MAX); // underrun: deepen buffer + audioNextTime = now + audioLead; + S._audioUnderruns = (S._audioUnderruns || 0) + 1; + } else if (audioNextTime > now + audioLead + 0.35) { + audioNextTime = now + audioLead; // drifted ahead: resync + } + const src = ctx.createBufferSource(); + src.buffer = buf; + src.connect(ctx.destination); + src.start(audioNextTime); + audioNextTime += len / rate; +} + +function onAudio(bytes) { + // Rebuild the decoder if a prior decode error dropped it (error cb nulls it). + if (!S.audioDecoder) { + if (!window.AudioDecoder) return; + setupAudio(); + if (!S.audioDecoder) return; + } + try { + S.audioDecoder.decode(new EncodedAudioChunk({ + type: "key", + timestamp: Math.round(now()), + data: bytes.subarray(1), + })); + } catch {} +} + +/* ------------------------------------------------------------- voice call */ +// Two-way voice: capture THIS browser's microphone, Opus-encode it (WebCodecs), +// and send each packet as [CHAN_VOICE=0xa2][opus] (E2E-sealed like everything +// else). The host decodes it and plays it on its speakers. We also flip the +// host's audio source to its mic so you hear the person there — a real call. +async function startVoice() { + if (S.voiceOn) return; + if (!window.AudioEncoder || !window.MediaStreamTrackProcessor) { + showBanner("Voice call needs a Chromium-based browser (WebCodecs).", true, 2800); + return; + } + let stream; + try { + stream = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 }, + }); + } catch (e) { showBanner("Microphone permission denied — can't start the call.", true, 2800); return; } + S.voiceStream = stream; + const track = stream.getAudioTracks()[0]; + S.voiceTrack = track; + S.voiceOn = true; + const enc = new AudioEncoder({ + output: (chunk) => { + try { + const tmp = new Uint8Array(chunk.byteLength); + chunk.copyTo(tmp); + const buf = new Uint8Array(tmp.length + 1); + buf[0] = 0xa2; // CHAN_VOICE + buf.set(tmp, 1); + sendBinary(buf); + } catch (e) {} + }, + error: () => {}, + }); + S.voiceEncoder = enc; + // Hear the other side: if host audio is off, switch it to the host mic. + if (!S.audioSrc) { + if (!S.audioDecoder) setupAudio(); + try { if (S.audioCtx && S.audioCtx.state !== "running") await S.audioCtx.resume(); } catch (e) {} + S.audioSrc = 2; + send({ t: "audio.set", src: 2 }); + const lbl = $("#audioLbl"); if (lbl) lbl.textContent = "🎤 Mic"; + const b = $("#btnAudio"); if (b) b.classList.add("active"); + } + const reader = new MediaStreamTrackProcessor({ track }).readable.getReader(); + S.voiceReader = reader; + let configured = false; + (async () => { + while (S.voiceOn) { + let res; + try { res = await reader.read(); } catch (e) { break; } + if (res.done) break; + const ad = res.value; + try { + if (!configured) { + // Opus handles rate internally, so encoding at the mic's native rate is + // fine — the host's 48 kHz decoder reproduces it correctly. + enc.configure({ codec: "opus", sampleRate: ad.sampleRate, numberOfChannels: ad.numberOfChannels, bitrate: 48000 }); + configured = true; + } + if (enc.state === "configured") enc.encode(ad); + } catch (e) {} + ad.close(); + } + })(); + updateVoiceUi(); + showBanner("Voice call started — speak into your mic.", true, 2000); +} +function stopVoice() { + if (!S.voiceOn && !S.voiceStream) { updateVoiceUi(); return; } + S.voiceOn = false; + try { if (S.voiceReader) S.voiceReader.cancel(); } catch (e) {} + try { if (S.voiceStream) S.voiceStream.getTracks().forEach((t) => t.stop()); } catch (e) {} + try { if (S.voiceEncoder && S.voiceEncoder.state !== "closed") S.voiceEncoder.close(); } catch (e) {} + S.voiceReader = S.voiceTrack = S.voiceStream = S.voiceEncoder = null; + updateVoiceUi(); +} +function updateVoiceUi() { + const mi = $("#miVoice"); + if (!mi) return; + mi.classList.toggle("on", !!S.voiceOn); + const txt = mi.childNodes[0]; + const label = S.voiceOn ? "End voice call" : "Start voice call (your mic)"; + if (txt && txt.nodeType === 3) txt.nodeValue = label; else mi.textContent = label; +} + +/* ------------------------------------------------------------- input */ +function canvasPos(e) { + // canvas is letterboxed (object-fit: contain): map into the drawn image rect + const c = $("#canvas"); + const r = c.getBoundingClientRect(); + const iw = S.dims.w || 1, ih = S.dims.h || 1; + const k = Math.min(r.width / iw, r.height / ih) || 1; + const dw = iw * k, dh = ih * k; + const ox = r.left + (r.width - dw) / 2, oy = r.top + (r.height - dh) / 2; + return { + x: Math.max(0, Math.min(iw - 1, Math.round((e.clientX - ox) / k))), + y: Math.max(0, Math.min(ih - 1, Math.round((e.clientY - oy) / k))), + }; +} +// button-bit map: left=1, right=2, middle=4, back(X1)=8, forward(X2)=16 +function mouseBtnBit(b) { + return b === 0 ? 1 : b === 2 ? 2 : b === 1 ? 4 : b === 3 ? 8 : b === 4 ? 16 : 0; +} + +const canvas = $("#canvas"); +canvas.addEventListener("pointermove", (e) => { + if (S.viewOnly || S.dead) return; + if (e.pointerType === "touch") return touchMove(e); + const p = canvasPos(e); + S.pendingMouse = { x: p.x, y: p.y, b: S.buttons, w: 0, hw: 0 }; + localCursorEcho(p); +}); + +// Local cursor echo (RustDesk-style): draw the remote cursor SHAPE at YOUR +// pointer position instantly instead of waiting for the host to echo it back. +// On a long link the echo lags by RTT, which made the cursor feel slow-motion. +function localCursorEcho(p) { + S.curPos = { x: p.x, y: p.y }; + S._localPos = S.curPos; + S._localCurTs = performance.now(); + if (nativeCur()) { + // the OS draws the pointer (with the remote shape, see applyCursorShape): + // it never waits on the page, so it stays crisp even while a slow link + // keeps the main thread busy decoding. Keep the overlay out of the way. + const rc = $("#remoteCursor"); + if (rc && !rc.hidden) { rc.hidden = true; $("#stage").classList.remove("remote-cursor"); } + return; + } + if (S.curId == null || !(S.curShapes || {})[S.curId]) return; + const rc = $("#remoteCursor"); + if (rc) { + positionCursor(); + rc.hidden = false; + $("#stage").classList.add("remote-cursor"); + } +} +// mouse/trackpad clients get the shape as the real OS cursor; touch clients +// and view-only keep the JS overlay (there is no pointer to dress up) +function nativeCur() { return !IS_TOUCH_CLIENT && !S.viewOnly; } +canvas.addEventListener("pointerdown", (e) => { + canvas.focus(); + if (S.viewOnly || S.dead) return; + if (e.pointerType === "touch") return touchDown(e); + // capture the pointer so a drag that leaves the canvas keeps tracking and the + // button-up is never lost (fixes "drag freezes / stuck button" past the edge) + try { canvas.setPointerCapture(e.pointerId); } catch {} + S.buttons |= mouseBtnBit(e.button); + const p = canvasPos(e); + send({ t: "i.mouse", x: p.x, y: p.y, b: S.buttons, w: 0, hw: 0 }); +}); +function pointerRelease(e) { + if (S.viewOnly || S.dead) return; + if (e.pointerType === "touch") return touchUp(e); + try { canvas.releasePointerCapture(e.pointerId); } catch {} + S.buttons &= ~mouseBtnBit(e.button); + const p = canvasPos(e); + send({ t: "i.mouse", x: p.x, y: p.y, b: S.buttons, w: 0, hw: 0 }); +} +canvas.addEventListener("pointerup", pointerRelease); +canvas.addEventListener("pointercancel", (e) => { + if (e && e.pointerType === "touch") return touchUp(e, true); + if (S.buttons) { S.buttons = 0; send({ t: "i.mouse", ...(S.pendingMouse || { x: 0, y: 0 }), b: 0, w: 0, hw: 0 }); } +}); + +/* ---------------- touch: TOUCHPAD mode for phones/tablets (RustDesk-style) + drag = move the cursor · tap = left click · double-tap = double click · + double-tap & hold = drag · hold still ~0.6s = right click · 2 fingers = scroll */ +const T = { pts: new Map(), mode: null, lpTimer: null, lastMidY: 0, scrollAcc: 0, + vx: 0, vy: 0, vInit: false, sid: null, lastX: 0, lastY: 0, + lastTapT: 0, moved: false, downT: 0 }; +function tClearLP() { if (T.lpTimer) { clearTimeout(T.lpTimer); T.lpTimer = null; } } +function tScale() { + const r = canvas.getBoundingClientRect(); + return Math.min(r.width / (S.dims.w || 1), r.height / (S.dims.h || 1)) || 1; +} +function tCursorInit() { + if (T.sid !== S.sid) { T.vInit = false; T.sid = S.sid; try { zoomReset(); } catch (e) {} } + if (T.vInit) return; + if (S.curPos) { T.vx = S.curPos.x; T.vy = S.curPos.y; } + else { T.vx = (S.dims.w || 2) / 2; T.vy = (S.dims.h || 2) / 2; } + T.vInit = true; +} +function tXY() { return { x: Math.round(T.vx), y: Math.round(T.vy) }; } +function tSendMove() { + const p = tXY(); + S.pendingMouse = { x: p.x, y: p.y, b: S.buttons, w: 0, hw: 0 }; + localCursorEcho(p); + try { followCursor(); } catch (e) {} // zoomed view pans with the cursor +} +function tapClick(btnBit) { + const p = tXY(); + send({ t: "i.mouse", x: p.x, y: p.y, b: btnBit, w: 0, hw: 0 }); + setTimeout(() => send({ t: "i.mouse", x: p.x, y: p.y, b: 0, w: 0, hw: 0 }), 60); +} +function touchDown(e) { + try { canvas.setPointerCapture(e.pointerId); } catch {} + T.pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (T.pts.size === 1) { + tCursorInit(); + T.lastX = e.clientX; T.lastY = e.clientY; + T.moved = false; T.downT = performance.now(); + if (performance.now() - T.lastTapT < 320) { + // double-tap & hold: press the left button at the cursor and drag + T.mode = "drag"; + S.buttons |= 1; + const p = tXY(); + send({ t: "i.mouse", x: p.x, y: p.y, b: S.buttons, w: 0, hw: 0 }); + } else { + T.mode = "move"; + tClearLP(); + T.lpTimer = setTimeout(() => { + if (T.mode === "move" && !T.moved && T.pts.size === 1) { + T.mode = "rclick"; + tapClick(2); + if (navigator.vibrate) try { navigator.vibrate(25); } catch {} + } + }, 600); + } + } else if (T.pts.size === 2) { + tClearLP(); + if (T.mode === "drag") { + S.buttons &= ~1; + const p = tXY(); + send({ t: "i.mouse", x: p.x, y: p.y, b: S.buttons, w: 0, hw: 0 }); + } + T.mode = "two"; // undecided: becomes "scroll" or "zoom" by motion + const ps = [...T.pts.values()]; + T.lastMidY = (ps[0].y + ps[1].y) / 2; + T.lastDist = Math.hypot(ps[0].x - ps[1].x, ps[0].y - ps[1].y); + T.scrollAcc = 0; + } +} +function touchMove(e) { + const rec = T.pts.get(e.pointerId); + if (!rec) return; + const dx = e.clientX - rec.x, dy = e.clientY - rec.y; + rec.x = e.clientX; rec.y = e.clientY; + if ((T.mode === "two" || T.mode === "scroll" || T.mode === "zoom") && T.pts.size >= 2) { + const ps = [...T.pts.values()]; + const mid = (ps[0].y + ps[1].y) / 2; + const dist = Math.hypot(ps[0].x - ps[1].x, ps[0].y - ps[1].y); + const dMid = mid - T.lastMidY, dDist = dist - T.lastDist; + if (T.mode === "two") { // decide the gesture once, then commit + if (Math.abs(dDist) > Math.abs(dMid) * 1.3 && Math.abs(dDist) > 6) T.mode = "zoom"; + else if (Math.abs(dMid) > 6) T.mode = "scroll"; + } + if (T.mode === "zoom") { + const mx = (ps[0].x + ps[1].x) / 2, my = (ps[0].y + ps[1].y) / 2; + zoomBy(dist / (T.lastDist || dist), mx, my); + T.lastDist = dist; T.lastMidY = mid; + return; + } + if (T.mode === "scroll") { + T.scrollAcc += dMid; + T.lastMidY = mid; T.lastDist = dist; + while (Math.abs(T.scrollAcc) >= 26) { + const w = T.scrollAcc > 0 ? 1 : -1; + T.scrollAcc -= w * 26; + const p = tXY(); + send({ t: "i.mouse", x: p.x, y: p.y, b: S.buttons, w: w, hw: 0 }); + } + return; + } + T.lastMidY = mid; T.lastDist = dist; + return; + } + if (T.mode !== "move" && T.mode !== "drag") return; + if (Math.abs(dx) + Math.abs(dy) >= 1) { T.moved = true; tClearLP(); } + const k = tScale(); + T.vx = Math.max(0, Math.min((S.dims.w || 1) - 1, T.vx + (dx / k) * 1.15)); + T.vy = Math.max(0, Math.min((S.dims.h || 1) - 1, T.vy + (dy / k) * 1.15)); + tSendMove(); +} +function touchUp(e, cancelled) { + try { canvas.releasePointerCapture(e.pointerId); } catch {} + T.pts.delete(e.pointerId); + tClearLP(); + if (T.mode === "move" && !cancelled && !T.moved && + performance.now() - T.downT < 300 && T.pts.size === 0) { + tapClick(1); + T.lastTapT = performance.now(); // arms double-tap / tap-drag + } else if (T.mode === "drag" && T.pts.size === 0) { + S.buttons &= ~1; + const p = tXY(); + send({ t: "i.mouse", x: p.x, y: p.y, b: S.buttons, w: 0, hw: 0 }); + } + if (T.pts.size === 0) T.mode = null; +} +// Mac trackpads emit many tiny pixel deltas per two-finger swipe; sending +// them raw truncated to zero on the host and most Windows apps ignore +// sub-notch wheel values anyway. Accumulate and emit WHOLE notches (±120 +// on the host side), carrying the remainder - trackpad now scrolls like a +// real wheel, and fast flicks scroll fast. +let wheelAccY = 0, wheelAccX = 0; +canvas.addEventListener("wheel", (e) => { + if (S.viewOnly || S.dead) return; + e.preventDefault(); + const p = canvasPos(e); + const unit = e.deltaMode === 1 ? 3 : e.deltaMode === 2 ? 1 : 100; + wheelAccY += -e.deltaY / unit; + wheelAccX += e.deltaX / unit; + // half-notch granularity (host sends ±60): same stream a Windows Precision + // Touchpad produces, so apps scroll smoothly and gentle swipes respond fast + const w = Math.trunc(wheelAccY * 2) / 2; + const hw = Math.trunc(wheelAccX * 2) / 2; + if (w || hw) { + wheelAccY -= w; + wheelAccX -= hw; + send({ t: "i.mouse", x: p.x, y: p.y, b: S.buttons, + w: Math.max(-10, Math.min(10, w)), hw: Math.max(-10, Math.min(10, hw)) }); + } +}, { passive: false }); +canvas.addEventListener("contextmenu", (e) => e.preventDefault()); +// back/forward mouse buttons fire 'auxclick'; also stop the browser navigating +canvas.addEventListener("auxclick", (e) => { if (e.button === 3 || e.button === 4) e.preventDefault(); }); + +function flushMouse() { + if (!S.pendingMouse || S.dead || S.viewOnly) return; + // adapt the move rate to the link: flooding a 300ms+ mobile link with + // 120 msg/s just builds a queue and replays your hand in slow motion + const gap = S.rtt > 400 ? 50 : S.rtt > 200 ? 33 : S.rtt > 100 ? 16 : 8; + const now = performance.now(); + if (now - (S._lastMoveSend || 0) < gap - 1) return; + S._lastMoveSend = now; + send({ t: "i.mouse", ...S.pendingMouse, b: S.buttons, _mv: true }); // _mv: pure motion → unreliable channel + S.pendingMouse = null; +} + +// Keyboard is captured at the WINDOW level (capture phase), not the canvas, so +// it keeps working in fullscreen and after you click any toolbar button - the +// canvas losing focus no longer kills the keyboard (a major "keys don't work" +// cause). We only skip when the session isn't showing or you're typing into a +// real form field (chat, file path, connect screen). +function keyGoesToRemote() { + if (S.viewOnly || S.dead) return false; + if ($("#screenSession").hidden) return false; + const el = document.activeElement, tag = (el && el.tagName) || ""; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || (el && el.isContentEditable)) + return false; + return true; +} +window.addEventListener("keydown", (e) => { + if (e.code === "F11") return; // let the browser toggle fullscreen + if (!keyGoesToRemote()) return; + // Mac client: let Cmd+V fall through untouched so the browser fires the + // 'paste' event below - we then push the Mac clipboard and paste remotely. + // (Works even in Safari, which blocks background clipboard polling.) + if (IS_MAC_CLIENT && e.metaKey && e.code === "KeyV" && !e.altKey && !e.shiftKey) return; + e.preventDefault(); // keep Ctrl+C/V/A/W/Tab etc. from hitting the browser + const kc = mapKey(e.code); + S.heldKeys.add(kc); + send({ t: "i.key", c: kc, d: true }); +}, true); +window.addEventListener("keyup", (e) => { + if (S.dead) return; + const kc = mapKey(e.code); + const had = S.heldKeys.delete(kc); + if (!had && !keyGoesToRemote()) return; // still release keys we had pressed + e.preventDefault(); + send({ t: "i.key", c: kc, d: false }); +}, true); +// Mac keyboards: Cmd acts as Ctrl on the remote Windows PC (RustDesk-style), +// so Cmd+C / Cmd+V / Cmd+A / Cmd+Z do what a Mac user expects instead of +// hitting the Windows key. +const IS_MAC_CLIENT = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent); +function mapKey(code) { + if (IS_MAC_CLIENT) { + if (code === "MetaLeft") return "ControlLeft"; + if (code === "MetaRight") return "ControlRight"; + } + return code; +} +// Gesture-based paste path (Safari-proof): grab the pasted text directly from +// the event, push it to the remote clipboard, then tap Ctrl+V over there. +window.addEventListener("paste", (e) => { + if (!keyGoesToRemote()) return; + const txt = e.clipboardData && e.clipboardData.getData("text"); + if (!txt) return; + e.preventDefault(); + S.lastRemoteClip = txt; + send({ t: "clip.set", text: txt, origin: "viewer" }); + setTimeout(() => { + send({ t: "i.key", c: "ControlLeft", d: true }); + send({ t: "i.key", c: "KeyV", d: true }); + send({ t: "i.key", c: "KeyV", d: false }); + send({ t: "i.key", c: "ControlLeft", d: false }); + }, 140); +}, true); +// release every held key when focus leaves / tab hides, so modifiers never get +// "stuck" down on the remote (classic RustDesk/TeamViewer issue after Alt+Tab) +window.addEventListener("blur", releaseAllKeys); +document.addEventListener("visibilitychange", () => { if (document.hidden) releaseAllKeys(); }); +document.addEventListener("fullscreenchange", () => { + const c = $("#canvas"); if (c && !$("#screenSession").hidden) c.focus(); +}); +function releaseAllKeys() { + if (!S.heldKeys || !S.heldKeys.size) return; + for (const code of S.heldKeys) send({ t: "i.key", c: code, d: false }); + S.heldKeys.clear(); +} + +/* ------------------------------------------------------------- toolbar */ +$("#btnDisconnect").onclick = () => { + // an EXPLICIT disconnect: don't auto-resume afterwards + S.userEnd = true; + try { localStorage.removeItem("nd.resume"); } catch {} + try { S.ws.close(); } catch {} +}; +$("#selMonitor").addEventListener("change", () => { + localStorage.setItem("nd.lastmon", $("#selMonitor").value); +}); +$("#selQuality").addEventListener("change", () => { + localStorage.setItem("nd.lastq", $("#selQuality").value); +}); +const _origQChange = $("#selQuality").onchange; +$("#selQuality").onchange = (e) => { + localStorage.setItem("nd.lastq", $("#selQuality").value); + if (_origQChange) _origQChange(e); +}; +$("#selMonitor").onchange = () => send({ t: "monitor.switch", mon: parseInt($("#selMonitor").value, 10) }); +function setViewOnly(on) { + S.viewOnly = on; + $("#btnViewOnly span").textContent = on ? "Input off" : "Input on"; + $("#btnViewOnly").classList.toggle("active", on); + canvas.classList.toggle("viewonly", on); + if (S.curId != null) applyCursorShape(S.curId); // native cursor on/off with input + // HOST-ENFORCED: the engine drops input while on (defense in depth under + // this local toggle — a modified client couldn't inject either). + if (!S.dead) send({ t: "view.only", on }); +} +$("#btnViewOnly").onclick = () => { + if (S.inputDeniedByHost) return; // host policy wins + setViewOnly(!S.viewOnly); +}; +$("#btnFullscreen").onclick = async () => { + try { + if (document.fullscreenElement) await document.exitFullscreen(); + else await $("#stage").requestFullscreen(); + } catch {} +}; +$("#btnKbdLock").onclick = async () => { + if (S.kbdLocked) { + try { await navigator.keyboard.unlock(); } catch {} + S.kbdLocked = false; + } else { + try { + if (!document.fullscreenElement) await $("#stage").requestFullscreen(); + await navigator.keyboard.lock(); + S.kbdLocked = true; + showBanner("Keyboard locked: system keys (Alt+Tab, Win, Ctrl+W) now go to the remote PC.", true, 2600); + } catch { + showBanner("Keyboard lock needs Chrome/Edge and fullscreen.", false, 2600); + } + } + $("#btnKbdLock").classList.toggle("active", S.kbdLocked); +}; + +/* clipboard sync */ +$("#btnClipboard").onclick = async () => { + S.clipSync = !S.clipSync; + $("#btnClipboard").classList.toggle("active", S.clipSync); + if (S.clipSync) { + try { await navigator.clipboard.readText(); } // triggers the permission prompt + catch { showBanner("Clipboard permission denied by browser.", false, 2200); } + } +}; +async function onRemoteClip(m) { + S.lastRemoteClip = m.text || ""; + if (S.clipSync && m.text) { + try { await navigator.clipboard.writeText(m.text); } catch {} + } +} + +// The host copied an IMAGE: write it to the browser clipboard as a PNG +// ClipboardItem so the user can paste it locally. Requires the clipboard +// permission + a secure context (netdesk.space is https), and a recent +// browser; failures are silent (the feature degrades, nothing breaks). +async function onRemoteClipImage(m) { + if (!S.clipSync || !m.png) return; + try { + const bytes = b64ToBytes(m.png); + const blob = new Blob([bytes], { type: "image/png" }); + if (navigator.clipboard && window.ClipboardItem) { + await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); + } + } catch {} +} + +/* ------------------------------------------------------------- remote cursor overlay */ +// fallback arrow for the touch overlay when the host could only name the cursor +const ARROW_SVG = "data:image/svg+xml;utf8," + encodeURIComponent( + '' + + ''); +function onCursorShape(m) { + S.curShapes = S.curShapes || {}; + // png = exact bitmap (custom cursors); css = a standard cursor's name, which the + // OS pointer renders natively and which arrives even when the bitmap can't + S.curShapes[m.id] = { url: m.png ? "data:image/png;base64," + m.png : ARROW_SVG, + w: m.w || 24, h: m.h || 24, hx: m.hx || 0, hy: m.hy || 0, css: m.css || null }; + if (S.curId === m.id) applyCursorShape(m.id); +} +function onCursorPos(m) { + const el = $("#remoteCursor"); + if (!el) return; + if (m.hidden) { el.hidden = true; $("#stage").classList.remove("remote-cursor"); return; } + if (S.curId !== m.id) { + S.curId = m.id; + if ((S.curShapes || {})[m.id]) applyCursorShape(m.id); + } + // while the local pointer is actively driving, ignore the host's laggy + // position echoes (they arrive ~RTT late) - shape/id updates were kept above + if (S._localCurTs && performance.now() - S._localCurTs < 400) return; + if (nativeCur()) { + // someone at the remote PC moved its mouse: show where it went as an + // overlay next to our own (parked) pointer - but never as a duplicate of + // our pointer sitting right there + const lp = S._localPos; + const far = !lp || Math.abs(lp.x - m.x) + Math.abs(lp.y - m.y) > 12; + const shp = (S.curShapes || {})[m.id]; + const onScreen = m.x >= 0 && m.y >= 0 && m.x < (S.dims.w || 1) && m.y < (S.dims.h || 1); + if (!far || !shp || !onScreen) { el.hidden = true; return; } + S.curPos = { x: m.x, y: m.y }; + if (el.dataset.id != String(m.id)) applyCursorShape(m.id); + positionCursor(); + el.hidden = false; // OS pointer stays visible (no remote-cursor class) + return; + } + S.curPos = { x: m.x, y: m.y }; + // hide the overlay when the cursor is off the monitor currently being viewed + if (m.x < 0 || m.y < 0 || m.x >= (S.dims.w || 1) || m.y >= (S.dims.h || 1)) { + el.hidden = true; $("#stage").classList.remove("remote-cursor"); return; + } + const shp = (S.curShapes || {})[m.id]; + if (!shp) return; // shape not arrived yet; wait for cursor.shape + if (el.dataset.id != String(m.id)) applyCursorShape(m.id); + positionCursor(); + el.hidden = false; + $("#stage").classList.add("remote-cursor"); // hide the local cursor over the canvas +} +function applyCursorShape(id) { + const shp = (S.curShapes || {})[id]; const el = $("#remoteCursor"); + if (!shp || !el) return; + el.src = shp.url; el.dataset.id = String(id); + // fine pointer: the remote shape becomes the real OS cursor (Chrome caps + // custom cursors at 128px; bigger ones fall back to the normal arrow) + if (!IS_TOUCH_CLIENT) { + const bitmap = shp.url.startsWith("data:image/png") && shp.w <= 128 && shp.h <= 128; + canvas.style.cursor = S.viewOnly ? "" : shp.css ? shp.css + : bitmap ? "url(" + shp.url + ") " + shp.hx + " " + shp.hy + ", auto" : ""; + } +} +function positionCursor() { + const el = $("#remoteCursor"); if (!el) return; + const shp = (S.curShapes || {})[S.curId]; if (!shp || !S.curPos) return; + const c = $("#canvas"), st = $("#stage"); + const r = c.getBoundingClientRect(), sr = st.getBoundingClientRect(); + const iw = S.dims.w || 1, ih = S.dims.h || 1; + const k = Math.min(r.width / iw, r.height / ih) || 1; // image px -> display px + const ox = r.left + (r.width - iw * k) / 2, oy = r.top + (r.height - ih * k) / 2; + const px = ox + S.curPos.x * k, py = oy + S.curPos.y * k; + // in Original (1:1) mode the stage scrolls; getBoundingClientRect already + // subtracts scroll, so add it back once to place the overlay in content space + el.style.left = (px - sr.left + st.scrollLeft - shp.hx * k) + "px"; + el.style.top = (py - sr.top + st.scrollTop - shp.hy * k) + "px"; + el.style.width = (shp.w * k) + "px"; + el.style.height = (shp.h * k) + "px"; +} +// keep the overlay aligned when the window/layout changes +window.addEventListener("resize", () => { if (!$("#screenSession").hidden) positionCursor(); }); +const IS_TOUCH_CLIENT = window.matchMedia && matchMedia("(pointer: coarse)").matches; +setInterval(async () => { + if (!S.clipSync || S.dead || !document.hasFocus()) return; + // iOS shows its native "Paste" permission bubble on EVERY programmatic + // clipboard read - polling made it pop nonstop. Touch devices use the + // gesture paste path (long-press → Paste in a text field, or the ⌨ panel). + if (IS_TOUCH_CLIENT) return; + try { + const txt = await navigator.clipboard.readText(); + if (txt && txt !== S.lastRemoteClip) { + S.lastRemoteClip = txt; + send({ t: "clip.set", text: txt, origin: "viewer" }); + } + } catch {} +}, 900); + +/* panels */ +function bindPanel(btn, panel, onClose) { + $(btn).onclick = () => { + const p = $(panel); + const show = p.hidden; + for (const q of ["#filePanel", "#chatPanel", "#sysPanel"]) $(q).hidden = true; + p.hidden = !show; + if (show && onClose) onClose(); + }; +} +bindPanel("#btnFiles", "#filePanel", () => send({ t: "file.roots" })); +bindPanel("#btnChat", "#chatPanel", () => { S.chatOpen = true; $("#chatText").focus(); }); +bindPanel("#btnSys", "#sysPanel", () => { + send({ t: "sys.cmd", a: "processes" }); + $("#chkLockOnEnd").checked = !!(S.info && S.info.lock_on_end); +}); +$("#btnFilesClose").onclick = () => $("#filePanel").hidden = true; +$("#btnChatClose").onclick = () => { $("#chatPanel").hidden = true; S.chatOpen = false; }; +$("#btnSysClose").onclick = () => $("#sysPanel").hidden = true; + +/* chat */ +function onChat(text, mine) { + const log = $("#chatLog"); + const div = document.createElement("div"); + div.className = "chat-msg" + (mine ? " me" : ""); + const who = document.createElement("div"); + who.className = "who"; + who.textContent = mine ? "You" : "Host"; + const body = document.createElement("div"); + body.textContent = text; + div.append(who, body); + log.append(div); + log.scrollTop = log.scrollHeight; +} +function sendChat() { + const txt = $("#chatText").value.trim(); + if (!txt) return; + $("#chatText").value = ""; + send({ t: "chat", text: txt, ts: Date.now() }); + onChat(txt, true); +} +$("#chatSend").onclick = sendChat; +$("#chatText").addEventListener("keydown", (e) => { + e.stopPropagation(); + if (e.key === "Enter") sendChat(); +}); + +/* power */ +for (const b of document.querySelectorAll("#sysPanel [data-cmd]")) { + b.onclick = () => { + const cmd = b.dataset.cmd; + if (cmd === "reboot" || cmd === "shutdown") { + if (!confirm(`Really ${cmd} the remote computer?`)) return; + } + send({ t: "sys.cmd", a: cmd }); + }; +} +$("#btnSysRun").onclick = () => { + const c = $("#sysCmd").value.trim(); + if (c) send({ t: "sys.cmd", a: "run", cmd: c }); +}; + +/* lock-on-disconnect */ +$("#chkLockOnEnd").onchange = () => + send({ t: "set.cfg", lock_on_end: $("#chkLockOnEnd").checked }); + +/* privacy mode (monitor off) */ +$("#btnPrivacy").onclick = () => send({ t: "sys.cmd", a: "privacy" }); + +/* process manager */ +$("#btnProcRefresh").onclick = () => send({ t: "sys.cmd", a: "processes" }); +function renderProcesses(items) { + const box = $("#procList"); + box.innerHTML = ""; + for (const p of items || []) { + const row = document.createElement("div"); + row.className = "file-row"; + const icon = document.createElement("span"); + icon.textContent = "⚙"; + const name = document.createElement("span"); + name.className = "name"; + name.textContent = p.name; + const meta = document.createElement("span"); + meta.className = "meta"; + meta.textContent = `${p.pid} · ${(p.mem / 1024).toFixed(0)} MB`; + const kill = document.createElement("span"); + kill.className = "acts"; + kill.style.display = "flex"; + const btn = document.createElement("button"); + btn.textContent = "⛔"; + btn.title = "End process"; + btn.onclick = (e) => { + e.stopPropagation(); + if (confirm(`End ${p.name} (PID ${p.pid})?`)) { + send({ t: "sys.cmd", a: "kill", pid: p.pid }); + setTimeout(() => send({ t: "sys.cmd", a: "processes" }), 700); + } + }; + kill.append(btn); + row.append(icon, name, meta, kill); + box.append(row); + } +} + +/* TCP tunnel (portal-gated): raw TCP to a host port over the session server */ +let tnlWs = null, tnlBytes = 0; +$("#btnTnlOpen").onclick = () => { + const st = $("#tnlStatus"); + if (tnlWs) { tnlWs.close(); tnlWs = null; st.textContent = "closed"; return; } + const port = parseInt($("#tnlPort").value, 10); + if (!port) { st.textContent = "enter a port"; return; } + const proto = location.protocol === "https:" ? "wss" : "ws"; + st.textContent = "connecting…"; + const ws = new WebSocket(`${proto}://${location.host}/tunnel/${S.hostId}`); + tnlWs = ws; + ws.binaryType = "arraybuffer"; + ws.onopen = () => { ws.send(JSON.stringify({ t: "open", port })); }; + ws.onmessage = (ev) => { + if (typeof ev.data === "string") { + const m = JSON.parse(ev.data); + if (m.t === "tunnel.ok") st.textContent = "open ✓ " + fmtBytes(tnlBytes); + if (m.t === "tunnel.err") st.textContent = "error: " + m.msg; + if (m.t === "tunnel.closed") st.textContent = "closed"; + return; + } + tnlBytes += ev.data.byteLength; + st.textContent = "open ✓ " + fmtBytes(tnlBytes) + " from host"; + }; + ws.onclose = () => { if (tnlWs === ws) { tnlWs = null; st.textContent = "disconnected"; } }; + ws.onerror = () => { st.textContent = "unauthorized? sign in to the portal"; }; +}; + +/* panel behavior: ESC closes, headers drag (TeamViewer-style floaters) */ +window.addEventListener("keydown", (e) => { + if (e.key !== "Escape") return; + let closed = false; + for (const q of ["#filePanel", "#chatPanel", "#sysPanel"]) { + const p = $(q); + if (p && !p.hidden) { p.hidden = true; closed = true; } + } + if (closed) e.stopPropagation(); +}); +document.querySelectorAll(".panel").forEach((p) => { + const head = p.querySelector(".panel-head"); + if (!head) return; + let dragArmed = false, dx = 0, dy = 0, moved = false; + head.addEventListener("mousedown", (e) => { + if (e.target.closest("button")) return; + dragArmed = true; moved = false; + const r = p.getBoundingClientRect(); + p.style.left = r.left + "px"; + p.style.top = r.top + "px"; + p.style.right = "auto"; + dx = e.clientX - r.left; dy = e.clientY - r.top; + const move = (ev) => { + if (!dragArmed) return; + moved = true; + p.style.left = Math.min(window.innerWidth - 60, Math.max(60 - r.width, ev.clientX - dx)) + "px"; + p.style.top = Math.min(window.innerHeight - 40, Math.max(0, ev.clientY - dy)) + "px"; + }; + const up = () => { + dragArmed = false; + window.removeEventListener("mousemove", move); + window.removeEventListener("mouseup", up); + }; + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + e.preventDefault(); + }); + head.addEventListener("click", (e) => { + if (e.target.closest("button")) return; + if (moved) return; // it was a drag, not a click + if (p.classList.contains("docked")) p.classList.remove("docked"); + }); + // minimize buttons + const minBtn = head.querySelector("[data-min]"); + if (minBtn) minBtn.addEventListener("click", (e) => { + e.stopPropagation(); + p.classList.add("docked"); + }); + // clicking a docked panel restores it + p.addEventListener("click", () => { + if (p.classList.contains("docked")) p.classList.remove("docked"); + }); +}); + +/* session recording (client-side, WebM) */ +const REC_ICON_IDLE = ''; +const REC_ICON_ACTIVE = ''; +let recorder = null, recChunks = []; +// host audio source cycle: 0 off -> 1 system ("what you hear") -> 2 microphone +S.audioSrc = 0; +if ($("#btnAudio")) $("#btnAudio").onclick = async () => { + // Make sure the decoder exists and the AudioContext is RUNNING — browsers keep + // it suspended until a user gesture resumes it, so a resume() from inside + // playAudio (no gesture) is often ignored and you get silence. This click IS + // the gesture, so resume here. + if (!S.audioDecoder) setupAudio(); + try { if (S.audioCtx && S.audioCtx.state !== "running") await S.audioCtx.resume(); } catch (e) {} + S.audioSrc = (S.audioSrc + 1) % 3; + send({ t: "audio.set", src: S.audioSrc }); + const names = ["Audio", "🔊 System", "🎤 Mic"]; + $("#audioLbl").textContent = names[S.audioSrc]; + $("#btnAudio").classList.toggle("active", S.audioSrc !== 0); + showBanner("Host audio: " + (S.audioSrc === 0 ? "off (click for system sound)" : S.audioSrc === 1 ? "system sound ON" : "microphone ON"), true, 1800); +}; + +// Browsers keep a freshly created AudioContext suspended until the user +// interacts with the page. Because you actively control the host (mouse/keys on +// the canvas), ANY of those gestures is enough — resume the audio engine on the +// first one so remote sound (auto-requested on connect) starts playing without +// needing a separate, specific button click. +["pointerdown", "mousedown", "keydown", "touchstart"].forEach((evt) => + window.addEventListener( + evt, + () => { try { if (S.audioCtx && S.audioCtx.state === "suspended") S.audioCtx.resume(); } catch (e) {} }, + { capture: true, passive: true } + ) +); + +$("#btnRec").onclick = () => { + if (recorder) { + recorder.stop(); + return; + } + try { + const stream = $("#canvas").captureStream(30); + recChunks = []; + recorder = new MediaRecorder(stream, { mimeType: "video/webm" }); + recorder.ondataavailable = (e) => { if (e.data.size) recChunks.push(e.data); }; + recorder.onstop = () => { + const blob = new Blob(recChunks, { type: "video/webm" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `netdesk-${S.hostId}-${new Date().toISOString().replace(/[:.]/g, "-")}.webm`; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 30000); + recorder = null; + $("#btnRec").classList.remove("active", "rec-on"); + $("#btnRec").innerHTML = REC_ICON_IDLE; + showBanner("Recording saved.", true, 2500); + }; + recorder.start(); + $("#btnRec").classList.add("active", "rec-on"); + $("#btnRec").innerHTML = REC_ICON_ACTIVE; + showBanner("Recording…", true); + } catch (e) { + showBanner("Recording not supported in this browser: " + e.message, false, 3000); + recorder = null; + } +}; + +/* adaptive quality */ +let autoTimer = null; +const AUTO_ORDER = ["min", "low", "balanced", "high", "extreme"]; +function autoStep() { + if ($("#selQuality").value !== "auto" || S.dead) return; + const TOP = AUTO_ORDER.length - 1; + const queue = S.decoder ? S.decoder.decodeQueueSize : 0; + const idx = AUTO_ORDER.indexOf(S.autoLevel || "balanced"); + // Congestion = RTT INFLATION over the session's baseline, not absolute RTT. + // A stable 500ms link (far away / mobile) can still run high quality; what + // hurts is rtt growing above its own floor (queues building = link choked). + // RTT measured over the direct path when it's up, else over the relay + const rtt = (S._rtc && S._rtc.up && S._rtc.rttMs) ? S._rtc.rttMs : S.rtt; + if (rtt) S._rttBase = Math.min((S._rttBase || rtt) * 1.01 + 1, rtt); + const base = S._rttBase || 0; + const lag = Math.max(0, (rtt || 0) - base); + // congestion = inflation relative to the link's own floor (a 300ms + // intercontinental link jitters far more in absolute ms than a 20ms one) + const bad = lag > Math.max(120, base * 0.6) || queue > 8; + const good = lag < Math.max(40, base * 0.2) && queue < 2; + // hysteresis: act only on a SUSTAINED condition, then cool down 8s - + // every change rebuilds the encoder (keyframe), so flapping = stutter + S._autoBad = bad ? (S._autoBad || 0) + 1 : 0; + S._autoGood = good ? (S._autoGood || 0) + 1 : 0; + if (performance.now() - (S._autoChangedAt || 0) < 8000) return; + let want = idx; + if (S._autoBad >= 3) want = Math.max(0, idx - (queue > 20 ? 2 : 1)); + else if (S._autoGood >= 6 && + S.fpsShown >= QUALITY[AUTO_ORDER[Math.min(idx + 1, TOP)]].fps - 5) + want = Math.min(TOP, idx + 1); + if (want !== idx) { S._autoChangedAt = performance.now(); S._autoBad = S._autoGood = 0; } + if (want !== idx) { + S.autoLevel = AUTO_ORDER[want]; + const q = QUALITY[S.autoLevel]; + send({ t: "set.quality", ...q }); + $("#tbStats").textContent = `auto → ${S.autoLevel}`; + } +} +$("#selQuality").addEventListener("change", () => { + S.autoLevel = null; + const v = $("#selQuality").value; + if (v === "auto") { + S.autoLevel = "balanced"; + send({ t: "set.quality", ...QUALITY.balanced }); + } else if (QUALITY[v]) { + // manual preset: actually apply it (was silently doing nothing before) + send({ t: "set.quality", ...QUALITY[v] }); + } +}); + +/* ------------------------------------------------------------- files */ +function setFileStatus(msg, isErr) { + const el = $("#fileStatus"); + el.textContent = msg; + el.style.color = isErr ? "#fda4a4" : ""; +} +function onFileList(m) { + const list = $("#fileList"); + list.innerHTML = ""; + $("#filePath").value = m.path || ""; + for (const it of m.items || []) { + const row = document.createElement("div"); + row.className = "file-row"; + const icon = document.createElement("span"); + icon.textContent = it.t === "d" ? "📁" : "📄"; + const name = document.createElement("span"); + name.className = "name"; + name.textContent = it.n; + const meta = document.createElement("span"); + meta.className = "meta"; + meta.textContent = it.t === "d" ? "" : fmtBytes(it.s || 0); + const acts = document.createElement("span"); + acts.className = "acts"; + if (it.t === "f") { + const dl = document.createElement("button"); + dl.textContent = "⬇"; + dl.title = "download"; + dl.onclick = (e) => { e.stopPropagation(); downloadFile(joinPath(m.path, it.n), it.n, it.s); }; + acts.append(dl); + if (isPrintable(it.n)) { + const pr = document.createElement("button"); + pr.textContent = "🖨"; + pr.title = "print on this device"; + pr.onclick = (e) => { e.stopPropagation(); printFile(joinPath(m.path, it.n), it.n, it.s); }; + acts.append(pr); + } + } + const ren = document.createElement("button"); + ren.textContent = "✏"; + ren.title = "rename"; + ren.onclick = (e) => { + e.stopPropagation(); + const nn = prompt("New name", it.n); + if (nn) send({ t: "file.ren", src: joinPath(m.path, it.n), dst: joinPath(m.path, nn) }); + }; + const del = document.createElement("button"); + del.textContent = "🗑"; + del.title = "delete"; + del.onclick = (e) => { + e.stopPropagation(); + if (confirm(`Delete ${it.n}?`)) send({ t: "file.del", path: joinPath(m.path, it.n), dir: it.t === "d" }); + }; + acts.append(ren, del); + row.append(icon, name, meta, acts); + if (it.t === "d") row.ondblclick = () => send({ t: "file.list", path: joinPath(m.path, it.n) }); + else row.ondblclick = () => downloadFile(joinPath(m.path, it.n), it.n, it.s); + list.append(row); + } +} +function joinPath(dir, name) { + if (!dir) return name; + if (/[\\/]$/.test(dir)) return dir + name; + return dir + "/" + name; +} +$("#btnFileGo").onclick = () => send({ t: "file.list", path: $("#filePath").value.trim() }); +$("#filePath").addEventListener("keydown", (e) => { + e.stopPropagation(); + if (e.key === "Enter") send({ t: "file.list", path: $("#filePath").value.trim() }); +}); +$("#btnFileUp").onclick = () => { + let p = $("#filePath").value.replace(/[\\/][^\\/]*[\\/]?$/, ""); + send({ t: "file.list", path: p }); +}; +$("#btnFileHome").onclick = () => send({ t: "file.roots" }); +$("#btnFileMkdir").onclick = () => { + const n = prompt("Folder name"); + if (n) send({ t: "file.mkdir", path: joinPath($("#filePath").value, n) }); +}; + +// ---- IDM-style downloads: when the host advertises `fr` (file ranges), split +// the file into parallel ranged segments with windowed file.ack flow control, +// live progress, and resume of only the missing byte ranges after a reconnect. +// Hosts without `fr` get the legacy single full-file stream. +function downloadFile(path, name, size, opts) { + size = size || 0; + const print = !!(opts && opts.print); + const verb = print ? "Preparing to print " : "Downloading "; + const ranged = !!(S.info && S.info.fr) && size > 0; + if (!ranged) { + const fid = S.nextFid++; + S.transfers[fid] = { path, name, size, chunks: new Map(), ranged: false, print }; + setFileStatus(verb + name + "…"); + send({ t: "file.get", path, fid }); + return; + } + const nSegs = size >= 8 * 1024 * 1024 ? 4 : size >= 2 * 1024 * 1024 ? 2 : 1; + const segLen = Math.ceil(size / nSegs); + const tr = { path, name, size, chunks: new Map(), ranged: true, print, segs: [], rcvdTotal: 0, lastUi: 0, sidEpoch: S.sid }; + for (let i = 0; i < nSegs; i++) { + const off = i * segLen; + const len = Math.min(segLen, size - off); + if (len <= 0) break; + const fid = S.nextFid++; + tr.segs.push({ fid, off, len, rcvd: 0, reqRcvd: 0, lastAck: 0, done: false }); + S.transfers[fid] = tr; + send({ t: "file.get", path, fid, offset: off, len }); + } + setFileStatus(`${verb}${name} in ${tr.segs.length} segment(s)…`); +} +// Printable types the browser can render + print without a server-side driver. +function isPrintable(name) { + const ext = (String(name).split(".").pop() || "").toLowerCase(); + return [ + "pdf", + "png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", + "txt", "log", "md", "csv", "json", "xml", "yml", "yaml", "ini", "conf", + "html", "htm", "js", "css", "py", "rs", "c", "h", "cpp", "java", "sh", "bat", "ps1" + ].includes(ext); +} +function printFile(path, name, size) { downloadFile(path, name, size, { print: true }); } +function escHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} +// Open an already-downloaded blob in a hidden same-origin iframe and invoke the +// browser's native print dialog — this prints the HOST's file on the VIEWER's +// local printer, with zero driver install on either side. PDFs/images render +// directly; text-like files are wrapped in a monospace
.
+function printBlob(blob, name) {
+  const ext = (String(name).split(".").pop() || "").toLowerCase();
+  const url = URL.createObjectURL(blob);
+  const done = (ok) => {
+    setFileStatus(ok ? "Sent " + name + " to your printer" : "Could not print " + name, !ok);
+    setTimeout(() => URL.revokeObjectURL(url), 60000);
+  };
+  const frame = document.createElement("iframe");
+  frame.setAttribute("aria-hidden", "true");
+  frame.style.cssText = "position:fixed;right:0;bottom:0;width:0;height:0;border:0;visibility:hidden";
+  const fire = () => {
+    try { frame.contentWindow.focus(); frame.contentWindow.print(); done(true); }
+    catch (e) { try { window.open(url, "_blank"); done(true); } catch (e2) { done(false); } }
+  };
+  const imgExts = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico"];
+  if (ext === "pdf") {
+    frame.src = url;
+    frame.onload = () => setTimeout(fire, 300);
+    document.body.appendChild(frame);
+  } else if (imgExts.includes(ext)) {
+    document.body.appendChild(frame);
+    const d = frame.contentWindow.document;
+    d.open();
+    d.write('' + escHtml(name) + '' +
+      '' +
+      '');
+    d.close();
+    done(true);
+  } else {
+    blob.text().then((txt) => {
+      document.body.appendChild(frame);
+      const d = frame.contentWindow.document;
+      d.open();
+      d.write('' + escHtml(name) + '' +
+        '
' + escHtml(txt) + '
'); + d.close(); + setTimeout(fire, 150); + }).catch(() => done(false)); + } +} +function onFileData(bytes, isUploadAck) { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const fid = dv.getUint32(1, true); + const off = Number(dv.getBigUint64(5, true)); + const tr = S.transfers[fid]; + if (!tr) return; + const data = bytes.subarray(13); + tr.chunks.set(off, data); + if (!tr.ranged) return; + const seg = tr.segs.find((s) => s.fid === fid); + if (seg) { + seg.rcvd += data.length; + seg.reqRcvd += data.length; + // Advance the host's send window roughly every 1 MB per segment. + if (seg.reqRcvd - seg.lastAck >= 1024 * 1024) { + seg.lastAck = seg.reqRcvd; + send({ t: "file.ack", fid, rcvd: seg.reqRcvd }); + } + // Completion is driven by BYTE COUNT here, NOT by file.done: the control-lane + // file.done can arrive before the last data chunks over the media lane, so + // gating completion on it raced and segments never closed even though every + // byte arrived. Once a segment has all its bytes, finalize it. + if (!seg.done && seg.rcvd >= seg.len) segComplete(tr, seg, fid); + } + tr.rcvdTotal += data.length; + const nowMs = performance.now(); + if (nowMs - tr.lastUi > 300) { + tr.lastUi = nowMs; + const pct = Math.min(100, Math.round((tr.rcvdTotal / Math.max(1, tr.size)) * 100)); + setFileStatus(`Downloading ${tr.name}… ${pct}% (${fmtBytes(tr.rcvdTotal)} / ${fmtBytes(tr.size)})`); + } +} +function segComplete(tr, seg, fid) { + if (seg.done || seg.rcvd < seg.len) return; + seg.done = true; + try { send({ t: "file.ack", fid, rcvd: seg.reqRcvd }); } catch (e) {} // final window release + if (tr.segs.every((s) => s.done)) { + finishDownload(tr); + for (const s of tr.segs) delete S.transfers[s.fid]; + } +} +function finishDownload(tr) { + const parts = [...tr.chunks.keys()].sort((a, b) => a - b).map((k) => tr.chunks.get(k)); + const blob = new Blob(parts); + if (tr.print) { printBlob(blob, tr.name || "file"); return; } + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = tr.name || "file"; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 30000); + setFileStatus("Saved " + a.download + " (" + fmtBytes(blob.size) + ")"); +} +function onFileDone(m) { + const tr = S.transfers[m.fid]; + if (!tr) return; + if (!tr.ranged) { + tr.name = m.name || tr.name; + finishDownload(tr); + delete S.transfers[m.fid]; + return; + } + // file.done is a BACKUP completion trigger; byte-count completion in onFileData + // is primary (file.done can arrive before the last data chunks). + const seg = tr.segs.find((s) => s.fid === m.fid); + if (seg) segComplete(tr, seg, m.fid); +} +// After a reconnect (new sid), re-request ONLY the missing byte ranges of any +// unfinished ranged download — IDM-style resume. +function resumeTransfers() { + if (!(S.info && S.info.fr)) return; + const seen = new Set(); + for (const k of Object.keys(S.transfers)) { + const tr = S.transfers[k]; + if (!tr || !tr.ranged || seen.has(tr)) continue; + seen.add(tr); + if (tr.sidEpoch === S.sid) continue; + tr.sidEpoch = S.sid; + for (const seg of tr.segs) { + if (seg.done || seg.rcvd >= seg.len) { seg.done = true; continue; } + delete S.transfers[seg.fid]; + seg.fid = S.nextFid++; + seg.reqRcvd = 0; + seg.lastAck = 0; + S.transfers[seg.fid] = tr; + send({ t: "file.get", path: tr.path, fid: seg.fid, offset: seg.off + seg.rcvd, len: seg.len - seg.rcvd }); + } + setFileStatus(`Resuming ${tr.name}… (${fmtBytes(tr.rcvdTotal)} / ${fmtBytes(tr.size)})`); + } +} +function onFileOk(m) { + setFileStatus(m.msg || "ok"); + if (m.refresh) send({ t: "file.list", path: $("#filePath").value }); +} + +async function uploadFile(file, destPath, viaBanner) { + const fid = S.nextFid++; + // no explicit target and no folder open in the Files panel -> the host + // drops it on the remote DESKTOP (TeamViewer behaviour) + const path = (destPath || joinPath($("#filePath").value || "", file.name)).replace(/\//g, "\\"); + setFileStatus(`Uploading ${file.name} (${fmtBytes(file.size)})…`); + if (viaBanner) showBanner(`⬆ Sending ${file.name} to the remote Desktop… 0%`, true, 0); + send({ t: "file.put", path, size: file.size, fid }); + const CHUNK = 256 * 1024; + for (let off = 0; off < file.size || off === 0; off += CHUNK) { + const blob = file.slice(off, off + CHUNK); + const head = new Uint8Array(13); + new DataView(head.buffer).setUint32(1, fid, true); + new DataView(head.buffer).setBigUint64(5, BigInt(off), true); + const part = new Uint8Array(await blob.arrayBuffer()); + const msg = new Uint8Array(13 + part.length); + msg.set(head, 0); + msg.set(part, 13); + msg[0] = 0xf2; + await sendBinary(msg); + const pct = Math.min(100, Math.round(((off + part.length) / Math.max(1, file.size)) * 100)); + setFileStatus(`Uploading ${file.name}… ${pct}%`); + if (viaBanner) showBanner(`⬆ Sending ${file.name} to the remote Desktop… ${pct}%`, true, 0); + // Real backpressure instead of a fixed sleep: run flat-out while the socket + // drains, pause only when >4 MB is buffered. Much faster on fast links, + // bounded memory on slow ones (IDM-style windowing for uploads). + while (S.ws && S.ws.readyState === 1 && S.ws.bufferedAmount > 4 * 1024 * 1024) { + await new Promise((r) => setTimeout(r, 10)); + } + if (file.size === 0) break; + } + if (viaBanner) showBanner(`✓ ${file.name} delivered to the remote Desktop`, true, 3200); +} +$("#btnFileUpload").onclick = () => $("#fileInput").click(); +$("#fileInput").onchange = async () => { + for (const f of $("#fileInput").files) await uploadFile(f); + $("#fileInput").value = ""; + send({ t: "file.list", path: $("#filePath").value }); +}; +$("#btnDirUpload").onclick = () => $("#dirInput").click(); +$("#dirInput").onchange = async () => { + const files = [...$("#dirInput").files]; + const base = $("#filePath").value || ""; + for (let i = 0; i < files.length; i++) { + const f = files[i]; + const rel = (f.webkitRelativePath || f.name).split("/").slice(1).join("\\") || f.name; + const dest = base ? `${base}\\${rel}` : rel.replace(/\\[^\\]+$/, ""); + setFileStatus(`Folder upload ${i + 1}/${files.length}: ${rel}`); + await uploadFile(f, dest); + } + $("#dirInput").value = ""; + send({ t: "file.list", path: $("#filePath").value }); +}; +const stage = $("#stage"); +stage.addEventListener("dragover", (e) => { e.preventDefault(); $("#dropHint").hidden = false; }); +stage.addEventListener("dragleave", () => { $("#dropHint").hidden = true; }); +stage.addEventListener("drop", async (e) => { + e.preventDefault(); + $("#dropHint").hidden = true; + for (const f of e.dataTransfer.files) await uploadFile(f, null, true); +}); + +/* ------------------------------------------------------------- misc UI */ +function setState(txt, cls) { + const el = $("#tbState"); + el.textContent = txt; + el.className = "state " + (cls || ""); +} +let bannerTimer = null; +function showBanner(msg, isInfo, ms) { + const b = $("#banner"); + b.textContent = msg; + b.className = "banner" + (isInfo ? " info" : ""); + b.hidden = false; + if (bannerTimer) clearTimeout(bannerTimer); + if (ms) bannerTimer = setTimeout(hideBanner, ms); +} +function hideBanner() { $("#banner").hidden = true; } + +function updateStats() { + autoStep(); + let limitTxt = "", rem = Infinity; + if (S.limit) { + rem = Math.max(0, S.limit - (now() - (S.startedAt || now())) / 1000); + const mm = String(Math.floor(rem / 60)).padStart(2, "0"); + const ss = String(Math.floor(rem % 60)).padStart(2, "0"); + limitTxt = ` · ${mm}:${ss} left`; + } + const mbps = ((S.bytesVideo * 8) / 1e6 / Math.max(0.001, (now() - S.lastFpsTick) / 1000 + 1)).toFixed(1); + S.bytesVideo = 0; + const el = $("#tbStats"); + el.textContent = + `${S.dims.w}×${S.dims.h} · ${S.fpsShown}fps · ~${mbps} Mbps · ${S.rtt}ms${S.e2e ? " · 🔒E2E" : ""}${limitTxt}`; + el.classList.toggle("limit-warn", !!S.limit && rem < 300); + // live connection-quality tint (green / amber / red by round-trip latency) + el.dataset.q = S.rtt < 80 ? "good" : S.rtt < 180 ? "ok" : "bad"; +} + +function teardownMedia() { + if (S.latestFrame) { try { S.latestFrame.close(); } catch {} S.latestFrame = null; } + if (S.decoder) { try { S.decoder.close(); } catch {} S.decoder = null; } + if (S.audioDecoder) { try { S.audioDecoder.close(); } catch {} S.audioDecoder = null; } + if (S.audioCtx) { try { S.audioCtx.close(); } catch {} S.audioCtx = null; } + try { stopVoice(); } catch {} + S.started = false; +} + +// TeamViewer-style collapse: ▲ hides the docked bar (video gets the full +// height), a tiny ▼ tab at top-center brings it back +(function () { + var tab = document.getElementById("tbTab"), tb = document.getElementById("toolbar"), + show = document.getElementById("tbShow"); + if (tab && tb) tab.addEventListener("click", function (e) { + e.stopPropagation(); + tb.classList.add("collapsed"); + if (show) show.hidden = false; + }); + if (show && tb) show.addEventListener("click", function (e) { + e.stopPropagation(); + tb.classList.remove("collapsed"); + show.hidden = true; + }); +})(); + +/* ============== TeamViewer-style session menus (Actions / View / Extras) == */ +(function () { + const menus = ["mActions", "mView", "mComm", "mExtras"]; + function closeMenus() { + menus.forEach((id) => { const m = $("#" + id); if (m) m.hidden = true; }); + document.querySelectorAll(".menu-btn.open").forEach((b) => b.classList.remove("open")); + } + function syncMenuState() { + const le = $("#miLockEnd"); + if (le) le.classList.toggle("on", !!(S.info && S.info.lock_on_end)); + document.querySelectorAll("#mView .mradio").forEach((r) => + r.classList.toggle("on", (S.scaleMode || "fit") === r.dataset.scale)); + } + document.querySelectorAll(".menu-btn").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const m = $("#" + btn.dataset.menu); + const wasOpen = m && !m.hidden; + closeMenus(); + if (!m || wasOpen) return; + syncMenuState(); + // fixed positioning: never clipped by the bar, clamped to the viewport + const br = btn.getBoundingClientRect(); + const tbr = document.getElementById("toolbar").getBoundingClientRect(); + m.style.top = (tbr.bottom + 4) + "px"; + m.style.left = Math.max(4, br.left - 6) + "px"; + m.hidden = false; + requestAnimationFrame(() => { + m.style.left = Math.max(4, Math.min(parseFloat(m.style.left) || 4, + window.innerWidth - m.offsetWidth - 6)) + "px"; + }); + btn.classList.add("open"); + if (btn.dataset.menu === "mView" && !window.__modesLoaded) loadModes(); + }); + }); + document.addEventListener("click", closeMenus); + document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenus(); }); + // collapsing the toolbar must also dismiss any open menu (tbTab stops propagation) + const _tab = $("#tbTab"); if (_tab) _tab.addEventListener("click", closeMenus); + menus.forEach((id) => { const m = $("#" + id); if (m) m.addEventListener("click", (e) => e.stopPropagation()); }); + + /* ---- Actions ---- */ + const miWol = $("#miWol"); + if (miWol) miWol.addEventListener("click", () => { + const mac = prompt( + "Wake-on-LAN\n\nEnter the MAC address of the PC to wake (e.g. AA:BB:CC:DD:EE:FF).\n" + + "This computer will broadcast the wake signal on its local network, so it must be on the same LAN as the sleeping PC."); + if (mac && mac.trim()) { + send({ t: "sys.cmd", a: "wol", mac: mac.trim() }); + showBanner("Sending wake signal to " + mac.trim() + "…", true, 2200); + } + }); + document.querySelectorAll(".mi[data-sys]").forEach((mi) => { + mi.addEventListener("click", () => { + const q = mi.dataset.confirm; + if (q && !window.confirm(q)) return; + send({ t: "sys.cmd", a: mi.dataset.sys }); + closeMenus(); + }); + }); + const miLockEnd = $("#miLockEnd"); + if (miLockEnd) miLockEnd.addEventListener("click", () => { + const next = !(S.info && S.info.lock_on_end); + S.info = Object.assign({}, S.info || {}, { lock_on_end: next }); + send({ t: "set.cfg", lock_on_end: next }); + const c = $("#chkLockOnEnd"); if (c) c.checked = next; + syncMenuState(); + }); + const miInfo = $("#miInfo"); + if (miInfo) miInfo.addEventListener("click", () => { + closeMenus(); + const dur = S._t0 ? Math.round((Date.now() - S._t0) / 1000) : 0; + const rows = [ + ["Partner ID", ($("#inId") || {}).value || "—"], + ["Remote resolution", (S.dims ? S.dims.w + " × " + S.dims.h : "—")], + ["Encryption", "ECDH P-256 + AES-256-GCM (end-to-end)"], + ["Live stats", ($("#tbStats") || {}).textContent || "—"], + ["Session time", Math.floor(dur / 60) + "m " + (dur % 60) + "s"], + ["Lock on session end", (S.info && S.info.lock_on_end) ? "on" : "off"], + ["Clipboard allowed", (S.info && S.info.perms && S.info.perms.clipboard === false) ? "no" : "yes"], + ["File transfer allowed", (S.info && S.info.perms && S.info.perms.files === false) ? "no" : "yes"], + ]; + let box = $("#infoBox"); + if (!box) { + box = document.createElement("div"); + box.id = "infoBox"; box.className = "tmenu"; + box.style.cssText = "position:fixed;top:70px;left:50%;transform:translateX(-50%);min-width:340px;z-index:80"; + document.body.appendChild(box); + box.addEventListener("click", (e) => e.stopPropagation()); + } + box.innerHTML = '
Session information
' + + rows.map((r) => '
' + + r[0] + "" + String(r[1]).replace(/[<>&]/g, "") + "
").join("") + + '
'; + box.hidden = false; + $("#infoClose").onclick = () => { box.hidden = true; }; + }); + if (!S._t0) S._t0 = Date.now(); + + /* ---- View: scaling ---- */ + function applyScale(mode) { + S.scaleMode = mode; + try { localStorage.setItem("nd.scale", mode); } catch (e) {} + $("#stage").classList.toggle("orig", mode === "orig"); + syncMenuState(); + } + document.querySelectorAll("#mView .mradio").forEach((r) => + r.addEventListener("click", () => { applyScale(r.dataset.scale); closeMenus(); })); + try { applyScale(localStorage.getItem("nd.scale") || "fit"); } catch (e) { applyScale("fit"); } + + /* ---- View: remote screen resolution ---- */ + function loadModes() { send({ t: "sys.cmd", a: "modes" }); } + // delegate: the #miModesLoad button is recreated each session (resetSessionState) + const modesWrap = $("#miModes"); + if (modesWrap) modesWrap.addEventListener("click", (e) => { + if (e.target && e.target.id === "miModesLoad") loadModes(); + }); + function renderModes(m) { + window.__modesLoaded = true; + const wrap = $("#miModes"); + if (!wrap) return; + wrap.textContent = ""; + const cur = m.current || []; + (m.items || []).slice(0, 18).forEach(function (wh) { + // coerce to integers - never trust host-sent values into the DOM (no innerHTML) + const w = parseInt(wh[0], 10), h = parseInt(wh[1], 10); + if (!(w > 0 && h > 0)) return; + const b = document.createElement("button"); + b.className = "mi mradio" + (w === cur[0] && h === cur[1] ? " on" : ""); + b.textContent = w + " × " + h; + const tick = document.createElement("span"); + tick.className = "mtick"; tick.textContent = "✓"; + b.appendChild(tick); + b.onclick = function () { + if (!window.confirm("Change the remote screen to " + w + " × " + h + "?")) return; + send({ t: "sys.cmd", a: "setmode", w: w, h: h }); + closeMenus(); + }; + wrap.appendChild(b); + }); + if (!wrap.children.length) { + const d = document.createElement("div"); + d.className = "mi"; d.style.cssText = "cursor:default;color:#8b96a6"; + d.textContent = "No modes reported"; + wrap.appendChild(d); + } + } + + /* ---- View: wallpaper / refresh ---- */ + const miWp = $("#miWallpaper"); + if (miWp) miWp.addEventListener("click", () => send({ t: "sys.cmd", a: "wallpaper" })); + const miRf = $("#miRefresh"); + if (miRf) miRf.addEventListener("click", () => { send({ t: "keyframe" }); showBanner("Screen refreshed", true, 1400); closeMenus(); }); + + /* ---- Extras ---- */ + const click = (sel) => { const b = $(sel); if (b) b.click(); }; + const wire = (id, fn) => { const el = $(id); if (el) el.addEventListener("click", () => { fn(); closeMenus(); }); }; + wire("#miFiles", () => click("#btnFiles")); + wire("#miChat", () => click("#btnChat")); + wire("#miRec", () => click("#btnRec")); + wire("#miShot", () => { + const c = $("#canvas"); + if (!c || !c.width) return showBanner("No frame yet", false, 1600); + c.toBlob(function (blob) { + if (!blob) return; + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = "netdesk-" + (($("#inId") || {}).value || "shot") + "-" + + new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19) + ".png"; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 4000); + showBanner("Screenshot saved", true, 1600); + }, "image/png"); + }); + + /* ---- results from the host ---- */ + window.onSysRes = function (m) { + if (m.kind === "modes") { + if (m.ok) renderModes(m); else showBanner("Resolutions: " + (m.msg || "unavailable"), false, 2600); + return true; + } + if (m.kind === "wallpaper") { + if (miWp) miWp.classList.toggle("on", !!m.on); + showBanner(m.msg || "ok", m.ok, 1800); + return true; + } + if (m.kind === "setmode") { + showBanner(m.ok ? "Resolution changed" : "Failed: " + m.msg, m.ok, 2400); + if (m.ok) { window.__modesLoaded = false; send({ t: "keyframe" }); } + return true; + } + return false; // cad + everything else: generic banner + }; +})(); + +/* ============== mobile on-screen keyboard (⌨ Type button) ============== */ +(function () { + const osk = $("#oskInput"), btn = $("#btnOsk"); + if (!osk || !btn) return; + btn.addEventListener("click", () => { + osk.value = ""; + osk.style.pointerEvents = "auto"; + osk.focus({ preventScroll: true }); + }); + const SH = "!@#$%^&*()"; + const PUNCT = { "-": "Minus", "=": "Equal", "[": "BracketLeft", "]": "BracketRight", + "\\": "Backslash", ";": "Semicolon", "'": "Quote", ",": "Comma", ".": "Period", + "/": "Slash", "`": "Backquote" }; + const PUNCT_SH = { "_": "Minus", "+": "Equal", "{": "BracketLeft", "}": "BracketRight", + "|": "Backslash", ":": "Semicolon", "\"": "Quote", "<": "Comma", ">": "Period", + "?": "Slash", "~": "Backquote" }; + function keyOf(ch) { + if (/^[a-z]$/.test(ch)) return { c: "Key" + ch.toUpperCase(), sh: false }; + if (/^[A-Z]$/.test(ch)) return { c: "Key" + ch, sh: true }; + if (/^[0-9]$/.test(ch)) return { c: "Digit" + ch, sh: false }; + const i = SH.indexOf(ch); + if (i >= 0) return { c: "Digit" + ((i + 1) % 10), sh: true }; + if (ch === " ") return { c: "Space", sh: false }; + if (PUNCT[ch]) return { c: PUNCT[ch], sh: false }; + if (PUNCT_SH[ch]) return { c: PUNCT_SH[ch], sh: true }; + return null; + } + function tapKey(code, sh) { + if (sh) send({ t: "i.key", c: "ShiftLeft", d: true }); + send({ t: "i.key", c: code, d: true }); + send({ t: "i.key", c: code, d: false }); + if (sh) send({ t: "i.key", c: "ShiftLeft", d: false }); + } + osk.addEventListener("beforeinput", (ev) => { + ev.preventDefault(); + if (ev.inputType === "insertText" && ev.data) { + for (const ch of ev.data) { const k = keyOf(ch); if (k) tapKey(k.c, k.sh); } + } else if (ev.inputType === "deleteContentBackward") tapKey("Backspace", false); + else if (ev.inputType === "insertLineBreak") tapKey("Enter", false); + }); + osk.addEventListener("keydown", (ev) => { + const pass = ["Backspace", "Enter", "Tab", "Escape", "ArrowLeft", "ArrowRight", + "ArrowUp", "ArrowDown", "Delete", "Home", "End"]; + if (pass.indexOf(ev.key) >= 0) { + ev.preventDefault(); + tapKey(ev.code && ev.code !== "Unidentified" ? ev.code : ev.key, false); + } + }); + osk.addEventListener("blur", () => { osk.style.pointerEvents = "none"; }); +})(); + +/* ===== reference-exact bar: monitors & quality live in the View menu ===== */ +(function () { + const QLABELS = { auto: "Auto (adaptive)", low: "Low", balanced: "Balanced", + high: "High", extreme: "Extreme" }; + function radio(label, on, fn) { + const b = document.createElement("button"); + b.className = "mi mradio" + (on ? " on" : ""); + b.textContent = label; + const t = document.createElement("span"); t.className = "mtick"; t.textContent = "✓"; + b.appendChild(t); b.onclick = fn; + return b; + } + function refreshViewMenu() { + const mons = $("#miMons"), qual = $("#miQual"); + if (mons) { + mons.textContent = ""; + const sel = $("#selMonitor"); + [...sel.options].forEach((o) => { + mons.appendChild(radio(o.textContent || ("Display " + o.value), + sel.value === o.value, () => { + sel.value = o.value; + // dispatch alone fires BOTH the property handler and listeners + sel.dispatchEvent(new Event("change")); + refreshViewMenu(); + })); + }); + if (!sel.options.length) { + const d = document.createElement("div"); + d.className = "mi"; d.style.cssText = "cursor:default;color:#7c8898"; + d.textContent = "Connect to list displays"; + mons.appendChild(d); + } + } + if (qual) { + qual.textContent = ""; + const q = $("#selQuality"); + Object.keys(QLABELS).forEach((v) => { + qual.appendChild(radio(QLABELS[v], q.value === v, () => { + q.value = v; + q.dispatchEvent(new Event("change")); + refreshViewMenu(); + })); + }); + } + } + const vbtn = document.querySelector('.menu-btn[data-menu="mView"]'); + if (vbtn) vbtn.addEventListener("click", refreshViewMenu); + refreshViewMenu(); + // build badge: instantly tells us whether a tab runs the current UI + window.ND_BUILD = "v52"; + const foot = document.querySelector("#screenConnect .foot"); + if (foot) foot.textContent = "NetDesk viewer · build " + window.ND_BUILD; + const tb = $("#toolbar"); if (tb) tb.title = "NetDesk " + window.ND_BUILD; +})(); + +/* block remote user's input (RustDesk parity) */ +(function () { + const mi = $("#miBlockInput"); + if (mi) mi.addEventListener("click", () => send({ t: "sys.cmd", a: "block_input" })); + const prev = window.onSysRes; + window.onSysRes = function (m) { + if (m.kind === "block_input") { + if (mi) mi.classList.toggle("on", !!m.on); + showBanner((m.ok ? "" : "⚠ ") + (m.msg || ""), m.ok, 3000); + return true; + } + return prev ? prev(m) : false; + }; +})(); + +/* ===== monitor quick-switch button + Communicate menu (v44) ===== */ +(function () { + // --- monitor cycle: appears only when the host has 2+ displays --- + const btn = $("#btnMonCycle"), lbl = $("#monCycleLbl"), sel = $("#selMonitor"); + function syncMon() { + if (!btn || !sel) return; + const n = sel.options.length; + btn.hidden = n < 2; + if (n >= 2) { + const idx = Math.max(0, [...sel.options].findIndex(o => o.value === sel.value)); + lbl.textContent = (idx + 1) + "/" + n; + btn.title = "Switch monitor — now on " + + (sel.options[idx] ? sel.options[idx].textContent : ""); + } + } + if (btn) btn.addEventListener("click", () => { + const n = sel.options.length; + if (n < 2) return; + const idx = [...sel.options].findIndex(o => o.value === sel.value); + sel.value = sel.options[(idx + 1) % n].value; + sel.dispatchEvent(new Event("change")); // fires property handler + listeners once + syncMon(); + showBanner("🖥 " + sel.options[(idx + 1) % n].textContent, true, 1500); + }); + if (sel) sel.addEventListener("change", syncMon); + setInterval(syncMon, 1200); // options arrive with session.info + syncMon(); + + // --- Communicate menu --- + const miMsg = $("#miMsg"); + if (miMsg) miMsg.addEventListener("click", () => { + const b = $("#btnChat"); if (b) b.click(); + const inp = $("#chatText"); if (inp) setTimeout(() => inp.focus(), 120); + }); + const miSound = $("#miSound"); + function syncSound() { + // "on" means the host is actually sending (source != 0) AND we can play it. + if (miSound) miSound.classList.toggle("on", + !!(S.audioSrc && S.audioSrc !== 0 && S.audioCtx && S.audioCtx.state === "running")); + } + if (miSound) miSound.addEventListener("click", async () => { + // This toggles REMOTE (host) sound on/off. It must command the host + // (audio.set) — not merely suspend the local context — or the host never + // captures and "on" is silent. Mirrors the 🔊 toolbar button. + if (!S.audioDecoder) setupAudio(); + const turningOn = !(S.audioSrc && S.audioSrc !== 0); + try { + if (turningOn) { + S.audioSrc = 1; + if (S.audioCtx && S.audioCtx.state !== "running") await S.audioCtx.resume(); + send({ t: "audio.set", src: 1 }); + showBanner("Remote sound ON (system)", true, 1600); + } else { + S.audioSrc = 0; + send({ t: "audio.set", src: 0 }); + if (S.audioCtx && S.audioCtx.state === "running") await S.audioCtx.suspend(); + showBanner("Remote sound muted", true, 1600); + } + } catch (e) {} + const lbl = $("#audioLbl"); if (lbl) lbl.textContent = S.audioSrc === 0 ? "Audio" : "🔊 System"; + const b = $("#btnAudio"); if (b) b.classList.toggle("active", S.audioSrc !== 0); + syncSound(); + }); + const miVoice = $("#miVoice"); + if (miVoice) miVoice.addEventListener("click", () => { if (S.voiceOn) stopVoice(); else startVoice(); }); + updateVoiceUi(); + const cbtn = document.querySelector('.menu-btn[data-menu="mComm"]'); + if (cbtn) cbtn.addEventListener("click", syncSound); +})(); + +/* ============ WebRTC P2P fast path (video + input direct over UDP) ============ + Negotiated over the relay after the session is live. Same E2E-encrypted + bytes - only the pipe changes. The relay stays connected as fallback and + for files/clipboard/chat, so a failed P2P never costs the session. */ +(function () { + function rtcTeardown() { + const r = S._rtc; + S._rtc = null; S._rtcTried = false; + if (r && r.pc) { try { r.pc.close(); } catch (e) {} } + } + const _reset = resetSessionState; + resetSessionState = function () { rtcTeardown(); _reset(); }; + + // ask the server for ICE servers (STUN + the nearest TURN relay) + function fetchIce() { + return new Promise((res) => { + const fallback = [{ urls: ["stun:stun.cloudflare.com:3478", "stun:stun.l.google.com:19302"] }]; + const t = setTimeout(() => { S._iceWait = null; res(fallback); }, 2500); + S._iceWait = (servers) => { clearTimeout(t); S._iceWait = null; res(servers && servers.length ? servers : fallback); }; + send({ t: "rtc.config.req" }); + }); + } + window.__onRtcConfig = (m) => { if (S._iceWait) S._iceWait(m.iceServers); }; + + async function rtcConnect() { + if (S._rtcTried || S.dead || S.direct || !window.RTCPeerConnection) return; + S._rtcTried = true; + try { + const iceServers = await fetchIce(); + if (S.dead) return; + const pc = new RTCPeerConnection({ iceServers }); + // fresh frames beat late ones: unreliable-ish, 150ms max lifetime + const media = pc.createDataChannel("media", { ordered: false, maxPacketLifeTime: 150 }); + const ctl = pc.createDataChannel("ctl", { ordered: true }); + // pure mouse motion: unordered + no retransmits, so one lost packet on a + // lossy link never holds the pointer back (clicks/keys stay on "ctl") + const move = pc.createDataChannel("move", { ordered: false, maxRetransmits: 0 }); + media.binaryType = "arraybuffer"; + S._rtc = { pc, media, ctl, move, up: false, moveOk: false }; + // only engines that answer "move.ok" read the motion channel (older + // ones ignore its label, so motion must stay on "ctl" for them) + move.onmessage = (ev) => { try { if (JSON.parse(ev.data).t === "move.ok") S._rtc.moveOk = true; } catch (e) {} }; + media.onmessage = (ev) => { + const bytes = new Uint8Array(ev.data); + decodeQueue = decodeQueue.then(() => handleBinary(bytes)) + .catch((e) => console.error("p2p binary", e)); + }; + const maybeUp = () => { + const r = S._rtc; + if (r && r.media.readyState === "open" && r.ctl.readyState === "open" && !r.up) { + r.up = true; + updateStats(); + showBanner("⚡ Direct P2P connection active — lowest latency path", true, 3000); + } + }; + const down = () => { + const r = S._rtc; + if (r && r.up) { r.up = false; updateStats(); showBanner("P2P dropped — continuing over the relay", true, 2200); } + }; + media.onopen = maybeUp; ctl.onopen = maybeUp; + media.onclose = down; ctl.onclose = down; + pc.onconnectionstatechange = () => { + if (["failed", "disconnected", "closed"].indexOf(pc.connectionState) >= 0) down(); + }; + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + await new Promise((res) => { // non-trickle ICE, capped at 2.5s + if (pc.iceGatheringState === "complete") return res(); + const t = setTimeout(res, 2500); + pc.addEventListener("icegatheringstatechange", () => { + if (pc.iceGatheringState === "complete") { clearTimeout(t); res(); } + }); + }); + send({ t: "rtc.offer", sdp: pc.localDescription.sdp, ice: iceServers }); + } catch (e) { /* relay path continues untouched */ } + } + // which tier won? (direct P2P vs TURN relay) - read the selected pair + async function rtcRoute() { + const r = S._rtc; + if (!r || !r.up || !r.pc.getStats) return; + try { + const st = await r.pc.getStats(); + let pairId = null; + st.forEach((v) => { if (v.type === "transport" && v.selectedCandidatePairId) pairId = v.selectedCandidatePairId; }); + st.forEach((v) => { + if (!pairId && v.type === "candidate-pair" && v.nominated && v.state === "succeeded") pairId = v.id; + }); + const pair = pairId && st.get(pairId); + if (!pair) return; + const lc = st.get(pair.localCandidateId), rc = st.get(pair.remoteCandidateId); + r.route = ((lc && lc.candidateType === "relay") || (rc && rc.candidateType === "relay")) ? "TURN" : "P2P"; + if (pair.currentRoundTripTime) r.rttMs = Math.round(pair.currentRoundTripTime * 1000); + } catch (e) {} + } + setInterval(rtcRoute, 2000); + setInterval(() => { if (S.started && !S._rtcTried && !S.dead) rtcConnect(); }, 600); + + // latency-critical traffic rides the direct channel when it's up + const _send = send; + send = function (obj) { + const r = S._rtc; + const motion = obj._mv; delete obj._mv; + if (r && r.up && r.ctl.readyState === "open" && + (obj.t === "i.mouse" || obj.t === "i.key" || obj.t === "keyframe")) { + obj.sid = obj.sid || S.sid; + const ch = (motion && r.moveOk && r.move && r.move.readyState === "open") ? r.move : r.ctl; + try { ch.send(JSON.stringify(obj)); return true; } catch (e) {} + } + return _send(obj); + }; + + // link badge in the stats line + const _stats = updateStats; + updateStats = function () { + _stats(); + const el = $("#tbStats"); + if (el && el.textContent && S.started) { + const r = S._rtc; + el.textContent += (r && r.up) + ? (" · ⚡" + (r.route || "P2P") + (r.rttMs ? " " + r.rttMs + "ms" : "")) + : " · relay"; + } + }; +})(); + +/* ===== pinch view-zoom (mobile): transform on the canvas - every coordinate + path keeps working because it reads getBoundingClientRect, which already + reflects the transform. When zoomed, the viewport follows the cursor. ===== */ +const Z = { s: 1, ox: 0, oy: 0 }; +function applyView() { + const st = $("#stage"); + const vw = st.clientWidth, vh = st.clientHeight; + Z.s = Math.max(1, Math.min(4, Z.s)); + Z.ox = Math.min(0, Math.max(vw - vw * Z.s, Z.ox)); + Z.oy = Math.min(0, Math.max(vh - vh * Z.s, Z.oy)); + canvas.style.transformOrigin = "0 0"; + canvas.style.transform = (Z.s === 1 && !Z.ox && !Z.oy) ? "" : + "translate(" + Z.ox + "px," + Z.oy + "px) scale(" + Z.s + ")"; + try { positionCursor(); } catch (e) {} +} +function zoomBy(f, cx, cy) { + const sr = $("#stage").getBoundingClientRect(); + const px = cx - sr.left, py = cy - sr.top; + const ns = Math.max(1, Math.min(4, Z.s * f)); + const r = ns / Z.s; + Z.ox = px - (px - Z.ox) * r; + Z.oy = py - (py - Z.oy) * r; + Z.s = ns; + if (Z.s < 1.04) { Z.s = 1; Z.ox = 0; Z.oy = 0; } // snap back to full view + applyView(); +} +function zoomReset() { Z.s = 1; Z.ox = 0; Z.oy = 0; applyView(); } +function followCursor() { + if (Z.s <= 1) return; + const st = $("#stage"); + const vw = st.clientWidth, vh = st.clientHeight; + const r = canvas.getBoundingClientRect(), sr = st.getBoundingClientRect(); + const iw = S.dims.w || 1, ih = S.dims.h || 1; + const k = Math.min(r.width / iw, r.height / ih) || 1; + const cxv = (r.left - sr.left) + (r.width - iw * k) / 2 + T.vx * k; + const cyv = (r.top - sr.top) + (r.height - ih * k) / 2 + T.vy * k; + const mL = vw * 0.25, mR = vw * 0.75, mT = vh * 0.25, mB = vh * 0.75; + if (cxv < mL) Z.ox += mL - cxv; else if (cxv > mR) Z.ox -= cxv - mR; + if (cyv < mT) Z.oy += mT - cyv; else if (cyv > mB) Z.oy -= cyv - mB; + applyView(); +} + +/* SSO (SAML): reveal the "Sign in with SSO" button when the server has it enabled */ +(function () { + try { + fetch("/api/auth/sso", { cache: "no-store" }).then(function (r) { return r.json(); }).then(function (d) { + if (d && d.saml) { + var w = document.getElementById("ssoWrap"); if (w) w.hidden = false; + var b = document.getElementById("ssoBtn"); if (b) b.href = (d.login_url || "/saml/login") + "?RelayState=/app"; + // enforce: lead with SSO by de-emphasizing the password form + if (d.enforce) { + var sub = document.querySelector("#pane-in .auth-sub"); + if (sub) sub.textContent = "Your organization uses single sign-on. Continue with SSO."; + } + } + }).catch(function () {}); + } catch (e) {} +})(); diff --git a/server/web/contact.html b/server/web/contact.html new file mode 100644 index 0000000..92a42a4 --- /dev/null +++ b/server/web/contact.html @@ -0,0 +1,50 @@ + + + + + +Contact — NetDesk + + + + + + + + + + + + + + +
+
Contact

Get in touch

Sales, support, security or partnerships — we usually reply within one business day.

+
+
+
+
+
+
+ +

+
+
+ + + + +

Prefer to self-host and never talk to us? That's the whole point — just download it.

+
+
+
+ + + + + \ No newline at end of file diff --git a/server/web/download.html b/server/web/download.html new file mode 100644 index 0000000..c987e20 --- /dev/null +++ b/server/web/download.html @@ -0,0 +1,202 @@ + + + + + +Download — NetDesk + + + + + + + + + + + + + + + +
+
Download

Get NetDesk

Portable, no installation required. Double-click and go.

+
+
+
+

Windows 10 / 11 (x64)

+

The native NetDesk app — host panel and remote viewer in one window. No browser.

+ ⬇ Download Installer +

or the portable .zip — no install needed

+
+
+
+

Android 7+ (phone, tablet, BlueStacks)

+

Control your computers from your Android phone or tablet. Installs on devices and in BlueStacks.

+ ⬇ Download APK +

≈63 MB · all CPU types · Google Play soon

+
+
+
+

macOS (Apple Silicon M1–M5 & Intel)

+

Native build for Apple Silicon and Intel Macs. Control your Mac or connect from it.

+ Join the macOS beta +

Coming soon · notify me

+
+
+
+

No download needed on the viewer side — open the web client in any browser.

+

Self-hosting your own server? See the Self-Hosting Console and the deployment guide.

+
+
+ + +
+
One unified app

Host & remote control — together

This is NetDesk after you install it. The home screen is your host panel; type a Partner ID and the same window becomes the remote viewer. No separate apps, no browser required.

+ +
+ + + + \ No newline at end of file diff --git a/server/web/features.html b/server/web/features.html new file mode 100644 index 0000000..8737e9f --- /dev/null +++ b/server/web/features.html @@ -0,0 +1,41 @@ + + + + + +Features — NetDesk + + + + + + + + + + + + + + +
+
Features

Everything TeamViewer Pro does — self-hosted

+

A complete, production-grade remote desktop, running on infrastructure you own.

+
+

End-to-end encryption

AES-256-GCM with ephemeral ECDH P-256 keys, per frame. The relay only ever sees opaque bytes — even your own server can never watch the screen.

+

H.264 up to 1080p60

x264 ultrafast / zero-latency encoding, WebCodecs hardware decode in the browser, adaptive quality that steps with your network.

+

Unattended service access

Boot-time Windows service (SYSTEM): reachable at the logon screen and across UAC prompts, surviving reboots, no auto-logon needed.

+

Multi-monitor & real cursor

Switch displays live, see the actual remote cursor shape, keyboard-lock for system shortcuts, and full mouse + wheel + side buttons.

+

File transfer & folders

Browse, download, upload, rename, delete, make folders, drag-and-drop whole directories — atomic and end-to-end encrypted.

+

Clipboard, chat & recording

Two-way clipboard sync, in-session chat, and one-click session recording to a local .webm file.

+

Remote power & processes

Lock, sign out, reboot, shut down, run commands, and a full process manager to inspect and end tasks.

+

Zero-install web client

Connect from any browser on laptop or phone — nothing to install on the viewer side. Installable as a PWA.

+

2FA, accounts & devices

Optional TOTP two-factor, email accounts, and a "My Computers" device list that follows you across the web.

+
+ +
+ + + + + \ No newline at end of file diff --git a/server/web/host.js b/server/web/host.js new file mode 100644 index 0000000..fa20a47 --- /dev/null +++ b/server/web/host.js @@ -0,0 +1,307 @@ +/* NetDesk BROWSER HOST — share this screen (view-only) with an ID + password, + * no install. Speaks the exact NetDesk host protocol so any existing viewer + * (web or native app) can connect: reg.host -> session.challenge -> verify proof + * -> session.accept -> stream E2E-sealed H.264 (getDisplayMedia + WebCodecs). + * + * Hard limit (browser sandbox): VIEW-ONLY — a web page cannot inject OS mouse/ + * keyboard, so viewers see+hear this screen but cannot control it. Everything + * else (ID+password, end-to-end encryption, audio) works like the installed host. + * + * Reuses globals from app.js: enc, toHex, b64ToBytes, sha256Hex. + */ +(function () { + "use strict"; + const H = { + ws: null, id: null, pw: null, salt: null, iters: 200000, verifier: null, + sid: null, nonce: null, ecdh: null, e2eKey: null, authed: false, + stream: null, venc: null, aenc: null, vreader: null, areader: null, + forceKey: false, running: false, dims: { w: 0, h: 0 }, fcount: 0, + }; + const $ = (s) => document.querySelector(s); + + function supported() { + return !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia && + window.VideoEncoder && window.MediaStreamTrackProcessor && window.WebSocket); + } + function rand9() { + let s = String(1 + Math.floor(Math.random() * 9)); + for (let i = 0; i < 8; i++) s += Math.floor(Math.random() * 10); + return s; + } + function genPw() { + const a = "abcdefghjkmnpqrstuvwxyz23456789"; + let s = ""; + const r = crypto.getRandomValues(new Uint8Array(10)); + for (let i = 0; i < 10; i++) s += a[r[i] % a.length]; + return s; + } + function b64(u8) { let s = ""; for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return btoa(s); } + function status(msg) { const e = $("#hostStatus"); if (e) e.textContent = msg; } + + async function deriveCreds(password) { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const k = await crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveBits"]); + const dk = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt, iterations: H.iters }, k, 256); + H.salt = salt; H.verifier = toHex(dk); + } + + async function start() { + if (!supported()) { status("This browser can't host — use Chrome/Edge (needs WebCodecs + screen capture)."); return; } + try { + // getDisplayMedia must be in the click gesture — prompt for the screen first. + H.stream = await navigator.mediaDevices.getDisplayMedia({ + video: { frameRate: 30 }, audio: true, + }); + } catch (e) { status("Screen share cancelled."); return; } + H.id = $("#hostIdOut").textContent.trim() || rand9(); + H.pw = $("#hostPwOut").textContent.trim() || genPw(); + await deriveCreds(H.pw); + // the user clicked "Stop sharing" in the browser's share bar + H.stream.getVideoTracks()[0].addEventListener("ended", stop); + + const wsUrl = location.origin.replace(/^http/, "ws") + "/ws"; + H.ws = new WebSocket(wsUrl); + H.ws.binaryType = "arraybuffer"; + H.running = true; + H.ws.onopen = () => H.ws.send(JSON.stringify({ t: "reg.host", id: H.id, name: "Browser host", takeover: true })); + H.ws.onmessage = onMessage; + H.ws.onclose = () => { if (H.running) status("Disconnected from server."); }; + H.ws.onerror = () => status("Server connection error."); + $("#btnHostStart").hidden = true; + $("#btnHostStop").hidden = false; + status("Registering… share the ID + password below."); + } + + async function onMessage(ev) { + if (typeof ev.data !== "string") return onVoiceFrame(ev.data); // viewer mic (CHAN_VOICE) + let m; try { m = JSON.parse(ev.data); } catch { return; } + switch (m.t) { + case "host_status": + status(m.ok ? "✅ Live — anyone with the ID + password can view this screen." : ("Register failed: " + (m.msg || ""))); + break; + case "session.begin": return challenge(m); + case "session.auth": return auth(m); + case "keyframe": H.forceKey = true; break; + case "session.end": case "session.deny": case "viewer.left": + status("✅ Live — viewer disconnected; ready for the next."); + break; + // input / sys.cmd / clip.set / monitor.switch / file.* -> view-only: ignored + } + } + + async function challenge(m) { + H.sid = m.sid || toHex(crypto.getRandomValues(new Uint8Array(16))); + H.nonce = toHex(crypto.getRandomValues(new Uint8Array(16))); + H.ecdh = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]); + const pub = b64(new Uint8Array(await crypto.subtle.exportKey("raw", H.ecdh.publicKey))); + H.ws.send(JSON.stringify({ + t: "session.challenge", sid: H.sid, salt: b64(H.salt), iters: H.iters, + nonce: H.nonce, e2e: true, totp: false, pub, + })); + status("A viewer is connecting…"); + } + + async function auth(m) { + const expect = await sha256Hex(H.nonce + H.verifier); + if (m.proof !== expect) { + H.ws.send(JSON.stringify({ t: "session.deny", sid: H.sid, reason: "wrong password" })); + status("✅ Live — a viewer entered the wrong password (denied)."); + return; + } + // E2E: ECDH(host_priv, viewer_pub) -> SHA256(shared || "e2e") -> AES-GCM key + const viewerPub = await crypto.subtle.importKey("raw", b64ToBytes(m.pub), { name: "ECDH", namedCurve: "P-256" }, false, []); + const shared = new Uint8Array(await crypto.subtle.deriveBits({ name: "ECDH", public: viewerPub }, H.ecdh.privateKey, 256)); + const ctx = new Uint8Array(shared.length + 3); + ctx.set(shared, 0); ctx.set(enc.encode("e2e"), shared.length); + const keyBytes = new Uint8Array(await crypto.subtle.digest("SHA-256", ctx)); + H.e2eKey = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt", "decrypt"]); + + const vs = H.stream.getVideoTracks()[0].getSettings(); + H.dims = { w: (vs.width | 0) || 1280, h: (vs.height | 0) || 720 }; + const audio = H.stream.getAudioTracks().length > 0; + H.ws.send(JSON.stringify({ + t: "session.accept", sid: H.sid, + info: { w: H.dims.w, h: H.dims.h, name: "Browser host", e2e: true, audio, + monitors: [{ i: 1, w: H.dims.w, h: H.dims.h }], mon: 1 }, + })); + H.authed = true; + H.forceKey = true; + status("✅ Live — a viewer is now watching this screen."); + await startEncoders(); + } + + // ---- Voice call RECEIVE: the viewer's microphone arrives sealed as + // [CHAN_VOICE=0xa2][opus]. Decrypt, Opus-decode (WebCodecs) and play it on + // this browser-host's speakers, with the same adaptive jitter buffer and the + // REQUIRED planeIndex:0 copyTo the main viewer uses. + let vdec = null, vctx = null, vNext = 0, vLead = 0.12; + async function onVoiceFrame(buf) { + if (!H.e2eKey) return; + try { + const data = new Uint8Array(buf); + const pt = new Uint8Array(await crypto.subtle.decrypt( + { name: "AES-GCM", iv: data.subarray(0, 12) }, H.e2eKey, data.subarray(12))); + if (pt.length < 2 || pt[0] !== 0xa2) return; // CHAN_VOICE only + if (!vdec) setupVoiceDec(); + if (vdec && vdec.state !== "closed") { + vdec.decode(new EncodedAudioChunk({ + type: "key", timestamp: Math.round(performance.now() * 1000), data: pt.subarray(1), + })); + } + } catch (e) {} + } + function setupVoiceDec() { + if (!window.AudioDecoder) return; + try { + if (!vctx) vctx = new (window.AudioContext || window.webkitAudioContext)(); + vNext = 0; vLead = 0.12; + vdec = new AudioDecoder({ output: playVoice, error: () => { try { vdec = null; } catch (e) {} } }); + vdec.configure({ codec: "opus", sampleRate: 48000, numberOfChannels: 2 }); + } catch (e) { vdec = null; } + } + function playVoice(ad) { + const ctx = vctx; if (!ctx) { if (ad.close) ad.close(); return; } + if (ctx.state === "suspended") ctx.resume(); + const n = ad.numberOfChannels, len = ad.numberOfFrames, rate = ad.sampleRate; + const b = ctx.createBuffer(n, len, rate); + const planar = (ad.format || "").includes("planar"); + for (let ch = 0; ch < n; ch++) { + const dst = b.getChannelData(ch); + if (planar) ad.copyTo(dst, { planeIndex: ch, format: "f32-planar" }); + else { const tmp = new Float32Array(len * n); ad.copyTo(tmp, { planeIndex: 0, format: "f32" }); for (let i = 0; i < len; i++) dst[i] = tmp[i * n + ch]; } + } + if (ad.close) ad.close(); + const now = ctx.currentTime; + if (vNext === 0) vNext = now + vLead; + else if (vNext < now) { vLead = Math.min(vLead + 0.04, 0.45); vNext = now + vLead; } + else if (vNext > now + vLead + 0.35) vNext = now + vLead; + const src = ctx.createBufferSource(); src.buffer = b; src.connect(ctx.destination); src.start(vNext); vNext += len / rate; + } + + async function sealSend(frameBytes) { + if (!H.e2eKey || !H.ws || H.ws.readyState !== 1) return; + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, H.e2eKey, frameBytes)); + const out = new Uint8Array(12 + ct.length); + out.set(nonce, 0); out.set(ct, 12); + H.ws.send(out.buffer); + } + + async function startEncoders() { + if (H.venc) return; // one viewer at a time (one encoder) + const vt = H.stream.getVideoTracks()[0]; + H.venc = new VideoEncoder({ + output: (chunk) => { + const data = new Uint8Array(chunk.byteLength); + chunk.copyTo(data); + const frame = new Uint8Array(2 + data.length); + frame[0] = 0x01; // CHAN_VIDEO + frame[1] = chunk.type === "key" ? 1 : 0; // bit0 keyframe, codec 0 = H.264 + frame.set(data, 2); + sealSend(frame); + }, + error: (e) => console.error("video encoder", e), + }); + // Pick a supported H.264 config (profile/level varies by browser); keep + // annexb so the output has in-band SPS/PPS + start codes the viewer parses. + const cfgBase = { width: H.dims.w, height: H.dims.h, bitrate: 4_000_000, framerate: 30, avc: { format: "annexb" } }; + let chosen = null; + for (const codec of ["avc1.42E01F", "avc1.42001F", "avc1.4D401F", "avc1.640028", "avc1.42E01E"]) { + try { + const sup = await VideoEncoder.isConfigSupported(Object.assign({ codec }, cfgBase)); + if (sup && sup.supported) { chosen = sup.config || Object.assign({ codec }, cfgBase); break; } + } catch (e) { /* try next */ } + } + if (!chosen) { + status("This browser can't H.264-encode the screen — try Chrome/Edge."); + try { H.venc.close(); } catch { } H.venc = null; + return; + } + H.venc.configure(chosen); + const vproc = new MediaStreamTrackProcessor({ track: vt }); + H.vreader = vproc.readable.getReader(); + (async function pump() { + while (H.running && H.venc) { + let r; try { r = await H.vreader.read(); } catch { break; } + if (r.done) break; + const frame = r.value; + try { + if (H.venc && H.venc.state === "configured" && H.venc.encodeQueueSize < 3) { + const key = H.forceKey || (H.fcount % 120 === 0); + H.forceKey = false; + H.venc.encode(frame, { keyFrame: key }); + H.fcount++; + } + } catch (e) { /* encoder closed */ } + frame.close(); + } + })(); + + // audio (system/tab sound captured by getDisplayMedia), best-effort + const at = H.stream.getAudioTracks()[0]; + if (at && window.AudioEncoder) { + try { + H.aenc = new AudioEncoder({ + output: (chunk) => { + const data = new Uint8Array(chunk.byteLength); + chunk.copyTo(data); + const frame = new Uint8Array(1 + data.length); + frame[0] = 0xa1; // CHAN_AUDIO + frame.set(data, 1); + sealSend(frame); + }, + error: (e) => console.error("audio encoder", e), + }); + H.aenc.configure({ codec: "opus", sampleRate: 48000, numberOfChannels: 2, bitrate: 96000 }); + const aproc = new MediaStreamTrackProcessor({ track: at }); + H.areader = aproc.readable.getReader(); + (async function apump() { + while (H.running && H.aenc) { + let r; try { r = await H.areader.read(); } catch { break; } + if (r.done) break; + try { H.aenc.encode(r.value); } catch (e) { } + r.value.close(); + } + })(); + } catch (e) { console.warn("audio unavailable", e); } + } + } + + function stop() { + H.running = false; + try { H.venc && H.venc.close(); } catch { } H.venc = null; + try { H.aenc && H.aenc.close(); } catch { } H.aenc = null; + try { H.vreader && H.vreader.cancel(); } catch { } + try { H.areader && H.areader.cancel(); } catch { } + try { H.stream && H.stream.getTracks().forEach((t) => t.stop()); } catch { } + try { H.ws && H.ws.close(); } catch { } + H.stream = null; H.ws = null; H.e2eKey = null; H.authed = false; + $("#btnHostStart").hidden = false; + $("#btnHostStop").hidden = true; + status("Stopped sharing."); + } + + // ---- wire the UI ---- + function init() { + const sec = $("#hostShare"); + if (!sec) return; + if (!supported()) { + const b = $("#btnHostStart"); + if (b) { b.disabled = true; b.textContent = "Not supported in this browser"; } + status("Browser hosting needs Chrome or Edge (WebCodecs + screen capture)."); + } + // pre-generate a fresh ID + password to display + $("#hostIdOut").textContent = rand9(); + $("#hostPwOut").textContent = genPw(); + $("#btnHostStart").onclick = start; + $("#btnHostStop").onclick = stop; + const regen = $("#btnHostRegen"); + if (regen) regen.onclick = () => { + if (H.running) return; + $("#hostIdOut").textContent = rand9(); + $("#hostPwOut").textContent = genPw(); + }; + } + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init); + else init(); +})(); diff --git a/server/web/index.html b/server/web/index.html new file mode 100644 index 0000000..3ef65af --- /dev/null +++ b/server/web/index.html @@ -0,0 +1,329 @@ + + + + + +NetDesk — The Fast, Secure, Self-Hosted Remote Desktop + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ ● Open source · Self-hosted · End-to-end encrypted +

Remote access,
finally on your terms.

+

NetDesk is the fast, secure remote desktop you fully own. Your server, your keys, + your data — never a third-party cloud. The alternative to TeamViewer, AnyDesk and RustDesk, + free for individuals.

+ +
🔒 AES-256 end-to-end⚡ H.264 60fps🖥️ Multi-monitor💸 No subscription to self-host
+ + + +
+ + +
+
AES-256End-to-end on every path
+
<4 msDirect P2P on the LAN
+
1080p60Hardware-encoded, adaptive
+
$0Self-host · no per-seat fees
+
+ + +
+

The private alternative to

+
TeamViewerAnyDeskRustDeskChrome Remote DesktopSplashtop
+
+ + +
+
Everything you need
+

A complete remote desktop, self-hosted

+

All the power of TeamViewer Pro and RustDesk — running on infrastructure you own.

+
+

End-to-end encrypted

AES-256-GCM with ephemeral ECDH keys. Even your own server can't see the screen — only the two endpoints hold the keys.

+

Blazing fast

Hardware H.264 up to 1080p60, WebCodecs decode in the browser, adaptive quality. Buttery-smooth even over the internet.

+

Unattended access

Install as a boot-time service. Reach the logon screen and UAC prompts, across reboots — no one has to be at the PC.

+

File transfer & sync

Browse, drag-and-drop, upload whole folders, two-way clipboard sync, and remote printing — all end-to-end encrypted.

+

Multi-monitor & cursor

Switch displays live, see the real remote cursor, record the session, chat, and manage processes and power remotely.

+

Zero-install web client

Connect from any browser on any laptop or phone — nothing to install on the viewer side. Install the PWA for one-tap access.

+
+
+ + +
+
+
<10ms
control latency on LAN
+
60fps
1080p H.264 streaming
+
AES-256
end-to-end encryption
+
$0
to self-host, forever
+
+
+ + +
+
Why NetDesk

Own it, don't rent it

+

TeamViewer and AnyDesk route your sessions through their cloud and charge per seat. NetDesk runs on your server.

+
+ + + + + + + + +
CapabilityNetDeskTeamViewerRustDesk (SaaS)
Self-hosted, your server✓ Included✗Paid tier
End-to-end encryption✓ Default✓✓
Free for individuals✓Trial only✓
No per-seat licensing✓✗Self-host
Web client (no install)✓✓Limited
Open source✓✗✓
+
+ +
+ + +
+
Open source
+

Read every line. Then run it yourself.

+

The whole system — relay server, Windows host, zero-install web viewer, and the native Rust client — is published right here on netdesk.space. Browse it, download it, or clone it. No GitHub account, no third-party, no lock-in.

+ +
+
+ git clone https://netdesk.space/git/akronlogic/netdesk.git + +
+

Source-available — free to self-host & modify. Browse it like GitHub, straight from our own server.

+
+
+ + +
+

Take control of your remote access today.

+

Free for individuals. Self-host in minutes. No credit card, no cloud lock-in.

+ +
+ + + + + + diff --git a/server/web/manifest.webmanifest b/server/web/manifest.webmanifest new file mode 100644 index 0000000..b046b07 --- /dev/null +++ b/server/web/manifest.webmanifest @@ -0,0 +1,9 @@ +{ + "name": "NetDesk", + "short_name": "NetDesk", + "start_url": "/", + "display": "standalone", + "background_color": "#0b0e14", + "theme_color": "#3d7eff", + "icons": [{"src": "/ndicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any"}] +} \ No newline at end of file diff --git a/server/web/ndicon.svg b/server/web/ndicon.svg new file mode 100644 index 0000000..9c571e1 --- /dev/null +++ b/server/web/ndicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/server/web/portal.html b/server/web/portal.html new file mode 100644 index 0000000..7b45e82 --- /dev/null +++ b/server/web/portal.html @@ -0,0 +1,290 @@ + + + + + +NetDesk Console + + + + + + + + + + + + + + + diff --git a/server/web/portal.js b/server/web/portal.js new file mode 100644 index 0000000..8fef8ed --- /dev/null +++ b/server/web/portal.js @@ -0,0 +1,349 @@ +/* NetDesk management portal. */ +"use strict"; + +const $ = (s) => document.querySelector(s); + +function fmtBytes(n) { + if (n > 1e9) return (n / 1e9).toFixed(2) + " GB"; + if (n > 1e6) return (n / 1e6).toFixed(1) + " MB"; + if (n > 1e3) return (n / 1e3).toFixed(1) + " KB"; + return n + " B"; +} +function fmtUptime(s) { + const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60); + return (d ? d + "d " : "") + (h ? h + "h " : "") + m + "m"; +} +function fmtAgo(ts) { + const d = Math.max(0, Math.floor(Date.now() / 1000 - ts)); + if (d < 60) return d + "s ago"; + if (d < 3600) return Math.floor(d / 60) + "m ago"; + if (d < 86400) return Math.floor(d / 3600) + "h ago"; + return Math.floor(d / 86400) + "d ago"; +} + +async function api() { + const r = await fetch("/portal/api/overview", { cache: "no-store" }); + if (r.status === 401) { showLogin(); return null; } + return r.json(); +} + +function showLogin() { + $("#viewDash").hidden = true; + $("#viewLogin").hidden = false; + setTimeout(() => $("#pwPortal").focus(), 50); +} +function showDash() { + $("#viewLogin").hidden = true; + $("#viewDash").hidden = false; + refresh(); +} + +async function login() { + const err = $("#loginErr"); + err.hidden = true; + const r = await fetch("/portal/login", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: "password=" + encodeURIComponent($("#pwPortal").value), + redirect: "manual", + }); + if (r.ok || r.status === 0) { $("#pwPortal").value = ""; showDash(); return; } + err.textContent = r.status === 401 ? "Wrong password." : "Login failed (" + r.status + ")"; + err.hidden = false; +} + +function flag(cc) { + if (!cc || cc.length !== 2 || !/^[a-z]{2}$/i.test(cc)) return "🌐"; + return String.fromCodePoint(...[...cc.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65)); +} +// Escape untrusted values (viewer IP from X-Forwarded-For, host names, geo, …) +// before they touch innerHTML — prevents XSS in the admin console. +function esc(s) { + return String(s == null ? "" : s).replace(/[&<>"']/g, + c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} +// A compact labelled load bar for host telemetry (CPU / RAM / Disk). +function bar(label, pct, title) { + pct = Math.max(0, Math.min(100, Math.round(pct || 0))); + const col = pct > 85 ? "#ef4444" : pct > 60 ? "#f59e0b" : "#22c55e"; + return `
${label}` + + `` + + `${pct}%
`; +} + +function render(d) { + d.ownerEmail = (d.users || []).find(u => u.email.toLowerCase() === "owner@example.com"); + window.__ownerSeen = !!d.owner; + $("#stOnline").textContent = d.hosts_online; + $("#stTotal").textContent = d.hosts.length; + $("#stSessions").textContent = d.sessions_total; + $("#stTraffic").textContent = fmtBytes(d.bytes_relayed); + $("#stUptime").textContent = fmtUptime(d.uptime); + $("#metaServer").textContent = "server v" + d.version; + $("#kvReg").textContent = d.reg_key || "-"; + $("#kvServer").textContent = location.origin.replace(/^http/, "ws") + "/ws"; + + const tb = $("#tblHosts tbody"); + tb.innerHTML = ""; + for (const h of d.hosts) { + const tr = document.createElement("tr"); + const dot = document.createElement("td"); + dot.innerHTML = `${h.online ? (h.in_session ? "in session" : "online") : "offline"}`; + const name = document.createElement("td"); + name.textContent = h.name || "(unnamed)"; + if (h.hidden) { + const badge = document.createElement("span"); + badge.className = "pill-hidden"; + badge.textContent = "HIDDEN"; + name.append(" ", badge); + } + const id = document.createElement("td"); + id.className = "mono"; + id.textContent = h.id; + const ver = document.createElement("td"); + ver.textContent = h.ver || ""; + const seen = document.createElement("td"); + seen.textContent = h.online ? "now" : fmtAgo(h.last_seen); + // live telemetry (CPU / RAM / Disk bars + battery) + const met = document.createElement("td"); + met.className = "metrics-cell"; + const tm = h.telemetry || {}; + if (h.online && tm.cpu != null) { + const ramPct = tm.ram_total ? (100 * tm.ram_used / tm.ram_total) : 0; + const diskPct = tm.disk_total ? (100 * tm.disk_used / tm.disk_total) : 0; + let bat = ""; + if (tm.battery != null && tm.battery >= 0) { + bat = `
${tm.charging ? "⚡" : "🔋"} ${tm.battery}%
`; + } + met.innerHTML = + bar("CPU", tm.cpu) + + bar("RAM", ramPct, fmtBytes(tm.ram_used) + " / " + fmtBytes(tm.ram_total)) + + bar("SSD", diskPct, fmtBytes(tm.disk_used) + " / " + fmtBytes(tm.disk_total)) + + bat; + } else { + met.innerHTML = '—'; + } + const act = document.createElement("td"); + act.style.whiteSpace = "nowrap"; + if (h.online && d.owner) { + const disc = document.createElement("button"); + disc.className = "tb"; + disc.textContent = h.in_session ? "Disconnect" : ""; + disc.style.marginRight = "6px"; + disc.onclick = async () => { + await fetch("/portal/api/session/disconnect", { method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: h.id }) }); + refresh(); + }; + act.append(disc); + } + { + // Hide/unhide is a portal-admin action (the console is password-gated), + // so it's available to any authenticated portal session. + const hd = document.createElement("button"); + hd.className = "tb"; + hd.textContent = h.hidden ? "Unhide" : "Hide"; + hd.style.marginRight = "6px"; + hd.onclick = async () => { + await fetch("/portal/api/host/hide", { method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: h.id, hidden: !h.hidden }) }); + refresh(); + }; + act.append(hd); + } + if (d.owner) { + const rm = document.createElement("button"); + rm.className = "tb danger"; + rm.textContent = "Remove"; + rm.onclick = async () => { + if (!confirm("Remove " + h.name + " from the server?")) return; + await fetch("/portal/api/host/delete", { method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: h.id }) }); + refresh(); + }; + act.append(rm); + } + if (h.online) { + const a = document.createElement("a"); + a.className = "tb connect"; + a.href = "/app?id=" + h.id; + a.textContent = "Connect"; + act.append(a); + } + tr.append(dot, name, id, ver, seen, met, act); + tb.append(tr); + } + $("#noHosts").hidden = d.hosts.length > 0; + + // users section (owner only) + const usec = document.querySelector("#sec-users"); + if (usec) usec.hidden = !d.owner; + if (d.owner) { + const utb = document.querySelector("#tblUsers tbody"); + if (utb) { + utb.innerHTML = ""; + for (const u of d.users || []) { + const tr = document.createElement("tr"); + const em = document.createElement("td"); + em.className = "mono"; + em.textContent = (u.email.toLowerCase() === (d.ownerEmail || "").toLowerCase()) ? "⭐ " + u.email : u.email; + const vf = document.createElement("td"); + vf.textContent = u.verified ? "yes" : "pending"; + const cr = document.createElement("td"); + cr.textContent = fmtAgo(u.created); + const del = document.createElement("td"); + const db = document.createElement("button"); + db.className = "tb danger"; + db.textContent = "Delete"; + db.onclick = async () => { + if (!confirm("Delete " + u.email + "?")) return; + await fetch("/portal/api/user/delete", { method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: u.email }) }); + refresh(); + }; + del.append(db); + tr.append(em, vf, cr, del); + utb.append(tr); + } + } + } + + const dl = $("#dlList"); + if (d.files.length) { + dl.innerHTML = ""; + dl.classList.remove("muted"); + for (const f of d.files) { + const a = document.createElement("a"); + a.className = "dl-row"; + a.href = "/dl/" + encodeURIComponent(f.name); + a.innerHTML = `⬇${f.name}${fmtBytes(f.size)}`; + dl.append(a); + } + } + + // (the #feed audit log is populated by loadAudit(), below) + + // ---- live visitors (IP intelligence + location) -------------------- + const vtb = document.querySelector("#tblVisitors tbody"); + if (vtb) { + vtb.innerHTML = ""; + for (const v of (d.visitors || [])) { + const g = v.geo || {}; + const loc = g.country ? `${g.city ? g.city + ", " : ""}${g.country}` : "—"; + const tr = document.createElement("tr"); + tr.innerHTML = + `${v.active ? "live" : "connecting"}` + + `${esc(v.ip) || "—"}` + + `${flag(g.cc)} ${esc(loc)}
${esc(g.isp)}
` + + `${esc(v.host_name || v.host)}` + + `${v.email ? esc(v.email) : 'guest'} ${esc(v.plan)}` + + `${fmtAgo(v.since)}`; + vtb.append(tr); + } + const nv = document.querySelector("#noVis"); + if (nv) nv.hidden = (d.visitors || []).length > 0; + } + const vr = document.querySelector("#visRecent"); + if (vr) { + vr.innerHTML = ""; + for (const v of (d.visitors_recent || []).slice(0, 30)) { + const g = v.geo || {}; + const loc = g.country ? `${g.city ? g.city + ", " : ""}${g.country}` : ""; + const row = document.createElement("div"); + row.className = "feed-row"; + row.innerHTML = + `${fmtAgo(v.t)}` + + `${flag(g.cc)} ${esc(v.ip) || "?"}${loc ? " · " + esc(loc) : ""} → ` + + `${esc(v.host_name || v.host)} ${esc(v.plan)}`; + vr.append(row); + } + if (!(d.visitors_recent || []).length) vr.innerHTML = '
no visitors yet
'; + } +} + +async function refresh() { + const d = await api(); + if (d) { showDash(); render(d); } +} + +function applyTheme(t) { + document.documentElement.dataset.theme = t; + const b = document.querySelector("#btnThemeP"); + if (b) b.textContent = t === "light" ? "☾ Dark" : "☀ Light"; +} +applyTheme(localStorage.getItem("nd.theme") || "dark"); +if ($("#btnThemeP")) $("#btnThemeP").onclick = () => { + const t = document.documentElement.dataset.theme === "light" ? "dark" : "light"; + localStorage.setItem("nd.theme", t); + applyTheme(t); +}; + +// null-safe binding: a missing element must never kill the whole script +function on(sel, fn) { const el = $(sel); if (el) el.onclick = fn; return el; } + +on("#btnLogin", login); +if ($("#pwPortal")) $("#pwPortal").addEventListener("keydown", (e) => { if (e.key === "Enter") login(); }); +on("#btnCopyReg", () => { + navigator.clipboard.writeText($("#kvReg").textContent); + $("#btnCopyReg").textContent = "Copied"; + setTimeout(() => ($("#btnCopyReg").textContent = "Copy"), 1200); +}); +on("#btnCopySrv", () => { + navigator.clipboard.writeText($("#kvServer").textContent.replace("/ws", "")); + $("#btnCopySrv").textContent = "Copied"; + setTimeout(() => ($("#btnCopySrv").textContent = "Copy"), 1200); +}); +on("#btnLogout", async () => { + await fetch("/portal/logout", { redirect: "manual" }); + location.href = location.pathname.replace(/\/portal$/, "/"); +}); + +/* ---- audit log console (full persistent log + filters + CSV export) ---- */ +function auditParams() { + const p = new URLSearchParams(); + const k = ($("#auKind") || {}).value, q = (($("#auQ") || {}).value || "").trim(), lim = ($("#auLimit") || {}).value; + if (k) p.set("kind", k); + if (q) p.set("q", q); + if (lim) p.set("limit", lim); + return p; +} +async function loadAudit() { + const feed = $("#feed"); + if (!feed) return; + const p = auditParams(); + const csv = $("#auCsv"); + if (csv) csv.href = "/portal/api/audit.csv?" + p.toString(); + try { + const r = await fetch("/portal/api/audit?" + p.toString(), { cache: "no-store" }); + if (!r.ok) return; + const d = await r.json(); + const sel = $("#auKind"); + if (sel && sel.options.length <= 1 && Array.isArray(d.kinds)) { + for (const k of d.kinds) { const o = document.createElement("option"); o.value = k; o.textContent = k; sel.append(o); } + } + feed.innerHTML = ""; + if (!d.events.length) { feed.innerHTML = '
no matching events
'; return; } + for (const e of d.events) { + const row = document.createElement("div"); + row.className = "feed-row kind-" + String(e.kind || "").replace(/[^a-z-]/g, ""); + const dt = new Date((e.ts || 0) * 1000); + const t = dt.toLocaleDateString() + " " + dt.toLocaleTimeString(); + const act = e.actor ? ` · ${esc(e.actor)}` : ""; + row.innerHTML = `${t}${esc(e.kind || "")}${esc(e.msg || "")}${act}`; + feed.append(row); + } + } catch (err) {} +} +(function wireAudit() { + const r = $("#auRefresh"); if (r) r.addEventListener("click", loadAudit); + const k = $("#auKind"); if (k) k.addEventListener("change", loadAudit); + const l = $("#auLimit"); if (l) l.addEventListener("change", loadAudit); + const q = $("#auQ"); let t; if (q) q.addEventListener("input", () => { clearTimeout(t); t = setTimeout(loadAudit, 300); }); +})(); +setInterval(loadAudit, 8000); + +refresh().then(() => { loadAudit(); setInterval(refresh, 3000); }).catch((e) => { showLogin(); }); diff --git a/server/web/pricing.html b/server/web/pricing.html new file mode 100644 index 0000000..d0e1aa3 --- /dev/null +++ b/server/web/pricing.html @@ -0,0 +1,79 @@ + + + + + +Pricing — NetDesk + + + + + + + + + + + + + + +
+
Pricing

Simple, honest pricing

+

Free for individuals, self-hosted forever. Upgrade for teams, unlimited sessions and priority support.

+
+
+

Individual

For personal use, self-hosted
+
$0/forever
+
    +
  • Self-host on your own server
  • +
  • End-to-end encryption
  • +
  • 1 registered computer
  • +
  • Sessions up to 60 min
  • +
  • File transfer & clipboard
  • +
  • Community support
  • +
+ Download free +
+
Most popular +

Pro

For power users & small teams
+
$9/month
+
    +
  • Everything in Individual
  • +
  • Unlimited computers
  • +
  • Unlimited session length
  • +
  • Address book & device groups
  • +
  • 2FA & audit log
  • +
  • Priority email support
  • +
+ Start Pro +
+
+

Business

For organizations at scale
+
Custom
+
    +
  • Everything in Pro
  • +
  • SSO / SAML & SCIM
  • +
  • Central policy & permissions
  • +
  • On-prem & air-gapped deploy
  • +
  • SLA & dedicated support
  • +
  • Custom branding
  • +
+ Contact sales +
+
+

+ Self-hosting NetDesk is always free and open source — you only pay for the hosted convenience tiers. + Prices in USD. Cancel anytime. See our refund policy.

+
+

Questions

+
+

Is NetDesk really free?

Yes. The full software is open source and free to self-host, forever. The paid tiers add hosted convenience, unlimited sessions and team features.

+

What's the session time limit on the free tier?

Individual (unregistered) sessions are capped at 60 minutes; simply reconnect to continue, or sign up / upgrade to remove the limit — the same model RustDesk uses.

+

Do you see my screen?

Never. Sessions are end-to-end encrypted; even the relay server only sees opaque encrypted bytes.

+

Can I use it commercially?

Self-hosting is permitted for commercial use under the open-source license. For managed/hosted commercial use, choose Pro or Business.

+
+ + + + \ No newline at end of file diff --git a/server/web/privacy.html b/server/web/privacy.html new file mode 100644 index 0000000..b52b8d8 --- /dev/null +++ b/server/web/privacy.html @@ -0,0 +1,44 @@ + + + + + +Privacy Policy — NetDesk + + + + + + + + + + + + + + +
+

Privacy Policy

Last updated: September 27, 2026

+

NetDesk is built privacy-first. This policy explains what the hosted service at netdesk.space collects. If you +self-host, none of this applies — your data stays entirely on your own server.

+

What we cannot see

We can never see the contents of your remote sessions. All screen, input, file and +clipboard data is end-to-end encrypted between the two endpoints; our relay only forwards opaque ciphertext.

+

What the hosted service stores

+
    +
  • Account data: your email and a salted password hash (PBKDF2), if you create an account.
  • +
  • Device registry: the IDs and names of computers you bind to your account, and online/offline status.
  • +
  • Operational logs: connection timestamps, IP addresses and coarse events for security and abuse prevention, retained for a limited period.
  • +
+

We do not store your host passwords, session contents, or screen data.

+

Cookies

We use a single essential cookie to keep you signed in. No third-party advertising or tracking cookies.

+

Sharing

We do not sell your data. We share it only as required by law or to operate the service (e.g., our mail +provider for verification emails).

+

Your rights

You may access, export or delete your account data at any time from the console, or by emailing +privacy@netdesk.space.

+

Contact

Questions about privacy? Email privacy@netdesk.space.

+
+ + + + \ No newline at end of file diff --git a/server/web/refund.html b/server/web/refund.html new file mode 100644 index 0000000..21ecbeb --- /dev/null +++ b/server/web/refund.html @@ -0,0 +1,32 @@ + + + + + +Refund Policy — NetDesk + + + + + + + + + + + + + + +
+

Refund Policy

Last updated: September 27, 2026

+

We want you to be happy with NetDesk Pro and Business.

+

14-day money-back guarantee

If you're not satisfied with a paid plan, request a full refund within 14 days +of your first payment — no questions asked. Email billing@netdesk.space.

+

Renewals

Renewal charges are refundable within 7 days of the renewal date. Cancel anytime to stop future charges.

+

Self-hosting

The self-hosted software is free and open source — there is nothing to refund.

+
+ + + + \ No newline at end of file diff --git a/server/web/security.html b/server/web/security.html new file mode 100644 index 0000000..ac940e6 --- /dev/null +++ b/server/web/security.html @@ -0,0 +1,48 @@ + + + + + +Security — NetDesk + + + + + + + + + + + + + + +
+

Security & Trust

Our commitment to keeping your remote sessions private.

+

End-to-end encryption by default

+

Every NetDesk session negotiates an ephemeral ECDH P-256 key exchange and encrypts all media with +AES-256-GCM, per frame, with a fresh nonce. The relay server forwards only opaque ciphertext — it +cannot decrypt your screen, input, files or clipboard. Only the two endpoints hold the keys.

+

Authentication

+

The host password never leaves your machine in the clear: the viewer proves knowledge of it with a +PBKDF2-HMAC-SHA256 (200,000 iterations) challenge/response, throttled on failure. Optional +TOTP two-factor adds an authenticator code at connect. An optional registration key restricts which +hosts may register with your server.

+

Transport

+

All traffic runs over TLS (WSS) to your server. We recommend a real certificate (Let's Encrypt); +NetDesk ships automation for it. HTTP is available only for localhost/LAN testing.

+

You own the data

+

NetDesk is self-hosted. There is no NetDesk cloud in the path. Your session metadata, accounts and audit logs live on +your server. Nothing is sent to a third party.

+

Open source

+

The entire stack is open source and auditable. Don't trust our word — read the code at +netdesk.space/git/akronlogic/netdesk.

+

Responsible disclosure

+

Found a vulnerability? Email security@netdesk.space. We respond within 72 +hours and credit reporters.

+
+ + + + \ No newline at end of file diff --git a/server/web/site.css b/server/web/site.css new file mode 100644 index 0000000..0bb9224 --- /dev/null +++ b/server/web/site.css @@ -0,0 +1,289 @@ +/* ================================================================ + NetDesk marketing site — production design system + ================================================================ */ +:root { + --bg: #0a0d14; --bg2: #10141e; --bg3: #161c28; --card: #141a26; + --fg: #eaeff8; --muted: #9aa4b6; --faint: #6b7688; + --accent: #3d7eff; --accent2: #6aa1ff; --accent-dim: rgba(61,126,255,.12); + --ok: #2fbf71; --border: #212a3a; --border-soft: #1a2130; + --r: 14px; --r-sm: 9px; --maxw: 1160px; + --grad: linear-gradient(135deg, #3d7eff 0%, #6aa1ff 55%, #2fbf71 130%); + --shadow: 0 24px 60px rgba(0,0,0,.45); +} +:root[data-theme="light"] { + --bg: #f6f8fc; --bg2: #ffffff; --bg3: #eef2f8; --card: #ffffff; + --fg: #131a26; --muted: #55617a; --faint: #97a1b4; + --border: #e2e8f1; --border-soft: #edf1f7; --accent-dim: rgba(61,126,255,.08); + --shadow: 0 24px 60px rgba(24,39,75,.10); +} +* { box-sizing: border-box; margin: 0; padding: 0; } +html { scroll-behavior: smooth; } +body { + font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; + background: var(--bg); color: var(--fg); line-height: 1.6; font-size: 16px; + -webkit-font-smoothing: antialiased; +} +a { color: inherit; text-decoration: none; } +img { max-width: 100%; display: block; } +.wrap { max-width: var(--maxw); margin: 0 auto; padding: 0 22px; } +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); } + +/* ---- header ---- */ +.nz { position: sticky; top: 0; z-index: 50; backdrop-filter: blur(14px); + background: color-mix(in srgb, var(--bg) 82%, transparent); border-bottom: 1px solid var(--border-soft); } +.nz-in { display: flex; align-items: center; justify-content: space-between; height: 66px; } +.nz-brand { display: flex; align-items: center; gap: 10px; font-weight: 800; font-size: 20px; letter-spacing: -.4px; } +.nz-logo { width: 34px; height: 34px; border-radius: 9px; background: var(--grad); + display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 14px rgba(61,126,255,.35); } +.nz-links { display: flex; align-items: center; gap: 4px; } +.nz-links a { padding: 9px 14px; border-radius: 8px; color: var(--muted); font-weight: 500; font-size: 15px; transition: .15s; } +.nz-links a:hover { color: var(--fg); background: var(--bg3); } +.nz-links a.active { color: var(--fg); } +.nz-cta { display: flex; align-items: center; gap: 10px; } +.nz-burger { display: none; background: none; border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; color: var(--fg); cursor: pointer; } +.btn { display: inline-flex; align-items: center; gap: 8px; padding: 11px 20px; border-radius: 10px; + font-weight: 600; font-size: 15px; cursor: pointer; border: 1px solid transparent; transition: .15s; white-space: nowrap; } +.btn-primary { background: var(--grad); color: #fff; box-shadow: 0 8px 22px rgba(61,126,255,.32); } +.btn-primary:hover { filter: brightness(1.07); transform: translateY(-1px); } +.btn-ghost { background: var(--bg2); color: var(--fg); border-color: var(--border); } +.btn-ghost:hover { border-color: var(--accent); } +.btn-lg { padding: 15px 28px; font-size: 16.5px; border-radius: 12px; } + +/* ---- hero ---- */ +.hero { position: relative; overflow: hidden; padding: 92px 0 70px; text-align: center; } +.hero::before { content: ""; position: absolute; inset: 0; z-index: -1; + background: radial-gradient(760px 420px at 50% -8%, rgba(61,126,255,.22), transparent 62%), + radial-gradient(680px 420px at 88% 24%, rgba(47,191,113,.12), transparent 60%); } +.tagpill { display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px; border-radius: 999px; + background: var(--accent-dim); border: 1px solid var(--border); color: var(--accent2); font-size: 13.5px; font-weight: 600; margin-bottom: 22px; } +.hero h1 { font-size: clamp(38px, 6vw, 66px); line-height: 1.05; letter-spacing: -1.6px; font-weight: 850; margin-bottom: 20px; } +.hero h1 .g { background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; } +.hero p.sub { font-size: clamp(17px, 2.2vw, 21px); color: var(--muted); max-width: 720px; margin: 0 auto 32px; } +.hero-cta { display: flex; gap: 14px; justify-content: center; flex-wrap: wrap; } +.hero-note { margin-top: 18px; color: var(--faint); font-size: 14px; } +.hero-shot { max-width: 940px; margin: 54px auto 0; border-radius: 16px; border: 1px solid var(--border); + box-shadow: var(--shadow); overflow: hidden; background: var(--bg2); } + +/* ---- sections ---- */ +.section { padding: 74px 0; } +.section.alt { background: var(--bg2); border-top: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft); } +.sec-head { text-align: center; max-width: 660px; margin: 0 auto 48px; } +.sec-head .eyebrow { color: var(--accent2); font-weight: 700; font-size: 14px; letter-spacing: 1px; text-transform: uppercase; } +.sec-head h2 { font-size: clamp(28px, 4vw, 42px); letter-spacing: -1px; font-weight: 800; margin: 10px 0 12px; } +.sec-head p { color: var(--muted); font-size: 18px; } + +.grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; } +.grid2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; } +.feat { background: var(--card); border: 1px solid var(--border); border-radius: var(--r); padding: 26px; transition: .18s; } +.feat:hover { border-color: var(--accent); transform: translateY(-3px); box-shadow: var(--shadow); } +.feat .ic { width: 46px; height: 46px; border-radius: 11px; background: var(--accent-dim); color: var(--accent2); + display: flex; align-items: center; justify-content: center; margin-bottom: 16px; } +.feat h3 { font-size: 18.5px; font-weight: 700; margin-bottom: 8px; } +.feat p { color: var(--muted); font-size: 15px; } + +/* ---- pricing ---- */ +.price-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; align-items: stretch; } +.plan { background: var(--card); border: 1px solid var(--border); border-radius: var(--r); padding: 32px 28px; display: flex; flex-direction: column; } +.plan.pop { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), var(--shadow); position: relative; } +.plan .badge { position: absolute; top: -13px; left: 50%; transform: translateX(-50%); background: var(--grad); + color: #fff; font-size: 12.5px; font-weight: 700; padding: 5px 14px; border-radius: 999px; } +.plan h3 { font-size: 20px; font-weight: 800; } +.plan .who { color: var(--muted); font-size: 14.5px; margin: 4px 0 18px; } +.plan .price { font-size: 44px; font-weight: 850; letter-spacing: -1.5px; } +.plan .price small { font-size: 16px; color: var(--muted); font-weight: 500; } +.plan ul { list-style: none; margin: 22px 0; display: grid; gap: 12px; } +.plan li { display: flex; gap: 10px; font-size: 15px; color: var(--fg); } +.plan li svg { flex-shrink: 0; color: var(--ok); margin-top: 3px; } +.plan .btn { justify-content: center; margin-top: auto; } + +/* ---- prose (legal/about) ---- */ +.prose { max-width: 820px; margin: 0 auto; } +.prose h1 { font-size: 40px; letter-spacing: -1px; margin-bottom: 8px; } +.prose .updated { color: var(--faint); font-size: 14px; margin-bottom: 34px; } +.prose h2 { font-size: 24px; margin: 34px 0 12px; letter-spacing: -.4px; } +.prose h3 { font-size: 18px; margin: 22px 0 8px; } +.prose p, .prose li { color: var(--muted); margin-bottom: 12px; } +.prose ul, .prose ol { padding-left: 22px; margin-bottom: 14px; } +.prose a { color: var(--accent2); } +.prose strong { color: var(--fg); } + +/* ---- contact ---- */ +.contact-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; align-items: start; } +.field { margin-bottom: 16px; } +.field label { display: block; font-size: 14px; font-weight: 600; margin-bottom: 6px; } +.field input, .field textarea, .field select { width: 100%; background: var(--bg2); border: 1px solid var(--border); + border-radius: 10px; padding: 12px 14px; color: var(--fg); font-size: 15px; font-family: inherit; outline: none; } +.field input:focus, .field textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); } +.info-row { display: flex; gap: 14px; padding: 16px 0; border-bottom: 1px solid var(--border-soft); } +.info-row .ic { color: var(--accent2); } + +/* ---- cta band ---- */ +.ctaband { text-align: center; padding: 70px 0; background: var(--bg2); border-top: 1px solid var(--border-soft); } +.ctaband h2 { font-size: clamp(28px, 4vw, 40px); letter-spacing: -1px; margin-bottom: 12px; } +.ctaband p { color: var(--muted); font-size: 18px; margin-bottom: 26px; } + +/* ---- footer ---- */ +.ft { background: var(--bg2); border-top: 1px solid var(--border); padding: 56px 0 28px; } +.ft-top { display: grid; grid-template-columns: 1.6fr 1fr 1fr 1fr; gap: 30px; } +.ft-brand p { color: var(--muted); font-size: 14.5px; margin: 14px 0; max-width: 300px; } +.ft-col h4 { font-size: 13px; text-transform: uppercase; letter-spacing: .8px; color: var(--faint); margin-bottom: 14px; } +.ft-col a { display: block; color: var(--muted); font-size: 15px; padding: 5px 0; transition: .12s; } +.ft-col a:hover { color: var(--fg); } +.ft-bot { display: flex; align-items: center; justify-content: space-between; margin-top: 40px; padding-top: 22px; + border-top: 1px solid var(--border-soft); color: var(--faint); font-size: 14px; flex-wrap: wrap; gap: 12px; } +.ft-social { display: flex; gap: 12px; } +.ft-social a { color: var(--muted); } .ft-social a:hover { color: var(--fg); } +.theme-toggle { background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; color: var(--fg); + padding: 8px 12px; cursor: pointer; font-size: 13.5px; } + +/* ---- misc ---- */ +.logos { display: flex; gap: 34px; justify-content: center; align-items: center; flex-wrap: wrap; opacity: .8; color: var(--muted); font-weight: 600; } +.compare { width: 100%; border-collapse: collapse; } +.compare th, .compare td { padding: 14px 16px; text-align: left; border-bottom: 1px solid var(--border-soft); font-size: 15px; } +.compare th { color: var(--faint); font-weight: 600; font-size: 13px; text-transform: uppercase; } +.compare td.y { color: var(--ok); font-weight: 700; } +.compare td.n { color: var(--faint); } +.compare tr td:first-child { color: var(--fg); font-weight: 600; } + +@media (max-width: 900px) { + .nz-links { display: none; } + .nz-burger { display: block; } + .nz-links.open { display: flex; position: absolute; top: 66px; left: 0; right: 0; flex-direction: column; + background: var(--bg2); border-bottom: 1px solid var(--border); padding: 12px; gap: 4px; } + .grid3, .price-grid, .ft-top { grid-template-columns: 1fr; } + .grid2, .contact-grid { grid-template-columns: 1fr; } + .ft-top { gap: 24px; } +} + +@media (max-width: 600px) { + /* Phones: keep the top bar to logo + primary CTA + burger so the page never + scrolls sideways. The secondary "Sign in" link stays reachable in-page. */ + .nz-cta .btn-ghost { display: none; } + .nz-cta { gap: 8px; } +} + +/* ================================================================ + ELEVATION LAYER v2 — award-winning, enterprise-grade polish. + Component-scoped overrides of the base system above (cascade wins), + so every marketing page that links site.css is lifted at once. + ================================================================ */ +:root{ + --accent-grad: linear-gradient(135deg,#4d8bff 0%,#6aa1ff 42%,#34d399 115%); + --glass: color-mix(in srgb, var(--card) 70%, transparent); + --hair: rgba(255,255,255,.07); + --hair-strong: rgba(255,255,255,.14); + --shadow-sm: 0 2px 10px rgba(0,0,0,.28); + --shadow-lg: 0 34px 90px -28px rgba(0,0,0,.70); + --ring: 0 0 0 4px rgba(77,139,255,.14); + --r: 16px; --r-sm: 11px; +} +:root[data-theme="light"]{ + --glass: rgba(255,255,255,.72); + --hair: rgba(16,24,40,.06); --hair-strong: rgba(16,24,40,.12); + --shadow-lg: 0 34px 90px -30px rgba(24,39,75,.22); +} + +/* fine film-grain for tactile depth — fixed, never intercepts input */ +body::after{ + content:""; position:fixed; inset:0; z-index:9998; pointer-events:none; opacity:.03; + mix-blend-mode:overlay; + background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); +} + +/* ---- scroll reveal (universal; site.js adds .in on intersect) ---- */ +.reveal{ opacity:0; transform:translateY(20px); transition:opacity .7s cubic-bezier(.2,.7,.2,1), transform .7s cubic-bezier(.2,.7,.2,1); will-change:opacity,transform; } +.reveal.in{ opacity:1; transform:none; } +@media (prefers-reduced-motion: reduce){ .reveal{ opacity:1 !important; transform:none !important; } } + +/* ---- nav: crisper glass + gradient hairline ---- */ +.nz{ background: color-mix(in srgb, var(--bg) 68%, transparent); border-bottom:1px solid var(--hair); } +.nz::after{ content:""; position:absolute; left:0; right:0; bottom:-1px; height:1px; + background:linear-gradient(90deg,transparent,rgba(77,139,255,.4),rgba(52,211,153,.3),transparent); opacity:.6; } +.nz-logo{ box-shadow:0 6px 18px -4px rgba(77,139,255,.5), inset 0 1px 0 rgba(255,255,255,.3); } +.nz-links a:hover{ background:var(--hair); color:var(--fg); } +.nz-links a.active{ color:var(--fg); } +.nz-links a.active::after{ content:""; display:block; height:2px; margin-top:7px; border-radius:2px; background:var(--accent-grad); } + +/* ---- buttons: gradient + inner highlight + hover sheen ---- */ +.btn{ border-radius:12px; letter-spacing:-.1px; transition:transform .16s ease, box-shadow .16s ease, filter .16s ease, border-color .16s ease; } +.btn-primary{ background:var(--accent-grad); position:relative; overflow:hidden; + box-shadow:0 10px 28px -8px rgba(77,139,255,.55), inset 0 1px 0 rgba(255,255,255,.28); } +.btn-primary::before{ content:""; position:absolute; top:0; left:-120%; width:60%; height:100%; + background:linear-gradient(100deg,transparent,rgba(255,255,255,.35),transparent); transform:skewX(-18deg); transition:left .6s ease; } +.btn-primary:hover::before{ left:140%; } +.btn-primary:hover{ transform:translateY(-2px); box-shadow:0 16px 40px -10px rgba(77,139,255,.6), inset 0 1px 0 rgba(255,255,255,.3); filter:none; } +.btn-ghost{ background:var(--glass); backdrop-filter:blur(10px); border-color:var(--hair-strong); } +.btn-ghost:hover{ border-color:var(--accent); background:var(--card); transform:translateY(-1px); } +.btn:focus-visible{ outline:none; box-shadow:var(--ring); } + +/* ---- feature cards: gradient border + cursor spotlight + lifted hover ---- */ +.feat{ position:relative; border:1px solid transparent; border-radius:var(--r); padding:28px; overflow:hidden; + background:linear-gradient(var(--card),var(--card)) padding-box, + linear-gradient(180deg,var(--hair-strong),rgba(255,255,255,.015)) border-box; + transition:transform .22s cubic-bezier(.2,.7,.2,1), box-shadow .22s ease; } +.feat::before{ content:""; position:absolute; inset:0; border-radius:inherit; opacity:0; transition:opacity .3s; pointer-events:none; + background:radial-gradient(420px circle at var(--mx,50%) var(--my,-10%), rgba(77,139,255,.16), transparent 42%); } +.feat:hover{ transform:translateY(-5px); box-shadow:var(--shadow-lg); } +.feat:hover::before{ opacity:1; } +.feat .ic{ width:50px; height:50px; border-radius:13px; color:#a8c6ff; border:1px solid var(--hair); + background:linear-gradient(135deg, rgba(77,139,255,.20), rgba(52,211,153,.10)); + box-shadow:inset 0 1px 0 rgba(255,255,255,.14), 0 8px 20px -8px rgba(77,139,255,.45); } +.feat h3{ font-size:19px; letter-spacing:-.3px; } +.feat p{ font-size:15px; line-height:1.62; } + +/* ---- generic card (pricing/other) spotlight hover parity ---- */ +.plan{ position:relative; border-radius:var(--r); transition:transform .22s cubic-bezier(.2,.7,.2,1), box-shadow .22s ease; } +.plan:hover{ transform:translateY(-4px); box-shadow:var(--shadow-lg); } +.plan.pop{ border:1px solid transparent; + background:linear-gradient(var(--card),var(--card)) padding-box, var(--accent-grad) border-box; + box-shadow:0 0 70px -24px rgba(77,139,255,.6), var(--shadow-lg); } +.plan .price{ background:var(--accent-grad); -webkit-background-clip:text; background-clip:text; color:transparent; } +.plan.pop .badge{ box-shadow:0 8px 22px -6px rgba(77,139,255,.6); } + +/* ---- section heads: gradient eyebrow + tighter type ---- */ +.sec-head .eyebrow{ display:inline-block; background:var(--accent-grad); -webkit-background-clip:text; background-clip:text; color:transparent; } +.sec-head h2{ letter-spacing:-1.2px; } +.section.alt{ background:color-mix(in srgb,var(--bg2) 92%, #000 8%); } + +/* ---- comparison table: container + highlighted NetDesk column + icons ---- */ +.compare{ border-collapse:separate; border-spacing:0; border:1px solid var(--hair); border-radius:var(--r); overflow:hidden; background:var(--card); box-shadow:var(--shadow-sm); } +.compare th, .compare td{ border-bottom:1px solid var(--hair); } +.compare tr:last-child td{ border-bottom:none; } +.compare th:nth-child(2), .compare td:nth-child(2){ background:color-mix(in srgb,var(--accent) 8%, transparent); position:relative; } +.compare th:nth-child(2){ color:var(--accent2); } +.compare thead th:nth-child(2)::after, .compare tr:first-child td:nth-child(2)::before{ } +.compare td.y{ color:var(--ok); } +.compare tr td:first-child{ color:var(--fg); } + +/* ---- enterprise trust: stats band ---- */ +.statband{ border-top:1px solid var(--hair); border-bottom:1px solid var(--hair); + background:color-mix(in srgb,var(--bg2) 60%, transparent); } +.statband .wrap{ display:grid; grid-template-columns:repeat(4,1fr); gap:18px; padding:30px 22px; } +.stat{ text-align:center; position:relative; } +.stat:not(:last-child)::after{ content:""; position:absolute; right:-9px; top:14%; height:72%; width:1px; background:var(--hair); } +.stat b{ display:block; font-size:clamp(26px,3.4vw,36px); font-weight:850; letter-spacing:-1px; line-height:1; + background:var(--accent-grad); -webkit-background-clip:text; background-clip:text; color:transparent; } +.stat span{ display:block; margin-top:8px; color:var(--muted); font-size:13.5px; font-weight:500; } + +/* ---- logos strip: refined ---- */ +.logos{ gap:40px; opacity:1; } +.logos span{ color:var(--faint); font-weight:650; font-size:15px; letter-spacing:.2px; transition:color .2s, filter .2s; filter:grayscale(1) opacity(.7); } +.logos span:hover{ color:var(--muted); filter:none; } + +/* ---- hero shot: layered depth ---- */ +.hero-shot, .hz-shot{ box-shadow:var(--shadow-lg), 0 0 0 1px var(--hair); } + +/* ---- footer: subtle top glow ---- */ +.ft{ position:relative; border-top:1px solid var(--hair); } +.ft::before{ content:""; position:absolute; top:-1px; left:0; right:0; height:1px; + background:linear-gradient(90deg,transparent,rgba(77,139,255,.35),transparent); } +.theme-toggle:hover{ border-color:var(--accent); } + +/* ---- form fields: softer focus ---- */ +.field input:focus, .field textarea:focus, .field select:focus{ box-shadow:var(--ring); } + +/* ---- responsive: stats stack ---- */ +@media (max-width:760px){ + .statband .wrap{ grid-template-columns:repeat(2,1fr); gap:24px 12px; } + .stat:nth-child(2)::after{ display:none; } +} diff --git a/server/web/site.js b/server/web/site.js new file mode 100644 index 0000000..20cabe8 --- /dev/null +++ b/server/web/site.js @@ -0,0 +1,103 @@ +/* NetDesk marketing site: shared header + footer, theme, mobile nav. */ +(function () { + "use strict"; + var YEAR = 2026; + var NAV = [ + ["Features", "/features"], ["Pricing", "/pricing"], + ["Security", "/security"], ["Download", "/download"], + ["Source", "/git/akronlogic/netdesk"] + ]; + var page = (document.body.getAttribute("data-page") || "").toLowerCase(); + + function svgLogo() { + return '' + + ''; + } + + function header() { + var links = NAV.map(function (n) { + var active = page === n[0].toLowerCase() ? " class=\"active\"" : ""; + return '' + n[0] + ""; + }).join(""); + return '
' + + 'NetDesk" + + '' + + '
' + + 'Sign in' + + 'Get NetDesk' + + '' + + "
"; + } + + function footer() { + return '"; + } + + function applyTheme(t) { + document.documentElement.setAttribute("data-theme", t); + try { localStorage.setItem("nd.theme", t); } catch (e) {} + } + + document.addEventListener("DOMContentLoaded", function () { + var h = document.getElementById("siteHeader"); + var f = document.getElementById("siteFooter"); + if (h) h.innerHTML = header(); + if (f) f.innerHTML = footer(); + + var burger = document.getElementById("nzBurger"); + if (burger) burger.onclick = function () { document.getElementById("nzLinks").classList.toggle("open"); }; + var ft = document.getElementById("ftTheme"); + if (ft) ft.onclick = function () { + applyTheme(document.documentElement.getAttribute("data-theme") === "light" ? "dark" : "light"); + }; + // reflect signed-in state in the header + fetch("/api/auth/me").then(function (r) { return r.ok ? r.json() : null; }).then(function (d) { + var b = document.getElementById("btnSignin"); + if (d && d.email && b) { b.textContent = "My Computers"; b.href = "/app"; } + }).catch(function () {}); + + // --- scroll reveal (every page that uses .reveal) --- + var revs = document.querySelectorAll(".reveal"); + if (revs.length && "IntersectionObserver" in window) { + var io = new IntersectionObserver(function (es) { + es.forEach(function (e) { if (e.isIntersecting) { e.target.classList.add("in"); io.unobserve(e.target); } }); + }, { threshold: 0.1, rootMargin: "0px 0px -8% 0px" }); + revs.forEach(function (el) { io.observe(el); }); + } else { + revs.forEach(function (el) { el.classList.add("in"); }); + } + + // --- cursor spotlight on cards (sets --mx/--my for the ::before glow) --- + function spot(e) { + var r = e.currentTarget.getBoundingClientRect(); + e.currentTarget.style.setProperty("--mx", (e.clientX - r.left) + "px"); + e.currentTarget.style.setProperty("--my", (e.clientY - r.top) + "px"); + } + document.querySelectorAll(".feat, .plan").forEach(function (c) { + c.addEventListener("mousemove", spot, { passive: true }); + }); + }); + + try { applyTheme(localStorage.getItem("nd.theme") || "dark"); } catch (e) { applyTheme("dark"); } +})(); diff --git a/server/web/sitemap.xml b/server/web/sitemap.xml new file mode 100644 index 0000000..e02f9ef --- /dev/null +++ b/server/web/sitemap.xml @@ -0,0 +1,13 @@ + + + https://netdesk.space/weekly1.0 + https://netdesk.space/pricingweekly0.7 + https://netdesk.space/featuresweekly0.7 + https://netdesk.space/securityweekly0.7 + https://netdesk.space/downloadweekly0.7 + https://netdesk.space/contactweekly0.7 + https://netdesk.space/aboutweekly0.7 + https://netdesk.space/termsweekly0.7 + https://netdesk.space/privacyweekly0.7 + https://netdesk.space/refundweekly0.7 + diff --git a/server/web/terms.html b/server/web/terms.html new file mode 100644 index 0000000..4574bd9 --- /dev/null +++ b/server/web/terms.html @@ -0,0 +1,44 @@ + + + + + +Terms of Service — NetDesk + + + + + + + + + + + + + + +
+

Terms of Service

Last updated: September 27, 2026

+

These Terms govern your use of NetDesk software and the optional hosted services provided at netdesk.space +("Service"). By downloading, self-hosting or using NetDesk, you agree to these Terms.

+

1. License

NetDesk's source code is provided under its open-source license. You may self-host, modify and +use it, including commercially, subject to that license. The hosted Pro and Business services are provided under these Terms.

+

2. Acceptable use

You may only use NetDesk to access computers you own or are explicitly authorized to +administer. You must not use NetDesk to gain unauthorized access to any system, to distribute malware, or for any +unlawful purpose. You are responsible for complying with all applicable laws in your jurisdiction.

+

3. Accounts

You are responsible for safeguarding your account credentials, host passwords and 2FA secrets. +Notify us of any unauthorized use.

+

4. Free tier & limits

The free Individual tier is provided as-is and may include session time limits and +device limits. We may adjust free-tier limits with reasonable notice.

+

5. Payment

Paid plans are billed in advance and renew automatically until cancelled. Fees are non-refundable +except as stated in our Refund Policy.

+

6. Disclaimer

THE SOFTWARE AND SERVICE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. To the maximum extent +permitted by law, NetDesk disclaims all warranties and is not liable for any indirect, incidental or consequential damages.

+

7. Changes

We may update these Terms; material changes will be posted here with a new date.

+

8. Contact

Questions? Contact us or email hello@netdesk.space.

+
+ + + + \ No newline at end of file diff --git a/tools/patch_accounts.py b/tools/patch_accounts.py new file mode 100644 index 0000000..c290e70 --- /dev/null +++ b/tools/patch_accounts.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""One-shot patch: add user accounts + email auth to netdesk_server.py.""" +import re + +p = "server/netdesk_server.py" +s = open(p, encoding="utf-8").read() + +ANCHOR = "TUNNELS: dict[int, web.WebSocketResponse] = {} # tid -> viewer-side socket\nTUNNEL_TID = [1]" +assert ANCHOR in s, "tunnel anchor missing" + +BLOCK = ''' +# ---------------------------------------------------------------- user accounts (email sign-in for viewers) +import sqlite3 +import smtplib +from email.message import EmailMessage as _MailMsg + +ACCOUNTS_DB = os.path.join(_server_root, "accounts.db") +USER_SESSIONS = {} # token -> email + +def _acct_db(): + conn = sqlite3.connect(ACCOUNTS_DB) + conn.execute( + "CREATE TABLE IF NOT EXISTS users(id INTEGER PRIMARY KEY AUTOINCREMENT," + " email TEXT UNIQUE NOT NULL, pw_salt TEXT NOT NULL, pw_hash TEXT NOT NULL," + " verified INTEGER NOT NULL DEFAULT 0, verify_token TEXT," + " reset_token TEXT, reset_exp INTEGER, created INTEGER NOT NULL)") + return conn + +def _pbkdf(password, salt): + import hashlib as _h + return _h.pbkdf2_hmac("sha256", password.encode(), salt, 200_000).hex() + +def _mail_html(title, body_html, link=None, link_text=None): + link_html = "" + if link: + link_html = ( + '

' + '' + link_text + "

" + '

Or paste this link: ' + link + "

") + logo = ('' + '' + '' + '') + return ( + '
' + '
' + logo + + 'NetDesk
' + '

' + title + "

" + '
' + body_html + "
" + + link_html + + '

' + "If you did not request this, ignore this email.

") + +def send_mail(to, subject, html): + if not ARGS.mail_from: + raise RuntimeError("email not configured on this server") + msg = _MailMsg() + msg["From"] = "NetDesk <" + ARGS.mail_from + ">" + msg["To"] = to + msg["Subject"] = subject + msg.set_content("Your mail client does not support HTML.") + msg.add_alternative(html, subtype="html") + host = ARGS.smtp_host or "127.0.0.1" + with smtplib.SMTP(host, 25, timeout=20) as smtp: + smtp.send_message(msg) + +def user_from_cookie(req): + if not ARGS.mail_from: + return None + tok = req.cookies.get("nd_user", "") + return USER_SESSIONS.get(tok) + +async def auth_signup(req): + body = await req.json() + email = str(body.get("email", "")).strip().lower() + pw = str(body.get("password", "")) + if "@" not in email or len(email) < 5: + return web.json_response({"error": "enter a valid email"}, status=400) + if len(pw) < 8: + return web.json_response({"error": "password needs 8+ characters"}, status=400) + salt = _secrets.token_bytes(16) + vtok = _secrets.token_urlsafe(24) + now = int(time.time()) + with _acct_db() as conn: + try: + conn.execute( + "INSERT INTO users(email,pw_salt,pw_hash,verified,verify_token,created) " + "VALUES(?,?,?,?,?,?)", (email, salt.hex(), _pbkdf(pw, salt), 0, vtok, now)) + except sqlite3.IntegrityError: + row = conn.execute("SELECT verified FROM users WHERE email=?", (email,)).fetchone() + return web.json_response( + {"error": "email already registered - sign in or reset password", + "unverified": bool(row and not row[0])}, status=409) + link = ARGS.public_url + "/api/auth/verify?token=" + vtok + send_mail(email, "Verify your NetDesk account", + _mail_html("Confirm your email", + "Welcome to NetDesk. Click below to activate your account " + "and start connecting to your computers.", link, "Verify my email")) + log_event("auth", "signup: " + email) + return web.json_response({"ok": True, "msg": "verification email sent"}) + +async def auth_verify(req): + tok = req.query.get("token", "") + with _acct_db() as conn: + row = conn.execute("SELECT id FROM users WHERE verify_token=? AND verified=0", (tok,)).fetchone() + if not row: + return web.Response(status=400, text="invalid or used link") + conn.execute("UPDATE users SET verified=1, verify_token=NULL WHERE id=?", (row[0],)) + log_event("auth", "email verified") + raise web.HTTPFound(ARGS.public_url + "/?verified=1") + +async def auth_login(req): + body = await req.json() + email = str(body.get("email", "")).strip().lower() + pw = str(body.get("password", "")) + with _acct_db() as conn: + row = conn.execute("SELECT pw_salt,pw_hash,verified FROM users WHERE email=?", (email,)).fetchone() + if not row: + return web.json_response({"error": "wrong email or password"}, status=401) + if _pbkdf(pw, bytes.fromhex(row[0])) != row[1]: + return web.json_response({"error": "wrong email or password"}, status=401) + if not row[2]: + return web.json_response({"error": "check your inbox - email not verified yet"}, status=403) + tok = _secrets.token_urlsafe(24) + USER_SESSIONS[tok] = email + resp = web.json_response({"ok": True, "email": email}) + resp.set_cookie("nd_user", tok, httponly=True, samesite="Lax", max_age=30 * 86400) + log_event("auth", "viewer signed in: " + email) + return resp + +async def auth_me(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + return web.json_response({"email": email}) + +async def auth_logout(req): + tok = req.cookies.get("nd_user", "") + USER_SESSIONS.pop(tok, None) + resp = web.json_response({"ok": True}) + resp.del_cookie("nd_user") + return resp + +async def auth_forgot(req): + body = await req.json() + email = str(body.get("email", "")).strip().lower() + with _acct_db() as conn: + row = conn.execute("SELECT id FROM users WHERE email=? AND verified=1", (email,)).fetchone() + if row: + tok = _secrets.token_urlsafe(24) + conn.execute("UPDATE users SET reset_token=?, reset_exp=? WHERE id=?", + (tok, int(time.time()) + 1800, row[0])) + link = ARGS.public_url + "/?reset=" + tok + send_mail(email, "Reset your NetDesk password", + _mail_html("Password reset", + "Click below to choose a new password. " + "The link expires in 30 minutes.", link, "Reset password")) + return web.json_response( + {"ok": True, "msg": "if that account exists, a reset email was sent"}) + +async def auth_reset(req): + body = await req.json() + tok, pw = str(body.get("token", "")), str(body.get("password", "")) + if len(pw) < 8: + return web.json_response({"error": "password needs 8+ characters"}, status=400) + with _acct_db() as conn: + row = conn.execute("SELECT id FROM users WHERE reset_token=? AND reset_exp>?", + (tok, int(time.time()))).fetchone() + if not row: + return web.json_response({"error": "link expired or invalid"}, status=400) + salt = _secrets.token_bytes(16) + conn.execute("UPDATE users SET pw_salt=?, pw_hash=?, reset_token=NULL, reset_exp=NULL " + "WHERE id=?", (salt.hex(), _pbkdf(pw, salt), row[0])) + log_event("auth", "password reset") + return web.json_response({"ok": True}) + +''' + +# insert block before portal section +s = s.replace(ANCHOR, BLOCK + ANCHOR, 1) + +# CLI args +s = s.replace( + ' ap.add_argument("--access-path", dest="access_path",\n' + ' help="secret URL segment hosting the UI (random hash); root serves 404")', + ' ap.add_argument("--access-path", dest="access_path",\n' + ' help="secret URL segment hosting the UI (random hash); root serves 404")\n' + ' ap.add_argument("--mail-from", dest="mail_from", default="",\n' + ' help="sender address enabling email accounts, e.g. noreply@netdesk.space")\n' + ' ap.add_argument("--smtp-host", dest="smtp_host", default="127.0.0.1",\n' + ' help="SMTP relay for verification mail (default localhost postfix)")\n' + ' ap.add_argument("--public-url", dest="public_url", default="",\n' + ' help="public base URL used in email links, e.g. https://netdesk.space")') + +# middleware-free auth routes (public) +s = s.replace( + ' app.router.add_get("/tunnel/{hid}", tunnel_ws)', + ' app.router.add_get("/tunnel/{hid}", tunnel_ws)\n' + ' # viewer email accounts\n' + ' app.router.add_post("/api/auth/signup", auth_signup)\n' + ' app.router.add_post("/api/auth/login", auth_login)\n' + ' app.router.add_post("/api/auth/forgot", auth_forgot)\n' + ' app.router.add_post("/api/auth/reset", auth_reset)\n' + ' app.router.add_get("/api/auth/verify", auth_verify)\n' + ' app.router.add_get("/api/auth/me", auth_me)\n' + ' app.router.add_post("/api/auth/logout", auth_logout)') + +# print line in main +s = s.replace( + ' print(f"[netdesk] web portal enabled at {base}/portal (downloads: {FILES_DIR})")', + ' print(f"[netdesk] web portal enabled at {base}/portal (downloads: {FILES_DIR})")\n' + ' if ARGS.mail_from:\n' + ' print(f"[netdesk] viewer accounts ON - mail from {ARGS.mail_from} via {ARGS.smtp_host}:25")') + +open(p, "w", encoding="utf-8").write(s) +print("server accounts patch applied") diff --git a/tools/patch_plans.py b/tools/patch_plans.py new file mode 100644 index 0000000..e5b54ad --- /dev/null +++ b/tools/patch_plans.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +"""Patch: guest/user/owner plans with server-side session limits, +owner admin access, keyless host registration support, host/user +management endpoints, public host download.""" +import re + +p = "server/netdesk_server.py" +s = open(p, encoding="utf-8").read() + +# ---------- 1. plans + owner ---------- +old = "TUNNEL_TID = [1]" +new = """TUNNEL_TID = [1] + +# session plans: guests 20 min, verified users 45 min, owner unlimited +PLAN_LIMITS = {"owner": None, "user": 45 * 60, "guest": 20 * 60} + +def plan_for(email): + if email and ARGS.owner_email and email.lower() == ARGS.owner_email.lower(): + return "owner", None + if email: + return "user", PLAN_LIMITS["user"] + return "guest", PLAN_LIMITS["guest"]""" +assert old in s, "p1" +s = s.replace(old, new, 1) + +# ---------- 2. owner + plan on session.begin; drop hard signin gate ---------- +old = """ elif t == "session.begin": + if ARGS.mail_from and not user_from_cookie(req): + log_event("auth", "connect blocked: viewer not signed in") + await send_json(ws, {"t": "session.deny", "sid": None, "reason": "signin"}) + continue + hid = str(m.get("id", "")).strip() + h = HOSTS.get(hid)""" +new = """ elif t == "session.begin": + vuser = user_from_cookie(req) + vplan, vlimit = plan_for(vuser) + hid = str(m.get("id", "")).strip() + h = HOSTS.get(hid)""" +assert old in s, "p2" +s = s.replace(old, new, 1) + +# after sess created, attach plan info +old = """ sess = Session(sid, h, ws) + h.session = sess + viewer_sid = sid + STATS.sessions_total += 1 + log_event("session", f"viewer {peer} connecting to host {hid}") + log.info("session %s: viewer %s -> host %s", sid, peer, hid) + await send_json(h.ws, {"t": "session.begin", "sid": sid})""" +new = """ sess = Session(sid, h, ws) + sess.plan, sess.vlimit, sess.vemail = vplan, vlimit, vuser + h.session = sess + viewer_sid = sid + STATS.sessions_total += 1 + log_event("session", f"viewer {peer} ({vplan}) connecting to host {hid}") + log.info("session %s: viewer %s (%s) -> host %s", sid, peer, vplan, hid) + await send_json(h.ws, {"t": "session.begin", "sid": sid})""" +assert old in s, "p3" +s = s.replace(old, new, 1) + +# on accept: tell viewer the plan + start the enforcement timer +old = """ if t == "session.accept" and host is not None: + sess.active = True + log_event("session", f"session accepted on host {sess.host.id}") + await send_json(sess.viewer_ws, m)""" +new = """ if t == "session.accept" and host is not None: + sess.active = True + log_event("session", f"session accepted on host {sess.host.id}") + plan_msg = {"t": "session.plan", "plan": sess.plan, + "limit": sess.vlimit or 0, "email": sess.vemail or ""} + await send_json(sess.viewer_ws, plan_msg) + if sess.vlimit: + async def _limit_killer(s=sess): + await asyncio.sleep(s.vlimit) + if s.active: + log_event("session", f"time limit on host {s.host.id}") + await relay_session_end( + s, "session time limit reached - reconnect or sign up", + None) + if s.host: + s.host.session = None + asyncio.ensure_future(_limit_killer(sess)) + await send_json(sess.viewer_ws, m)""" +assert old in s, "p4" +s = s.replace(old, new, 1) + +# ---------- 3. Session fields ---------- +old = """class Session: + def __init__(self, sid, host, viewer_ws): + self.sid = sid + self.host = host + self.viewer_ws = viewer_ws + self.active = False""" +new = """class Session: + def __init__(self, sid, host, viewer_ws): + self.sid = sid + self.host = host + self.viewer_ws = viewer_ws + self.active = False + self.plan = "guest" + self.vlimit = None + self.vemail = "" + self.started = time.time()""" +assert old in s, "p5" +s = s.replace(old, new, 1) + +# ---------- 4. owner-aware portal auth + admin endpoints ---------- +old = """def portal_authorized(req) -> bool: + if not ARGS.portal_password: + return False + tok = req.cookies.get("ndportal", "") + return tok in PORTAL_SESSIONS""" +new = """def owner_from_cookie(req) -> bool: + email = user_from_cookie(req) + return bool(email and ARGS.owner_email and email.lower() == ARGS.owner_email.lower()) + + +def portal_authorized(req) -> bool: + if owner_from_cookie(req): + return True + if not ARGS.portal_password: + return False + tok = req.cookies.get("ndportal", "") + return tok in PORTAL_SESSIONS""" +assert old in s, "p6" +s = s.replace(old, new, 1) + +# admin endpoints +old = """async def portal_download(req):""" +new = """async def portal_users(req): + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + users = [] + with _acct_db() as conn: + for row in conn.execute("SELECT email, verified, created FROM users " + "ORDER BY created DESC LIMIT 500"): + users.append({"email": row[0], "verified": bool(row[1]), "created": row[2]}) + return web.json_response({"users": users}) + + +async def portal_user_delete(req): + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + body = await req.json() + email = str(body.get("email", "")).lower() + if ARGS.owner_email and email == ARGS.owner_email.lower(): + return web.json_response({"error": "cannot delete the owner"}, status=400) + with _acct_db() as conn: + conn.execute("DELETE FROM users WHERE email=?", (email,)) + for tok, em in list(USER_SESSIONS.items()): + if em == email: + USER_SESSIONS.pop(tok, None) + log_event("admin", f"user deleted: {email}") + return web.json_response({"ok": True}) + + +async def portal_host_delete(req): + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + hid = str((await req.json()).get("id", "")) + h = HOSTS.get(hid) + if h is None: + return web.json_response({"error": "host offline (removed from registry)"}, status=404) + try: + await h.ws.close() + except Exception: + pass + log_event("admin", f"host {hid} removed by admin") + return web.json_response({"ok": True, "msg": "host removed; it must re-register"}) + + +async def portal_session_disconnect(req): + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + hid = str((await req.json()).get("id", "")) + h = HOSTS.get(hid) + if h is None or not h.session: + return web.json_response({"error": "no active session"}, status=404) + await relay_session_end(h.session, "disconnected by admin", None) + h.session = None + log_event("admin", f"session on {hid} disconnected by admin") + return web.json_response({"ok": True}) + + +async def portal_download(req):""" +assert old in s, "p7" +s = s.replace(old, new, 1) + +# overview: users + richer host info +old = """ return web.json_response({ + "version": VERSION, + "reg_key": ARGS.reg_key or "", + "uptime": int(now - STATS.started),""" +new = """ users = [] + with _acct_db() as conn: + for row in conn.execute("SELECT email, verified, created FROM users " + "ORDER BY created DESC LIMIT 500"): + users.append({"email": row[0], "verified": bool(row[1]), "created": row[2]}) + for k in known: + h = HOSTS.get(k["id"]) + k["plan"] = h.session.plan if (h and h.session) else "" + return web.json_response({ + "version": VERSION, + "owner": bool(ARGS.owner_email and user_from_cookie(req) and + user_from_cookie(req).lower() == ARGS.owner_email.lower()), + "users": users, + "reg_key": ARGS.reg_key or "", + "uptime": int(now - STATS.started),""" +assert old in s, "p8" +s = s.replace(old, new, 1) + +# routes +old = """ app.router.add_get("/portal/api/overview", portal_overview)""" +new = """ app.router.add_get("/portal/api/overview", portal_overview) + app.router.add_get("/portal/api/users", portal_users) + app.router.add_post("/portal/api/user/delete", portal_user_delete) + app.router.add_post("/portal/api/host/delete", portal_host_delete) + app.router.add_post("/portal/api/session/disconnect", portal_session_disconnect)""" +assert old in s, "p9" +s = s.replace(old, new, 1) + +# public host download (homepage button) +old = """async def tunnel_ws(req):""" +new = """async def public_download(req): + name = os.path.basename(req.match_info.get("name", "")) + path = os.path.join(FILES_DIR, name) + if not os.path.isfile(path): + return web.Response(status=404, text="not found") + with open(path, "rb") as f: + body = f.read() + resp = web.Response(body=body, content_type="application/octet-stream") + resp.headers["Content-Disposition"] = f'attachment; filename="{name}"' + return resp + + +async def tunnel_ws(req):""" +assert old in s, "p10" +s = s.replace(old, new, 1) +old = """ app.router.add_get("/tunnel/{hid}", tunnel_ws)""" +new = """ app.router.add_get("/tunnel/{hid}", tunnel_ws) + app.router.add_get("/download/{name}", public_download)""" +assert old in s; s = s.replace(old, new, 1) + +# ---------- 5. CLI owner arg ---------- +old = """ ap.add_argument("--mail-from", dest="mail_from", default="",""" +new = """ ap.add_argument("--owner-email", dest="owner_email", default="", + help="email with unrestricted access + admin panel") + ap.add_argument("--mail-from", dest="mail_from", default="",""" +assert old in s; s = s.replace(old, new, 1) + +open(p, "w", encoding="utf-8").write(s) +print("plans/owner/admin patch applied") diff --git a/tools/smoke_test.py b/tools/smoke_test.py new file mode 100644 index 0000000..07a7e46 --- /dev/null +++ b/tools/smoke_test.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +""" +NetDesk smoke test - validates the full pipeline against a live server+host: + + connect -> challenge/auth (with E2E) -> accept -> H.264 stream decode -> PNG + + ping/pong, clipboard sync, quality change, monitor info, clean session end. + +Run: python tools/smoke_test.py [server_ws_url] +Assumes server on 127.0.0.1:8443 and a host already registered +(host's config.json supplies the ID + verifier). +""" + +import asyncio +import base64 +import hashlib +import json +import os +import sys +import time +from pathlib import Path + +import aiohttp +import av +import numpy as np +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "host")) +from netdesk_host import appdata_dir, clip_get, clip_set, totp_now # noqa: E402 + +SERVER = sys.argv[1] if len(sys.argv) > 1 else "ws://127.0.0.1:8443/ws" +OUT_PNG = Path(__file__).resolve().parent / "smoke_frame.png" +TEST_TOTP_SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP" + +# point this at a portable exe's folder to test the packaged build +_cfg_path = (Path(os.environ["ND_CONFIG_DIR"]) / "config.json") if os.environ.get("ND_CONFIG_DIR") \ + else (appdata_dir() / "config.json") +cfg = json.loads(_cfg_path.read_text("utf-8")) +HOST_ID = cfg["id"] +VERIFIER = cfg["verifier"] + +PASS, FAIL = [], [] + + +def check(name, ok, detail=""): + (PASS if ok else FAIL).append(name) + print(f" [{'PASS' if ok else 'FAIL'}] {name} {('- ' + str(detail)) if detail else ''}") + + +async def recv_until(ws, want_t, timeout=8, pred=None): + """Receive JSON messages until one matches t (and pred); skip others.""" + deadline = time.time() + timeout + while time.time() < deadline: + try: + msg = await ws.receive(timeout=max(0.1, deadline - time.time())) + except asyncio.TimeoutError: + break + if msg.type == aiohttp.WSMsgType.TEXT: + try: + m = json.loads(msg.data) + except Exception: + continue + if m.get("t") == want_t and (pred is None or pred(m)): + return m + return None + + +async def connect_and_auth(ws, totp_code=None, strict=True): + """session.begin -> challenge -> auth -> accept/deny. Returns + (sid, info_or_None, e2e_key_or_None, deny_reason).""" + await ws.send_json({"t": "session.begin", "id": HOST_ID, "direct": False}) + ch = await recv_until(ws, "session.challenge") + if strict: + check("session.begin -> challenge", ch is not None) + if not ch: + raise SystemExit(1) + check("challenge advertises 2FA", ch.get("totp") is bool(cfg.get("totp"))) + if not ch: + return None, None, None, "no challenge" + proof = hashlib.sha256((ch["nonce"] + VERIFIER).encode()).hexdigest() + priv = ec.generate_private_key(ec.SECP256R1()) + pub_b = priv.public_key().public_bytes( + serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint) + await ws.send_json({ + "t": "session.auth", "sid": ch["sid"], "proof": proof, + "pub": base64.b64encode(pub_b).decode(), + "totp": totp_code or "", + }) + # wait for accept or deny, keeping media messages out of the way + deadline = time.time() + 8 + deny_reason = None + while time.time() < deadline: + try: + msg = await ws.receive(timeout=max(0.1, deadline - time.time())) + except asyncio.TimeoutError: + break + if msg.type != aiohttp.WSMsgType.TEXT: + continue + m = json.loads(msg.data) + if m.get("t") == "session.accept": + host_pub = ec.EllipticCurvePublicKey.from_encoded_point( + ec.SECP256R1(), base64.b64decode(ch["pub"])) + shared = priv.exchange(ec.ECDH(), host_pub) + e2e = AESGCM(hashlib.sha256(shared + b"e2e").digest()) + return ch["sid"], m.get("info", {}), e2e, None + if m.get("t") == "session.deny": + deny_reason = m.get("reason") + break + return ch["sid"], None, None, deny_reason + + +async def main(): + print(f"smoke test against {SERVER} (host ID {HOST_ID})") + saved_clip = clip_get() + try: + # ---- shared login (viewer accounts required when mail is on) ------- + async with aiohttp.ClientSession() as http: + import sqlite3 + acct_db = Path(os.environ.get( + "ND_ACCOUNTS_DB", + str(Path(__file__).resolve().parents[1] / "server" / "accounts.db"))) + try: + c = sqlite3.connect(acct_db) + c.execute("CREATE TABLE IF NOT EXISTS users(id INTEGER PRIMARY KEY AUTOINCREMENT," + " email TEXT UNIQUE NOT NULL, pw_salt TEXT NOT NULL, pw_hash TEXT NOT NULL," + " verified INTEGER NOT NULL DEFAULT 0, verify_token TEXT," + " reset_token TEXT, reset_exp INTEGER, created INTEGER NOT NULL)") + salt = os.urandom(16).hex() + import hashlib as _hh + h = _hh.pbkdf2_hmac("sha256", b"testpass123", bytes.fromhex(salt), 200_000).hex() + c.execute("INSERT OR IGNORE INTO users(email,pw_salt,pw_hash,verified,created) " + "VALUES('smoketest@netdesk.space',?,?,1,strftime('%s','now'))", (salt, h)) + c.execute("UPDATE users SET verified=1, verify_token=NULL, " + "pw_salt=?, pw_hash=? WHERE email='smoketest@netdesk.space'", + (salt, h)) + c.commit(); c.close() + except Exception: + pass + base = SERVER.split("/ws")[0] + base = base.replace("wss://", "https://").replace("ws://", "http://") + try: + async with http.post(base + "/api/auth/login", + json={"email": "smoketest@netdesk.space", + "password": "testpass123"}, + allow_redirects=False) as lr: + USER_COOKIE = lr.headers.get("Set-Cookie", "").split(";")[0] + except Exception: + USER_COOKIE = "" + # 2FA wrong-code rejection test + if cfg.get("totp"): + async with http.ws_connect(SERVER, max_msg_size=64 * 1024 * 1024, + headers={"Cookie": USER_COOKIE}) as ws: + sid, info, key, reason = await connect_and_auth(ws, totp_code="000000") + check("2FA wrong code rejected", info is None and reason == "bad 2FA code", + str(reason)) + + async with aiohttp.ClientSession() as http: + async with http.ws_connect(SERVER, max_msg_size=64 * 1024 * 1024, + headers={"Cookie": USER_COOKIE}) as ws: + code = totp_now(cfg["totp"]) if cfg.get("totp") else None + sid, info, e2e_key, reason = await connect_and_auth(ws, totp_code=code) + check("session.auth -> accept", info is not None, reason) + if info is None: + raise SystemExit(1) + check("session info has dims", info.get("w", 0) > 0 and info.get("h", 0) > 0, + f"{info.get('w')}x{info.get('h')}") + check("monitors reported", len(info.get("monitors", [])) >= 1, + f"{len(info.get('monitors', []))} monitor(s)") + check("E2E negotiated", info.get("e2e") is True) + check("lock_on_end setting present", "lock_on_end" in info) + + # normalize state leaked from previous runs (never touches the + # display unless ND_DISPLAY_TESTS=1) + await ws.send_json({"t": "set.cfg", "sid": sid, "privacy_on_session": False, + "lock_on_end": False}) + await recv_until(ws, "sys.res", timeout=6, pred=lambda x: x.get("msg") == "config saved") + + # 3. ping/pong + t0 = time.time() + await ws.send_json({"t": "ping", "sid": sid, "c": t0}) + m = await recv_until(ws, "pong") + rtt = (time.time() - t0) * 1000 + check("ping/pong", m is not None, f"{rtt:.0f} ms") + check("control latency sane", rtt < 1500, f"{rtt:.0f} ms < 1500 ms") + + # 4. clipboard host->viewer (host polls and pushes changes) + marker = f"netdesk-smoke-{int(time.time())}" + clip_set(marker) + m = await recv_until(ws, "clip.set", timeout=6, + pred=lambda x: x.get("text") == marker) + check("clipboard host->viewer", m is not None) + + # 5. quality change -> expect session.info + await ws.send_json({"t": "set.quality", "sid": sid, + "fps": 15, "bitrate": 2000, "scale": 0.75}) + m = await recv_until(ws, "session.info", timeout=6) + check("quality change -> session.info", m is not None, + f"{m['info'].get('w')}x{m['info'].get('h')} @ {m['info'].get('fps')}fps" + if m else "timeout") + + # 6. collect + decode video (E2E decrypt each frame). + # Static screens correctly send nothing, so force keyframes. + dec = av.CodecContext.create("h264", "r") + frames = [] + video_msgs = 0 + video_bytes = 0 + for _ in range(30): + await ws.send_json({"t": "keyframe", "sid": sid}) + await asyncio.sleep(0.25) + for _ in range(20): + try: + msg = await ws.receive(timeout=0.15) + except asyncio.TimeoutError: + break + if msg.type != aiohttp.WSMsgType.BINARY: + continue + data = bytes(msg.data) + data = e2e_key.decrypt(data[:12], data[12:], None) + if not data or data[0] != 0x01: + continue + video_msgs += 1 + video_bytes += len(data) + try: + for f in dec.decode(av.Packet(data[2:])): + frames.append(f) + except Exception: + pass # waiting for keyframe + if len(frames) >= 3: + break + check("E2E video packets received", video_msgs >= 3, + f"{video_msgs} msgs, {video_bytes//1024} KB") + check("H.264 decodes", len(frames) >= 1, f"{len(frames)} frame(s)") + if frames: + img = frames[-1].to_image() + img.save(OUT_PNG) + arr = np.asarray(img.convert("L")) + check("frame not black", float(arr.std()) > 3.0, + f"std={arr.std():.1f} size={img.size}") + print(f" saved {OUT_PNG} ({img.size[0]}x{img.size[1]})") + + # 7. mouse move to current cursor position (harmless) + import ctypes + from ctypes import wintypes + pt = wintypes.POINT() + ctypes.windll.user32.GetCursorPos(ctypes.byref(pt)) + await ws.send_json({"t": "i.mouse", "sid": sid, + "x": pt.x, "y": pt.y, "b": 0, "w": 0, "hw": 0}) + await asyncio.sleep(0.3) + check("input event accepted", True) + + # 7b. process manager + await ws.send_json({"t": "sys.cmd", "sid": sid, "a": "processes"}) + m = await recv_until(ws, "sys.res", timeout=20, + pred=lambda x: x.get("kind") == "processes") + n_proc = len(m["items"]) if m and m.get("items") else 0 + check("process list", m is not None and m.get("ok") and n_proc > 3, + f"{n_proc} processes") + + # 7c/d. display-affecting tests are OPT-IN (ND_DISPLAY_TESTS=1) + # so the suite never blanks the screen of a machine in use + if os.environ.get("ND_DISPLAY_TESTS") == "1": + await ws.send_json({"t": "set.cfg", "sid": sid, "lock_on_end": False, + "privacy_on_session": True, "allow_display_power": True}) + m = await recv_until(ws, "sys.res", timeout=6, + pred=lambda x: x.get("msg") == "config saved") + check("set.cfg saved", m is not None and m.get("ok") is True) + await ws.send_json({"t": "sys.cmd", "sid": sid, "a": "privacy"}) + m = await recv_until(ws, "sys.res", timeout=8, + pred=lambda x: x.get("kind") == "privacy") + check("privacy mode on", m is not None and m.get("on") is True) + await ws.send_json({"t": "sys.cmd", "sid": sid, "a": "privacy"}) + m = await recv_until(ws, "sys.res", timeout=8, + pred=lambda x: x.get("kind") == "privacy") + check("privacy mode off", m is not None and m.get("on") is False) + await ws.send_json({"t": "set.cfg", "sid": sid, "privacy_on_session": False, + "allow_display_power": False}) + await recv_until(ws, "sys.res", timeout=6, + pred=lambda x: x.get("msg") == "config saved") + else: + # gate check: with display power disabled the host must refuse + await ws.send_json({"t": "sys.cmd", "sid": sid, "a": "privacy"}) + m = await recv_until(ws, "sys.res", timeout=8, + pred=lambda x: x.get("kind") == "privacy") + check("display control gated off by default", + m is not None and m.get("ok") is False, + str(m and m.get("msg"))) + await ws.send_json({"t": "set.cfg", "sid": sid, "lock_on_end": False, + "privacy_on_session": False}) + m = await recv_until(ws, "sys.res", timeout=6, + pred=lambda x: x.get("msg") == "config saved") + check("set.cfg saved", m is not None and m.get("ok") is True) + + # 7e. system audio: host should advertise it and stream Opus + check("audio advertised", info.get("audio") is True, str(info.get("audio"))) + audio_pkts = 0 + dec_a = av.CodecContext.create("opus", "r") + aframes = 0 + deadline = time.time() + 8 + while time.time() < deadline and audio_pkts < 5: + try: + msg = await ws.receive(timeout=1) + except asyncio.TimeoutError: + # nudge video traffic so the ws stays warm; audio flows regardless + await ws.send_json({"t": "keyframe", "sid": sid}) + continue + if msg.type != aiohttp.WSMsgType.BINARY: + continue + data = bytes(msg.data) + data = e2e_key.decrypt(data[:12], data[12:], None) + if data and data[0] == 0xA1: + audio_pkts += 1 + try: + aframes += len(dec_a.decode(av.Packet(data[1:]))) + except Exception: + pass + check("system audio packets received", audio_pkts >= 5, f"{audio_pkts} pkts") + check("audio decodes (Opus)", aframes >= 1, f"{aframes} frames") + + # 7f. TCP tunnel round trip: echo server on this PC, through + # server -> (broker) -> host -> TCP -> back + async def _handle(reader, writer): + try: + while True: + d = await reader.read(4096) + if not d: + break + writer.write(d) + await writer.drain() + finally: + writer.close() + + echo_srv = await asyncio.start_server(_handle, "127.0.0.1", 0) + echo_port = echo_srv.sockets[0].getsockname()[1] + + portal_pw = os.environ.get("ND_PORTAL_PW") or os.environ.get("PORTAL_PASSWORD", "portaltest123") + async with http.post(f"{SERVER.replace('/ws', '')}/portal/login".replace("ws://", "http://"), + data={"password": portal_pw}, + allow_redirects=False) as resp: + if resp.status != 303: + raise SystemExit(f"portal login failed: {resp.status}") + cookie = resp.headers.get("Set-Cookie", "").split(";")[0] + tunnel_ws = await http.ws_connect( + f"{SERVER.replace('/ws', '')}/tunnel/{HOST_ID}".replace("ws://", "http://"), + headers={"Cookie": cookie}) + await tunnel_ws.send_json({"t": "open", "port": echo_port}) + # wait for tunnel.ok or err + tok = None + deadline = time.time() + 10 + while time.time() < deadline and tok is None: + try: + msg = await tunnel_ws.receive(timeout=1) + except asyncio.TimeoutError: + continue + if msg.type == aiohttp.WSMsgType.TEXT: + m = json.loads(msg.data) + if m.get("t") in ("tunnel.ok", "tunnel.err"): + tok = m + check("tunnel opened", tok is not None and tok.get("t") == "tunnel.ok", str(tok)) + if tok and tok.get("t") == "tunnel.ok": + payload = b"netdesk-tunnel-ping-12345" + await tunnel_ws.send_bytes(payload) + got = b"" + deadline = time.time() + 8 + while time.time() < deadline and len(got) < len(payload): + try: + msg = await tunnel_ws.receive(timeout=1) + except asyncio.TimeoutError: + break + if msg.type == aiohttp.WSMsgType.BINARY: + got += msg.data + check("TCP tunnel round trip", got == payload, f"{len(got)}/{len(payload)} bytes") + await tunnel_ws.close() + echo_srv.close() + + # 8. clean end, then reconnect (auto-privacy reconnect test + # only when display tests are explicitly enabled) + await ws.send_json({"t": "session.end", "sid": sid}) + await asyncio.sleep(0.6) + sid2, info2, e2e2, reason2 = None, None, None, None + for attempt, wait in enumerate((0, 3, 6), start=1): + if attempt > 1: + await asyncio.sleep(wait) + sid2, info2, e2e2, reason2 = await connect_and_auth(ws, strict=False) + if info2 is not None: + break + check("reconnect works", info2 is not None, reason2) + if info2 is not None: + check("no display sleep without opt-in", + info2.get("privacy_active") is False, + str(info2.get("privacy_active"))) + await ws.send_json({"t": "session.end", "sid": sid2}) + await ws.send_json({"t": "session.end", "sid": sid}) + check("session.end sent", True) + finally: + if saved_clip is not None: + clip_set(saved_clip) + + print() + print(f"RESULT: {len(PASS)} passed, {len(FAIL)} failed") + if FAIL: + print("failed:", ", ".join(FAIL)) + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main())