NetDesk 1.5.0 — source release
This commit is contained in:
commit
40318aafdd
227 files changed
+80798
No files matched your search
@@ -0,0 +1,10 @@
|
||||
# Statically link the MinGW C/C++ runtime so our binaries are SELF-CONTAINED
|
||||
# (no libstdc++-6.dll / libgcc_s_seh-1.dll / libwinpthread-1.dll dependency).
|
||||
# This is required for the portable build and a clean installer — a user must be
|
||||
# able to run NetDesk without installing any MinGW runtime.
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
rustflags = [
|
||||
"-C", "link-arg=-static",
|
||||
"-C", "link-arg=-static-libgcc",
|
||||
"-C", "link-arg=-static-libstdc++",
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
Cargo.lock.bak
|
||||
# Flutter (Phase 3)
|
||||
/ui/build/
|
||||
/ui/.dart_tool/
|
||||
/ui/.flutter-plugins*
|
||||
# local secrets / test creds
|
||||
*.env
|
||||
.secrets
|
||||
akronlogic-codesign.cer
|
||||
|
||||
# packaging build outputs
|
||||
packaging/dist/
|
||||
|
||||
# Android generated native libs (built by cargo-ndk)
|
||||
ui/android/app/src/main/jniLibs/
|
||||
# Flutter build + local
|
||||
ui/build/
|
||||
ui/.dart_tool/
|
||||
ui/android/key.properties
|
||||
*.keystore
|
||||
Generated
+5758
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,41 @@
|
||||
# NetDesk — native cross-platform remote desktop client.
|
||||
# A true compiled Rust core (networking, E2E crypto, transport, codec, input)
|
||||
# with a Flutter UI layer on top (Windows / macOS / Linux / iOS / Android).
|
||||
# Developed by Akronlogic (https://akronlogic.com). Full IP ownership — no AGPL.
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["core", "cli", "viewer", "frb", "host", "vpx", "opus"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Akronlogic <https://akronlogic.com>"]
|
||||
license = "Proprietary"
|
||||
repository = ""
|
||||
|
||||
[workspace.dependencies]
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync", "io-util"] }
|
||||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
futures-util = "0.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
|
||||
p256 = { version = "0.13", features = ["ecdh"] }
|
||||
aes-gcm = "0.10"
|
||||
base64 = "0.22"
|
||||
hex = "0.4"
|
||||
anyhow = "1"
|
||||
thiserror = "1"
|
||||
rand = "0.8"
|
||||
openh264 = "0.6"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
@@ -0,0 +1,63 @@
|
||||
# NetDesk — Native Remote Desktop
|
||||
|
||||
A true native, cross-platform remote-desktop client. **Developed by [Akronlogic](https://akronlogic.com).**
|
||||
Full IP ownership — built from scratch, no AGPL/third-party copyleft.
|
||||
|
||||
> Goal: a premium, TeamViewer-grade experience (RustDesk is our open-source
|
||||
> *reference* for features and approach — we re-implement, we don't vendor it).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
netdesk-native/
|
||||
├── core/ Rust core library → the compiled engine (one codebase, all platforms)
|
||||
│ signaling · E2E crypto · media transport · H.264 codec · input
|
||||
│ exposes a C ABI (cdylib/staticlib) for the UI layer
|
||||
├── cli/ Headless test harness (how the core is tested in CI / during build-out)
|
||||
└── ui/ Flutter app (added in Phase 3) → Windows/macOS/Linux/iOS/Android UI
|
||||
```
|
||||
|
||||
Why Rust core + Flutter UI: it is the only stack that gives one native codebase
|
||||
across all five targets (desktop **and** mobile) with native performance —
|
||||
the same architecture RustDesk proved. The heavy, latency-critical work lives in
|
||||
Rust; the UI is Flutter calling in over the C ABI.
|
||||
|
||||
## Compatibility
|
||||
|
||||
The client speaks the **existing NetDesk server protocol** (the self-hosted
|
||||
signaling/relay at `netdesk.space`), so it interoperates with the current web
|
||||
viewer and host engine during the transition:
|
||||
|
||||
- `session.begin` → `session.challenge` → `session.auth` → `session.accept`
|
||||
- Auth: `verifier = PBKDF2-HMAC-SHA256(password, salt, iters)`,
|
||||
`proof = SHA256(nonce ‖ verifier)`
|
||||
- E2E: ECDH P-256 → `SHA256(shared.x ‖ "e2e")` → AES-256-GCM
|
||||
- Media: AES-GCM(`iv[12] ‖ ciphertext`), plaintext `= [chan][keyframe][H.264 Annex-B]`
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] **Phase 1 — Core network + crypto** (this commit): WSS signaling, full
|
||||
auth + E2E handshake, decrypted H.264 frame stream, headless CLI that
|
||||
verifies a live session against the real server. *Tested.*
|
||||
- [ ] **Phase 2 — Decode + render**: H.264 hardware decode (openh264/FFmpeg/
|
||||
platform decoders), frame pacing, a minimal native window showing the
|
||||
remote screen; input injection back to the host.
|
||||
- [ ] **Phase 3 — Flutter UI**: the NetDesk app shell (connect screen, session
|
||||
toolbar, address book), Akronlogic branding, over the Rust C ABI.
|
||||
- [ ] **Phase 4 — Transport**: WebRTC P2P (direct) + TURN, matching the server's
|
||||
3-tier path; adaptive bitrate.
|
||||
- [ ] **Phase 5 — Feature parity**: multi-monitor, file transfer, clipboard,
|
||||
audio, unattended access, auto-start service, auto-update.
|
||||
- [ ] **Phase 6 — Packaging + signing**: signed installers per platform
|
||||
(Akronlogic code-signing), store builds for iOS/Android.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
# Rust core + CLI (Windows uses the GNU toolchain + MinGW-w64 binutils)
|
||||
cargo build --release
|
||||
# verify against a live host:
|
||||
target/release/netdesk <host-id> <password> wss://netdesk.space/ws 10
|
||||
```
|
||||
|
||||
© Akronlogic. All rights reserved.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="ndg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#25d386"/><stop offset="1" stop-color="#16a765"/></linearGradient></defs><rect width="32" height="32" rx="8" fill="url(#ndg)"/><path d="M16 6.5L6.5 18.2C6.1 18.7 6.4 19.4 7 19.4H25C25.6 19.4 25.9 18.7 25.5 18.2L16 6.5Z" fill="#fff"/><path d="M11.8 21H20.2L21.5 26.4C21.6 26.8 21.3 27.2 20.9 27.2H11.1C10.7 27.2 10.4 26.8 10.5 26.4L11.8 21Z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 510 B |
@@ -0,0 +1,97 @@
|
||||
[package]
|
||||
name = "netdesk-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
description = "Headless NetDesk native client — connects to a host and verifies the pipeline (dev/test harness)."
|
||||
|
||||
[[bin]]
|
||||
name = "netdesk"
|
||||
path = "src/main.rs"
|
||||
|
||||
# End-to-end feature test harness: connects to the live host and verifies every
|
||||
# Session feature + the read-only host_ctl backend. See src/bin/e2e.rs.
|
||||
[[bin]]
|
||||
name = "netdesk-e2e"
|
||||
path = "src/bin/e2e.rs"
|
||||
|
||||
# TCP tunnel client: forward a local port through the relay to a port on the
|
||||
# remote host (SSH/VNC/RDP/DB over NetDesk). Portal-authed.
|
||||
[[bin]]
|
||||
name = "netdesk-tunnel"
|
||||
path = "src/bin/tunnel.rs"
|
||||
|
||||
# LAN discovery scanner: find NetDesk hosts on the local network.
|
||||
[[bin]]
|
||||
name = "netdesk-lan-scan"
|
||||
path = "src/bin/lan_scan.rs"
|
||||
|
||||
# Recursive folder download: pull a whole remote folder tree to a local dir.
|
||||
[[bin]]
|
||||
name = "netdesk-getdir"
|
||||
path = "src/bin/getdir.rs"
|
||||
|
||||
# Interactive terminal: run commands on the remote host's shell (ConPTY).
|
||||
[[bin]]
|
||||
name = "netdesk-term"
|
||||
path = "src/bin/term.rs"
|
||||
|
||||
# Voice send-test: capture this PC's mic and stream it to the host over
|
||||
# CHAN_VOICE (drives the exact native-viewer voice path, headlessly).
|
||||
[[bin]]
|
||||
name = "netdesk-voice-send"
|
||||
path = "src/bin/voice_send.rs"
|
||||
|
||||
# FPS probe: count received video frames per 1-second bucket (observes the
|
||||
# host's startup FPS ramp end to end).
|
||||
[[bin]]
|
||||
name = "netdesk-fps-probe"
|
||||
path = "src/bin/fps_probe.rs"
|
||||
|
||||
# Privacy-mode integration test: toggle privacy + verify sys.res and that the
|
||||
# capture keeps delivering frames through the blackout.
|
||||
[[bin]]
|
||||
name = "netdesk-privacy-test"
|
||||
path = "src/bin/privacy_test.rs"
|
||||
|
||||
# Direct-IP connect test: dial a host's IP:port directly (no relay) over
|
||||
# DirectTcp and verify the E2E session + video.
|
||||
[[bin]]
|
||||
name = "netdesk-direct"
|
||||
path = "src/bin/direct_connect.rs"
|
||||
|
||||
[dependencies]
|
||||
netdesk-core = { path = "../core" }
|
||||
# e2e decodes received Opus audio packets to measure real per-source energy
|
||||
# (RMS/peak) — proving system + mic audio actually transmit, not just that
|
||||
# packets arrive.
|
||||
netdesk-opus = { path = "../opus" }
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
# e2e.rs parses sys.res / session.info JSON and pulls in the viewer's host_ctl
|
||||
# module verbatim (via #[path]); host_ctl needs serde derive, serde_json, a
|
||||
# blocking HTTP client (ureq) and the clipboard crate it references.
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
ureq = { version = "2.10", features = ["json"] }
|
||||
arboard = "3"
|
||||
# netdesk-tunnel client: portal-login (ureq) then a raw tunnel WebSocket to
|
||||
# wss://server/tunnel/<hid>, piping a local TcpListener through it.
|
||||
tokio-tungstenite = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
|
||||
# e2e.rs verifies REAL OS-level input injection on the host (which, for the live
|
||||
# test, runs on this same machine): GetCursorPos confirms the mouse actually
|
||||
# moved, GetAsyncKeyState confirms a key actually registered. DPI-aware so the
|
||||
# readings share the host's physical pixel space.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# the e2e bin #[path]-includes viewer/src/host_ctl.rs, which drives the in-process
|
||||
# native host engine for the HOSTCTL checks.
|
||||
netdesk-host = { path = "../host" }
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
"Win32_UI_HiDpi",
|
||||
] }
|
||||
@@ -0,0 +1,80 @@
|
||||
//! NetDesk direct-IP connect test — dials a host's IP:port directly (no relay),
|
||||
//! runs the same challenge/auth + E2E over a raw TCP socket (DirectTcp), and
|
||||
//! verifies video decodes. Proves encrypted direct-IP access. Developed by
|
||||
//! Akronlogic.
|
||||
//!
|
||||
//! Usage: netdesk-direct <ip:port> <host_id> <password> [secs]
|
||||
|
||||
use netdesk_core::signaling::connect_with;
|
||||
use netdesk_core::transport::DirectTcp;
|
||||
use netdesk_core::{ConnectConfig, SessionEvent};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
if a.len() < 4 {
|
||||
eprintln!("usage: netdesk-direct <ip:port> <host_id> <password> [secs]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let addr = a[1].clone();
|
||||
let secs: u64 = a.get(4).and_then(|s| s.parse().ok()).unwrap_or(6);
|
||||
|
||||
eprintln!("[direct] dialing {addr} …");
|
||||
let stream = TcpStream::connect(&addr)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("tcp connect {addr}: {e}"))?;
|
||||
let transport = DirectTcp::new(stream);
|
||||
|
||||
let cfg = ConnectConfig {
|
||||
host_id: a[2].clone(),
|
||||
password: a[3].clone(),
|
||||
ws_url: String::new(), // unused: the transport is provided directly
|
||||
totp: String::new(),
|
||||
takeover: false,
|
||||
};
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
let session = connect_with(cfg, tx, Box::new(transport))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("connect_with: {e}"))?;
|
||||
eprintln!("[direct] connected — E2E handshake completed over raw TCP; waiting for video …");
|
||||
|
||||
let mut frames = 0u32;
|
||||
let mut e2e = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(secs + 4);
|
||||
loop {
|
||||
if Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
match tokio::time::timeout(Duration::from_millis(500), rx.recv()).await {
|
||||
Ok(Some(SessionEvent::Connected { host_name, e2e: ee, w, h })) => {
|
||||
e2e = ee;
|
||||
eprintln!("[direct] session.accept host='{host_name}' {w}x{h} e2e={ee}");
|
||||
}
|
||||
Ok(Some(SessionEvent::Video { .. })) => {
|
||||
frames += 1;
|
||||
if frames >= 5 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Some(SessionEvent::Denied { reason })) => {
|
||||
eprintln!("[direct] denied: {reason}");
|
||||
std::process::exit(3);
|
||||
}
|
||||
Ok(Some(SessionEvent::Ended { .. })) | Ok(None) => break,
|
||||
Ok(Some(_)) => {}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
session.close();
|
||||
|
||||
if frames > 0 {
|
||||
println!("DIRECT_E2E_PASS frames={frames} e2e={e2e} addr={addr}");
|
||||
Ok(())
|
||||
} else {
|
||||
println!("DIRECT_E2E_FAIL frames=0 e2e={e2e}");
|
||||
std::process::exit(4);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,73 @@
|
||||
//! NetDesk FPS probe — connects as a viewer and counts received video frames in
|
||||
//! 1-second buckets, so the host's startup FPS ramp (qos::startup_fps: begins at
|
||||
//! INIT_FPS, climbs to target over RAMP_MS) is observable end to end: the first
|
||||
//! buckets should be ~INIT_FPS and climb to the target. Developed by Akronlogic.
|
||||
//!
|
||||
//! Usage: netdesk-fps-probe <host_id> <password> [ws_url] [secs]
|
||||
|
||||
use netdesk_core::{connect, ConnectConfig, SessionEvent};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
if a.len() < 3 {
|
||||
eprintln!("usage: netdesk-fps-probe <host_id> <password> [ws_url] [secs]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let secs: usize = a.get(4).and_then(|s| s.parse().ok()).unwrap_or(6);
|
||||
let cfg = ConnectConfig {
|
||||
host_id: a[1].clone(),
|
||||
password: a[2].clone(),
|
||||
ws_url: a.get(3).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()),
|
||||
totp: String::new(),
|
||||
takeover: false,
|
||||
};
|
||||
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
eprintln!("[fps-probe] connecting to host {} …", cfg.host_id);
|
||||
let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
|
||||
|
||||
// Count Video frames per 1-second bucket, timed from the FIRST frame (so the
|
||||
// connect/keyframe latency before frame 1 doesn't skew bucket 0).
|
||||
let mut buckets: Vec<u32> = vec![0; secs + 2];
|
||||
let mut first: Option<Instant> = None;
|
||||
let hard_deadline = Instant::now() + Duration::from_secs(secs as u64 + 6);
|
||||
loop {
|
||||
if Instant::now() >= hard_deadline {
|
||||
break;
|
||||
}
|
||||
match tokio::time::timeout(Duration::from_millis(500), rx.recv()).await {
|
||||
Ok(Some(SessionEvent::Video { .. })) => {
|
||||
let t0 = *first.get_or_insert_with(Instant::now);
|
||||
let b = t0.elapsed().as_secs() as usize;
|
||||
if b < buckets.len() {
|
||||
buckets[b] += 1;
|
||||
}
|
||||
if t0.elapsed().as_secs() as usize >= secs {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Some(SessionEvent::Denied { reason })) => {
|
||||
eprintln!("[fps-probe] denied: {reason}");
|
||||
std::process::exit(3);
|
||||
}
|
||||
Ok(Some(SessionEvent::Ended { .. })) | Ok(None) => break,
|
||||
Ok(Some(_)) => {}
|
||||
Err(_) => {} // 500ms idle tick; keep waiting
|
||||
}
|
||||
}
|
||||
session.close();
|
||||
|
||||
if first.is_none() {
|
||||
println!("FPS_BUCKETS no-frames");
|
||||
std::process::exit(4);
|
||||
}
|
||||
print!("FPS_BUCKETS");
|
||||
for (i, c) in buckets.iter().take(secs + 1).enumerate() {
|
||||
print!(" t{i}s={c}");
|
||||
}
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! NetDesk recursive folder download — pull an entire remote folder tree to a
|
||||
//! local directory, preserving structure (RustDesk-style folder transfer). It
|
||||
//! asks the host for a recursive manifest (`file.walk`) then downloads each file
|
||||
//! into `<local_dir>/<relative path>`. Developed by Akronlogic.
|
||||
//!
|
||||
//! Usage:
|
||||
//! netdesk-getdir <host_id> <password> <remote_dir> <local_dir> [ws_url] [totp]
|
||||
|
||||
use netdesk_core::{connect, ConnectConfig, SessionEvent};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
if a.len() < 5 {
|
||||
eprintln!("usage: netdesk-getdir <host_id> <password> <remote_dir> <local_dir> [ws_url] [totp]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let cfg = ConnectConfig {
|
||||
host_id: a[1].clone(),
|
||||
password: a[2].clone(),
|
||||
ws_url: a.get(5).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()),
|
||||
totp: a.get(6).cloned().unwrap_or_default(),
|
||||
takeover: false,
|
||||
};
|
||||
let remote_dir = a[3].trim_end_matches(['\\', '/']).to_string();
|
||||
let local_dir = PathBuf::from(&a[4]);
|
||||
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
eprintln!("[getdir] connecting to host {} …", cfg.host_id);
|
||||
let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
|
||||
eprintln!("[getdir] connected; requesting manifest of {remote_dir}");
|
||||
session.walk(&remote_dir);
|
||||
|
||||
// Collect the manifest, bounded by an OVERALL deadline (the host streams
|
||||
// cursor/ping events, so a per-message timeout would never fire).
|
||||
let manifest = timeout(Duration::from_secs(45), async {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Some(SessionEvent::FileWalk { files, truncated, .. }) => return Some((files, truncated)),
|
||||
Some(SessionEvent::FileError { msg }) => {
|
||||
eprintln!("[getdir] host error: {msg}");
|
||||
return None;
|
||||
}
|
||||
Some(_) => continue,
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let (files, truncated) = match manifest {
|
||||
Ok(Some(x)) => x,
|
||||
_ => return Err(anyhow::anyhow!("no manifest from host (timed out)")),
|
||||
};
|
||||
eprintln!(
|
||||
"[getdir] {} file(s){}; downloading to {}",
|
||||
files.len(),
|
||||
if truncated { " (TRUNCATED — hit the host's cap)" } else { "" },
|
||||
local_dir.display()
|
||||
);
|
||||
|
||||
let mut ok = 0usize;
|
||||
let mut bytes_total = 0u64;
|
||||
for (rel, size) in &files {
|
||||
// Remote path: host is Windows → backslash-join under remote_dir.
|
||||
let remote_full = format!("{}\\{}", remote_dir, rel.replace('/', "\\"));
|
||||
let fid = session.download(&remote_full);
|
||||
// Wait for THIS file's completion, bounded by an overall per-file deadline
|
||||
// (unrelated streamed events must not keep us alive indefinitely).
|
||||
let done_path = timeout(Duration::from_secs(90), async {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Some(SessionEvent::FileDownloadDone { fid: f, path, .. }) if f == fid => {
|
||||
return Some(path)
|
||||
}
|
||||
Some(SessionEvent::FileError { msg }) => {
|
||||
eprintln!("[getdir] skip {rel}: {msg}");
|
||||
return None;
|
||||
}
|
||||
Some(_) => continue, // progress / cursor / ping — ignore
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
eprintln!("[getdir] timeout on {rel}");
|
||||
None
|
||||
});
|
||||
if let Some(tmp) = done_path {
|
||||
// Move the downloaded temp file into <local_dir>/<rel>.
|
||||
let target = local_dir.join(PathBuf::from(rel.replace('/', std::path::MAIN_SEPARATOR_STR)));
|
||||
if let Some(parent) = target.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
// rename across drives can fail → fall back to copy+remove.
|
||||
if std::fs::rename(&tmp, &target).is_err() {
|
||||
if std::fs::copy(&tmp, &target).is_ok() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
} else {
|
||||
eprintln!("[getdir] could not place {rel}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
ok += 1;
|
||||
bytes_total += size;
|
||||
}
|
||||
}
|
||||
|
||||
session.close();
|
||||
println!(
|
||||
"[getdir] done: {}/{} files, {} bytes -> {}",
|
||||
ok,
|
||||
files.len(),
|
||||
bytes_total,
|
||||
local_dir.display()
|
||||
);
|
||||
if ok != files.len() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! NetDesk LAN scan — discover NetDesk hosts on the local network by UDP
|
||||
//! broadcast (the viewer end of the host's discovery responder). Prints each
|
||||
//! host's id + name so you can connect by id. Developed by Akronlogic.
|
||||
//!
|
||||
//! Usage: netdesk-lan-scan [timeout_ms] (default 800ms)
|
||||
|
||||
use netdesk_core::lan;
|
||||
use std::time::Duration;
|
||||
|
||||
fn main() {
|
||||
let ms: u64 = std::env::args()
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(800);
|
||||
eprintln!("[lan] scanning for NetDesk hosts on udp/{} for {ms}ms…", lan::LAN_PORT);
|
||||
match lan::scan(&lan::broadcast_targets(), Duration::from_millis(ms)) {
|
||||
Ok(hosts) if hosts.is_empty() => {
|
||||
println!("no NetDesk hosts found on this LAN");
|
||||
}
|
||||
Ok(hosts) => {
|
||||
println!("found {} host(s):", hosts.len());
|
||||
for h in hosts {
|
||||
// addr.port() is 0 until direct-IP connect ships; the id is what
|
||||
// a viewer uses to connect through the relay today.
|
||||
println!(" id={} name=\"{}\" ip={}", h.id, h.name, h.addr.ip());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[lan] scan failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Privacy-mode integration test. Connects to a host, confirms video is
|
||||
//! streaming, toggles privacy ON (expects sys.res {kind:"privacy", on:true} and
|
||||
//! that frames keep flowing — i.e. the capture survives the blackout), then
|
||||
//! toggles it OFF (on:false). Developed by Akronlogic.
|
||||
//!
|
||||
//! Usage: netdesk-privacy-test <host_id> <password> [ws_url]
|
||||
|
||||
use netdesk_core::{connect, ConnectConfig, SessionEvent};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
type Rx = mpsc::UnboundedReceiver<SessionEvent>;
|
||||
|
||||
async fn wait_for_frame(rx: &mut Rx, frames: &mut u32, secs: u64) -> anyhow::Result<()> {
|
||||
let start = *frames;
|
||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||
while Instant::now() < deadline {
|
||||
match tokio::time::timeout(Duration::from_millis(500), rx.recv()).await {
|
||||
Ok(Some(SessionEvent::Video { .. })) => {
|
||||
*frames += 1;
|
||||
if *frames > start {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(Some(SessionEvent::Denied { reason })) => anyhow::bail!("denied: {reason}"),
|
||||
Ok(Some(SessionEvent::Ended { .. })) | Ok(None) => anyhow::bail!("session ended"),
|
||||
Ok(Some(_)) => {}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
anyhow::bail!("no video frame within {secs}s")
|
||||
}
|
||||
|
||||
async fn wait_privacy(rx: &mut Rx, frames: &mut u32, secs: u64) -> anyhow::Result<bool> {
|
||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||
while Instant::now() < deadline {
|
||||
match tokio::time::timeout(Duration::from_millis(500), rx.recv()).await {
|
||||
Ok(Some(SessionEvent::Video { .. })) => *frames += 1,
|
||||
Ok(Some(SessionEvent::SysResult { json })) => {
|
||||
let v: serde_json::Value = serde_json::from_str(&json).unwrap_or_default();
|
||||
if v.get("kind").and_then(|k| k.as_str()) == Some("privacy") {
|
||||
return Ok(v.get("on").and_then(|o| o.as_bool()).unwrap_or(false));
|
||||
}
|
||||
}
|
||||
Ok(Some(SessionEvent::Denied { reason })) => anyhow::bail!("denied: {reason}"),
|
||||
Ok(Some(SessionEvent::Ended { .. })) | Ok(None) => anyhow::bail!("session ended"),
|
||||
Ok(Some(_)) => {}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
anyhow::bail!("no privacy sys.res within {secs}s")
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
if a.len() < 3 {
|
||||
eprintln!("usage: netdesk-privacy-test <host_id> <password> [ws_url]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let cfg = ConnectConfig {
|
||||
host_id: a[1].clone(),
|
||||
password: a[2].clone(),
|
||||
ws_url: a.get(3).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()),
|
||||
totp: String::new(),
|
||||
takeover: false,
|
||||
};
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
eprintln!("[privacy-test] connecting to host {} …", cfg.host_id);
|
||||
let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
|
||||
|
||||
let mut frames = 0u32;
|
||||
wait_for_frame(&mut rx, &mut frames, 8).await?;
|
||||
eprintln!("[privacy-test] streaming confirmed; toggling privacy ON");
|
||||
|
||||
session.privacy();
|
||||
let on1 = wait_privacy(&mut rx, &mut frames, 8).await?;
|
||||
let before = frames;
|
||||
// Give the blackout a few seconds up; capture must keep delivering frames.
|
||||
let flowed = wait_for_frame(&mut rx, &mut frames, 5).await.is_ok();
|
||||
eprintln!("[privacy-test] privacy on={on1}; frames during privacy: {} (was {before})", frames);
|
||||
|
||||
session.privacy();
|
||||
let on2 = wait_privacy(&mut rx, &mut frames, 8).await?;
|
||||
eprintln!("[privacy-test] privacy on={on2} (toggled off)");
|
||||
|
||||
session.close();
|
||||
if on1 && !on2 && flowed {
|
||||
println!("PRIVACY_TEST_PASS on->true off->false frames_flowed_during_privacy=true total_frames={frames}");
|
||||
Ok(())
|
||||
} else {
|
||||
println!("PRIVACY_TEST_FAIL on1={on1} on2={on2} flowed={flowed}");
|
||||
std::process::exit(3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Tiny viewer that connects to a host and sends ONE tap (mouse down+up) at
|
||||
//! TAP_X,TAP_Y (in the host's capture pixel space). Used to verify Android-host
|
||||
//! input injection on BlueStacks: send a tap, then screencap to confirm effect.
|
||||
//! E2E_HOST_ID=.. E2E_PASSWORD=.. TAP_X=.. TAP_Y=.. tap-probe
|
||||
use netdesk_core::{connect, ConnectConfig, SessionEvent};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let id = std::env::var("E2E_HOST_ID").expect("E2E_HOST_ID");
|
||||
let pw = std::env::var("E2E_PASSWORD").expect("E2E_PASSWORD");
|
||||
let x: i32 = std::env::var("TAP_X").unwrap_or_else(|_| "480".into()).parse().unwrap();
|
||||
let y: i32 = std::env::var("TAP_Y").unwrap_or_else(|_| "800".into()).parse().unwrap();
|
||||
let swipe = std::env::var("SWIPE").ok(); // "x2,y2" -> drag instead of tap
|
||||
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
let cfg = ConnectConfig {
|
||||
ws_url: "wss://netdesk.space/ws".into(),
|
||||
host_id: id,
|
||||
password: pw,
|
||||
totp: String::new(),
|
||||
takeover: false,
|
||||
};
|
||||
let session = connect(cfg, tx).await.expect("connect");
|
||||
// drain until Connected
|
||||
let mut connected = false;
|
||||
for _ in 0..50 {
|
||||
if let Ok(Some(ev)) = tokio::time::timeout(Duration::from_millis(200), rx.recv()).await {
|
||||
if matches!(ev, SessionEvent::Connected { .. }) {
|
||||
connected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !connected {
|
||||
println!("TAP_PROBE_FAIL not connected");
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
|
||||
// press
|
||||
session.mouse(x, y, 1, 0.0, 0.0);
|
||||
if let Some(s) = swipe {
|
||||
// drag: move with button held to (x2,y2), then release
|
||||
let parts: Vec<i32> = s.split(',').filter_map(|v| v.trim().parse().ok()).collect();
|
||||
let (x2, y2) = (parts[0], parts[1]);
|
||||
for i in 1..=8 {
|
||||
let ix = x + (x2 - x) * i / 8;
|
||||
let iy = y + (y2 - y) * i / 8;
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
session.mouse(ix, iy, 1, 0.0, 0.0);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(40)).await;
|
||||
session.mouse(x2, y2, 0, 0.0, 0.0);
|
||||
println!("SWIPE_SENT {x},{y} -> {x2},{y2}");
|
||||
} else {
|
||||
tokio::time::sleep(Duration::from_millis(80)).await;
|
||||
session.mouse(x, y, 0, 0.0, 0.0); // release = tap
|
||||
println!("TAP_SENT {x},{y}");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
session.close();
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! NetDesk remote terminal client — run commands on the remote host's shell via
|
||||
//! the ConPTY terminal channel and stream the output back. Developed by Akronlogic.
|
||||
//!
|
||||
//! Usage:
|
||||
//! netdesk-term <host_id> <password> [ws_url] [totp]
|
||||
//! Input: set ND_TERM_CMD="<command>" to run one command, or pipe commands on
|
||||
//! stdin (e.g. `echo dir | netdesk-term <id> <pw>`). Output streams for a few
|
||||
//! seconds, then it exits. (A full raw-TTY interactive mode is a follow-on.)
|
||||
|
||||
use netdesk_core::{connect, ConnectConfig, SessionEvent};
|
||||
use std::io::{Read as _, Write as _};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
if a.len() < 3 {
|
||||
eprintln!("usage: netdesk-term <host_id> <password> [ws_url] [totp] (ND_TERM_CMD=cmd, or pipe stdin)");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let cfg = ConnectConfig {
|
||||
host_id: a[1].clone(),
|
||||
password: a[2].clone(),
|
||||
ws_url: a.get(3).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()),
|
||||
totp: a.get(4).cloned().unwrap_or_default(),
|
||||
takeover: false,
|
||||
};
|
||||
|
||||
// The command batch to run: ND_TERM_CMD, else whatever is piped on stdin.
|
||||
let input = if let Ok(cmd) = std::env::var("ND_TERM_CMD") {
|
||||
format!("{cmd}\r\n")
|
||||
} else {
|
||||
tokio::task::spawn_blocking(|| {
|
||||
let mut s = String::new();
|
||||
let _ = std::io::stdin().read_to_string(&mut s);
|
||||
// normalize bare \n to \r\n for the shell
|
||||
s.replace("\r\n", "\n").replace('\n', "\r\n")
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
eprintln!("[term] connecting to host {} …", cfg.host_id);
|
||||
let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
|
||||
let tid = 1u32;
|
||||
session.term_open(tid);
|
||||
eprintln!("[term] shell opened (tid {tid})");
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(700)).await; // let the shell print its prompt
|
||||
if !input.trim().is_empty() {
|
||||
session.term_data(tid, &input);
|
||||
}
|
||||
|
||||
// Stream output for a bounded window, echoing everything the shell emits.
|
||||
let _ = timeout(Duration::from_secs(6), async {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Some(SessionEvent::TermData { data, .. }) => {
|
||||
print!("{data}");
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
Some(SessionEvent::TermExit { .. }) | Some(SessionEvent::Ended { .. }) => break,
|
||||
Some(_) => continue,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
session.term_close(tid);
|
||||
session.close();
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! NetDesk TCP tunnel client — forward a local port through the relay to a port
|
||||
//! on the remote host (SSH/VNC/RDP/DB over NetDesk), the viewer end of the
|
||||
//! `/tunnel/<hid>` server endpoint the host now implements.
|
||||
//!
|
||||
//! Each accepted local connection opens its own `wss://server/tunnel/<hid>`
|
||||
//! WebSocket (portal-authed via the `ndportal` cookie), sends `{"t":"open",...}`
|
||||
//! and then pipes raw bytes both ways. The server asks the host to open the TCP
|
||||
//! connection and relays the stream.
|
||||
//!
|
||||
//! Usage:
|
||||
//! netdesk-tunnel <host_id> <local_port> <remote_port> [remote_host] \
|
||||
//! [wss://server/] [portal_password]
|
||||
//! Env fallbacks: ND_SERVER (https/wss base), ND_PORTAL_PASSWORD.
|
||||
//!
|
||||
//! Example — reach the remote host's RDP:
|
||||
//! netdesk-tunnel 123456789 13389 3389
|
||||
//! mstsc /v:127.0.0.1:13389
|
||||
//!
|
||||
//! Developed by Akronlogic.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::{ClientRequestBuilder, Message};
|
||||
|
||||
fn env_or(args: &[String], idx: usize, env: &str, default: &str) -> String {
|
||||
args.get(idx)
|
||||
.cloned()
|
||||
.or_else(|| std::env::var(env).ok())
|
||||
.unwrap_or_else(|| default.to_string())
|
||||
}
|
||||
|
||||
/// Portal-login over HTTP to obtain the `ndportal` session cookie the tunnel
|
||||
/// endpoint requires. Returns the raw cookie value (`ndportal=…`).
|
||||
fn portal_cookie(http_base: &str, password: &str) -> Result<String> {
|
||||
// The server replies 303 with Set-Cookie: ndportal=…; don't follow the
|
||||
// redirect so we can read the cookie off the response.
|
||||
let agent = ureq::builder().redirects(0).build();
|
||||
let resp = agent
|
||||
.post(&format!("{http_base}/portal/login"))
|
||||
.send_form(&[("password", password)]);
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(ureq::Error::Status(_code, r)) => r, // 303 is an "error" status to ureq
|
||||
Err(e) => return Err(anyhow!("portal login request failed: {e}")),
|
||||
};
|
||||
let set_cookie = resp
|
||||
.header("set-cookie")
|
||||
.ok_or_else(|| anyhow!("portal login did not set a cookie (wrong password?)"))?;
|
||||
// "ndportal=XYZ; HttpOnly; …" -> "ndportal=XYZ"
|
||||
let cookie = set_cookie
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if !cookie.starts_with("ndportal=") {
|
||||
return Err(anyhow!("unexpected portal cookie: {cookie}"));
|
||||
}
|
||||
Ok(cookie)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// `--rdp` forwards to the remote host's RDP port (3389) and launches the
|
||||
// local Windows RDP client at the forwarded port once it's up — RustDesk's
|
||||
// "New RDP" workflow. It makes remote_port optional (defaults to 3389).
|
||||
let mut raw: Vec<String> = std::env::args().collect();
|
||||
let rdp = raw.iter().any(|a| a == "--rdp");
|
||||
raw.retain(|a| a != "--rdp");
|
||||
let args = raw;
|
||||
if args.len() < 3 || (!rdp && args.len() < 4) {
|
||||
eprintln!(
|
||||
"usage: netdesk-tunnel <host_id> <local_port> <remote_port> \
|
||||
[remote_host] [server] [portal_password]\n\
|
||||
or: netdesk-tunnel --rdp <host_id> <local_port> [remote_host] [server] [portal_password]"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
let host_id = args[1].clone();
|
||||
let local_port: u16 = args[2].parse().context("local_port")?;
|
||||
// With --rdp the remote port is fixed at 3389 and the positional args shift
|
||||
// left by one (no remote_port argument).
|
||||
let (remote_port, shift) = if rdp {
|
||||
(3389u16, 0usize)
|
||||
} else {
|
||||
(args[3].parse().context("remote_port")?, 1usize)
|
||||
};
|
||||
let remote_host = env_or(&args, 3 + shift, "ND_TUNNEL_RHOST", "127.0.0.1");
|
||||
let server = env_or(&args, 4 + shift, "ND_SERVER", "wss://netdesk.space/ws");
|
||||
let password = env_or(&args, 5 + shift, "ND_PORTAL_PASSWORD", "");
|
||||
|
||||
// Normalize the server into an https base (for portal login) + a wss base.
|
||||
let https_base = server
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
.trim_end_matches("/ws")
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
let wss_base = https_base
|
||||
.replace("https://", "wss://")
|
||||
.replace("http://", "ws://");
|
||||
let tunnel_url = format!("{wss_base}/tunnel/{host_id}");
|
||||
|
||||
if password.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"portal password required (arg 6 or ND_PORTAL_PASSWORD) — tunnels are portal-authed"
|
||||
));
|
||||
}
|
||||
let cookie = Arc::new(portal_cookie(&https_base, &password)?);
|
||||
eprintln!("[tunnel] portal authed; forwarding 127.0.0.1:{local_port} -> host {host_id} :{remote_port} ({remote_host})");
|
||||
|
||||
let listener = TcpListener::bind(("127.0.0.1", local_port))
|
||||
.await
|
||||
.with_context(|| format!("bind 127.0.0.1:{local_port}"))?;
|
||||
eprintln!("[tunnel] listening on 127.0.0.1:{local_port} (Ctrl+C to stop)");
|
||||
|
||||
// --rdp: launch the local RDP client at the forwarded port (Windows mstsc).
|
||||
if rdp {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let target = format!("/v:127.0.0.1:{local_port}");
|
||||
match std::process::Command::new("mstsc").arg(&target).spawn() {
|
||||
Ok(_) => eprintln!("[tunnel] launched mstsc {target}"),
|
||||
Err(e) => eprintln!("[tunnel] could not launch mstsc ({e}); connect manually to 127.0.0.1:{local_port}"),
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
eprintln!("[tunnel] --rdp: connect your RDP client to 127.0.0.1:{local_port}");
|
||||
}
|
||||
|
||||
loop {
|
||||
let (sock, peer) = listener.accept().await?;
|
||||
let cookie = cookie.clone();
|
||||
let url = tunnel_url.clone();
|
||||
let rhost = remote_host.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_conn(sock, &url, &cookie, &rhost, remote_port).await {
|
||||
eprintln!("[tunnel] {peer}: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Pipe one accepted local TCP connection through its own tunnel WebSocket.
|
||||
async fn handle_conn(
|
||||
sock: tokio::net::TcpStream,
|
||||
tunnel_url: &str,
|
||||
cookie: &str,
|
||||
rhost: &str,
|
||||
rport: u16,
|
||||
) -> Result<()> {
|
||||
let uri: tokio_tungstenite::tungstenite::http::Uri =
|
||||
tunnel_url.parse().context("tunnel uri")?;
|
||||
let req = ClientRequestBuilder::new(uri).with_header("Cookie", cookie.to_string());
|
||||
let (ws, _resp) = connect_async(req).await.context("tunnel ws connect")?;
|
||||
let (mut ws_tx, mut ws_rx) = ws.split();
|
||||
|
||||
// Ask the host to open the remote TCP connection.
|
||||
ws_tx
|
||||
.send(Message::Text(
|
||||
serde_json::json!({"t":"open","host":rhost,"port":rport}).to_string(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
let (mut rd, mut wr) = sock.into_split();
|
||||
|
||||
// local socket -> ws (binary)
|
||||
let up = async move {
|
||||
let mut buf = vec![0u8; 32 * 1024];
|
||||
loop {
|
||||
match rd.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
if ws_tx.send(Message::Binary(buf[..n].to_vec())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = ws_tx.close().await;
|
||||
};
|
||||
|
||||
// ws -> local socket; tunnel.ok/err/closed are control text.
|
||||
let down = async move {
|
||||
while let Some(msg) = ws_rx.next().await {
|
||||
match msg {
|
||||
Ok(Message::Binary(b)) => {
|
||||
if wr.write_all(&b).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Message::Text(t)) => {
|
||||
if t.contains("tunnel.err") || t.contains("tunnel.closed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) | Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let _ = wr.shutdown().await;
|
||||
};
|
||||
|
||||
tokio::join!(up, down);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! NetDesk voice send-test — drives the EXACT path the native viewer's voice
|
||||
//! button uses (`netdesk_host::audio::run_audio` with source = microphone, and
|
||||
//! `Session::voice` per Opus packet), headlessly, so we can prove viewer->host
|
||||
//! two-way voice end to end against a scratch host. Developed by Akronlogic.
|
||||
//!
|
||||
//! Usage: netdesk-voice-send <host_id> <password> [ws_url] [secs]
|
||||
//! Watch the HOST log for `voice: rx N pkts from viewer mic, recent peak X`.
|
||||
|
||||
use netdesk_core::{connect, ConnectConfig, SessionEvent};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
if a.len() < 3 {
|
||||
eprintln!("usage: netdesk-voice-send <host_id> <password> [ws_url] [secs]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let secs: u64 = a.get(4).and_then(|s| s.parse().ok()).unwrap_or(6);
|
||||
let cfg = ConnectConfig {
|
||||
host_id: a[1].clone(),
|
||||
password: a[2].clone(),
|
||||
ws_url: a.get(3).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()),
|
||||
totp: String::new(),
|
||||
takeover: false,
|
||||
};
|
||||
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
eprintln!("[voice-send] connecting to host {} …", cfg.host_id);
|
||||
let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
|
||||
eprintln!("[voice-send] connected; capturing microphone and streaming CHAN_VOICE for {secs}s");
|
||||
|
||||
// Mirror viewer::toggle_voice exactly: share the Session behind a Mutex and
|
||||
// feed each captured Opus packet to Session::voice from the capture thread.
|
||||
let sess = Arc::new(Mutex::new(session));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let sent = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let s2 = sess.clone();
|
||||
let st2 = stop.clone();
|
||||
let n2 = sent.clone();
|
||||
std::thread::spawn(move || {
|
||||
let src = Arc::new(AtomicU8::new(2)); // 2 = microphone (default capture device)
|
||||
netdesk_host::audio::run_audio(st2, || true, src, move |opus| {
|
||||
s2.lock().unwrap().voice(&opus);
|
||||
n2.fetch_add(1, Ordering::Relaxed);
|
||||
});
|
||||
});
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
eprintln!("[voice-send] microphone capture is Windows-only");
|
||||
}
|
||||
|
||||
// Drain session events (ignore them) while the capture thread streams.
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep_until(deadline) => break,
|
||||
ev = rx.recv() => match ev {
|
||||
Some(SessionEvent::Ended { .. }) | None => break,
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
let n = sent.load(Ordering::Relaxed);
|
||||
sess.lock().unwrap().close();
|
||||
println!("VOICE_SEND_DONE sent={n} packets");
|
||||
if n == 0 {
|
||||
eprintln!("[voice-send] WARNING: 0 packets — no microphone/capture device?");
|
||||
std::process::exit(3);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! NetDesk native CLI — a headless end-to-end test harness for the Rust core.
|
||||
//! Connects to a host through the real signaling server, completes the E2E
|
||||
//! handshake, and streams video, reporting fps / keyframes / bitrate. This is
|
||||
//! how the core is tested on CI and during the build-out, before the UI exists.
|
||||
//!
|
||||
//! Usage:
|
||||
//! netdesk <host-id> <password> [ws-url] [seconds]
|
||||
//! Example:
|
||||
//! netdesk 123456789 'secret' wss://netdesk.space/ws 10
|
||||
|
||||
use netdesk_core::decode::best_decoder_for;
|
||||
use netdesk_core::{connect, ConnectConfig, SessionEvent};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() < 3 {
|
||||
eprintln!("usage: netdesk <host-id> <password> [ws-url] [seconds]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let host_id = args[1].clone();
|
||||
let password = args[2].clone();
|
||||
let ws_url = args
|
||||
.get(3)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "wss://netdesk.space/ws".to_string());
|
||||
let secs: u64 = args.get(4).and_then(|s| s.parse().ok()).unwrap_or(10);
|
||||
|
||||
println!("NetDesk native core v{} (by {})", netdesk_core::VERSION, netdesk_core::VENDOR);
|
||||
println!("connecting to host {host_id} via {ws_url} ...");
|
||||
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
let cfg = ConnectConfig {
|
||||
ws_url,
|
||||
host_id,
|
||||
password,
|
||||
totp: String::new(),
|
||||
takeover: false,
|
||||
};
|
||||
|
||||
let session = match connect(cfg, tx).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("connect failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// ask for an immediate keyframe so we start decoding fast
|
||||
session.request_keyframe();
|
||||
|
||||
// Report the monitors the host advertised (this is what drives the viewer's
|
||||
// monitor-switch UI). With `switch` as the 5th arg, flip to another monitor
|
||||
// mid-session to prove monitor.switch works end to end.
|
||||
let mons: Vec<_> = session.monitors().to_vec();
|
||||
let cur = session.current_monitor();
|
||||
println!(
|
||||
" monitors: {} [{}] current={cur}",
|
||||
mons.len(),
|
||||
mons.iter()
|
||||
.map(|m| format!("#{} {}x{}", m.i, m.w, m.h))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
let do_switch = args.get(5).map(|s| s == "switch").unwrap_or(false);
|
||||
let mut switched = false;
|
||||
|
||||
let start = Instant::now();
|
||||
let mut frames = 0u64;
|
||||
let mut keyframes = 0u64;
|
||||
let mut bytes = 0u64;
|
||||
let mut connected = false;
|
||||
let mut last_report = Instant::now();
|
||||
// Decode the stream to prove the pixels come through. Codec-aware: follows
|
||||
// the host's per-frame codec tag (H.264 / VP9), swapping on a keyframe.
|
||||
let mut cur_codec: u8 = 0;
|
||||
let mut decoder = best_decoder_for(cur_codec);
|
||||
let mut decoded = 0u64;
|
||||
let mut audio_pkts = 0u64;
|
||||
let mut audio_bytes = 0u64;
|
||||
let mut last_decoded_dim = (0usize, 0usize);
|
||||
let mut last_nonblack = 0u64; // non-black pixels in the last decoded frame
|
||||
|
||||
loop {
|
||||
let remaining = Duration::from_secs(secs).checked_sub(start.elapsed());
|
||||
let timeout = match remaining {
|
||||
Some(r) if !r.is_zero() => r,
|
||||
_ => break,
|
||||
};
|
||||
match tokio::time::timeout(timeout, rx.recv()).await {
|
||||
Ok(Some(ev)) => match ev {
|
||||
SessionEvent::Connecting => println!(" . connecting"),
|
||||
SessionEvent::Challenged { e2e } => println!(" . challenged (e2e={e2e})"),
|
||||
SessionEvent::Connected { host_name, e2e, w, h } => {
|
||||
connected = true;
|
||||
println!(" ✓ CONNECTED to \"{host_name}\" {w}x{h} e2e={e2e}");
|
||||
}
|
||||
SessionEvent::Video { keyframe, au, nals, codec } => {
|
||||
frames += 1;
|
||||
bytes += au.len() as u64;
|
||||
if keyframe {
|
||||
keyframes += 1;
|
||||
}
|
||||
// Live monitor-switch test: after a few frames, flip to a
|
||||
// different monitor and watch the stream follow.
|
||||
if do_switch && !switched && frames == 30 && mons.len() > 1 {
|
||||
let other = mons.iter().map(|m| m.i).find(|&i| i != cur).unwrap_or(cur);
|
||||
println!(" --> monitor.switch to #{other}");
|
||||
session.monitor_switch(other);
|
||||
switched = true;
|
||||
}
|
||||
if codec != cur_codec && keyframe {
|
||||
println!(" codec switch -> {}", if codec == 1 { "VP9" } else { "H.264" });
|
||||
decoder = best_decoder_for(codec);
|
||||
cur_codec = codec;
|
||||
}
|
||||
if codec == cur_codec {
|
||||
match decoder.decode(&au) {
|
||||
Ok(Some(frame)) => {
|
||||
decoded += 1;
|
||||
last_decoded_dim = (frame.w, frame.h);
|
||||
last_nonblack =
|
||||
frame.rgb.chunks_exact(3).filter(|p| p[0] | p[1] | p[2] != 0).count() as u64;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
if decoded == 0 && frames <= 3 {
|
||||
eprintln!(" decode error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if last_report.elapsed() >= Duration::from_secs(1) {
|
||||
let secs_f = start.elapsed().as_secs_f64().max(0.001);
|
||||
println!(
|
||||
" {:>4} rx | {:>4} decoded | {:>3} key | {:>5.1} fps | {:>6.2} Mbps | last {}x{} ({} NAL, {} lit px)",
|
||||
frames,
|
||||
decoded,
|
||||
keyframes,
|
||||
decoded as f64 / secs_f,
|
||||
(bytes as f64 * 8.0) / secs_f / 1_000_000.0,
|
||||
last_decoded_dim.0,
|
||||
last_decoded_dim.1,
|
||||
nals,
|
||||
last_nonblack
|
||||
);
|
||||
last_report = Instant::now();
|
||||
}
|
||||
}
|
||||
SessionEvent::Audio { data } => {
|
||||
audio_pkts += 1;
|
||||
audio_bytes += data.len() as u64;
|
||||
}
|
||||
SessionEvent::Denied { reason } => {
|
||||
eprintln!(" ✗ DENIED: {reason}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
SessionEvent::Ended { reason } => {
|
||||
println!(" session ended: {reason}");
|
||||
break;
|
||||
}
|
||||
// New control-protocol events are not exercised by this probe.
|
||||
_ => {}
|
||||
},
|
||||
Ok(None) => break,
|
||||
Err(_) => break, // overall timeout
|
||||
}
|
||||
}
|
||||
|
||||
session.close();
|
||||
let secs_f = start.elapsed().as_secs_f64().max(0.001);
|
||||
println!("\n==== RESULT ====");
|
||||
println!("connected : {connected}");
|
||||
println!("audio : {audio_pkts} Opus pkts ({audio_bytes} B)");
|
||||
println!("frames rx : {frames} ({:.1} fps)", frames as f64 / secs_f);
|
||||
println!("decoded : {decoded} frames (last {}x{}, {} lit px)", last_decoded_dim.0, last_decoded_dim.1, last_nonblack);
|
||||
println!("keyframes : {keyframes}");
|
||||
println!("video : {:.2} Mbps avg", (bytes as f64 * 8.0) / secs_f / 1_000_000.0);
|
||||
if connected && decoded > 0 && last_nonblack > 0 {
|
||||
println!("STATUS : OK — native core decoded live H.264 to real pixels ({}x{})", last_decoded_dim.0, last_decoded_dim.1);
|
||||
} else if connected && frames > 0 {
|
||||
println!("STATUS : stream received but not decoded (decoder issue)");
|
||||
} else if connected {
|
||||
println!("STATUS : CONNECTED but no frames (host idle? request keyframe?)");
|
||||
} else {
|
||||
println!("STATUS : FAILED to establish a session");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
[package]
|
||||
name = "netdesk-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
description = "NetDesk native remote-desktop core (signaling, E2E crypto, transport, codec). Developed by Akronlogic."
|
||||
|
||||
[lib]
|
||||
name = "netdesk_core"
|
||||
crate-type = ["lib", "staticlib"] # frb static-links core (rlib); the vestigial netdesk_core cdylib overflowed MinGW export ordinals once webrtc was linked
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
rustls = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
# TOTP (RFC 6238) 2FA on the native host — HMAC-SHA1 over the time counter.
|
||||
sha1 = "0.10"
|
||||
pbkdf2 = { workspace = true }
|
||||
p256 = { workspace = true }
|
||||
aes-gcm = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
openh264 = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
# Host-engine permission gates + approve flow log through the platform's logger
|
||||
# (env_logger in the Windows host, logcat via android_logger on Android).
|
||||
log = "0.4"
|
||||
# Session recording: repackage the received H.264 into a playable .mp4 (no re-encode).
|
||||
mp4 = "0.14"
|
||||
webrtc = "0.17.2"
|
||||
async-trait = "0.1"
|
||||
bytes = "1"
|
||||
|
||||
# Windows-only: Media Foundation hardware H.264 decode (DXVA via the system
|
||||
# H264 decoder MFT). Guarded behind cfg(windows); the core still builds on the
|
||||
# other four targets with openh264 software decode only. The "windows" crate
|
||||
# links the GNU import libraries from windows-targets, so it builds on the
|
||||
# x86_64-pc-windows-gnu toolchain used for the portable build.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# VP9 software decode (libvpx) for constrained links. Windows-only for now: the
|
||||
# vendored static libvpx targets the x86_64-pc-windows-gnu ABI.
|
||||
netdesk-vpx = { path = "../vpx" }
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Com",
|
||||
"Win32_Media_MediaFoundation",
|
||||
"Win32_Graphics_Direct3D",
|
||||
"Win32_Graphics_Direct3D11",
|
||||
"Win32_Graphics_Dxgi",
|
||||
"Win32_Graphics_Dxgi_Common",
|
||||
] }
|
||||
|
||||
# macOS / iOS: VideoToolbox hardware H.264 decode (decode_vt.rs). The module
|
||||
# links the system CoreMedia / CoreVideo / VideoToolbox frameworks directly and
|
||||
# uses core-foundation only to build the pixel-buffer attribute dictionary that
|
||||
# bridges into those calls. cfg-gated behind the Apple targets, so the Windows,
|
||||
# Linux and Android builds never reference these crates.
|
||||
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies]
|
||||
core-foundation = "0.10"
|
||||
core-foundation-sys = "0.8"
|
||||
|
||||
# Android: MediaCodec hardware H.264 decode (decode_mediacodec.rs) via the NDK
|
||||
# Media C ABI in libmediandk — the same ABI the ndk-sys crate binds. cfg-gated
|
||||
# behind target_os = "android".
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
ndk-sys = "0.6"
|
||||
|
||||
# Linux: VAAPI hardware H.264 decode (decode_vaapi.rs) through FFmpeg /
|
||||
# libavcodec. This pulls libav* in via ffmpeg-next and needs the system FFmpeg
|
||||
# (>= 4) development libraries present at build time — on Linux only; the crate
|
||||
# and its build script are never compiled for the other targets.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
ffmpeg-next = "9.0"
|
||||
@@ -0,0 +1,214 @@
|
||||
//! White-label support — a renamed executable self-configures. A reseller /
|
||||
//! MSP can ship a pre-pointed client WITHOUT rebuilding, just by renaming the
|
||||
//! exe so its name encodes the relay + registration key (RustDesk's
|
||||
//! `rustdesk-host=...,key=....exe` trick). The viewer reads its OWN filename at
|
||||
//! startup and writes the encoded settings into config.json.
|
||||
//!
|
||||
//! Format (filename stem): `<BrandName>-<k=v,k=v,...>`, e.g.
|
||||
//! `NetDesk-server=relay.corp.example,key=CORP1.exe`
|
||||
//! `AcmeRemote-server=vpn.acme.io,port=8443,tls=1,name=Acme,id=100200300.exe`
|
||||
//! Keys: `server` (bare host — filenames can't hold `://` or `:`, so the scheme
|
||||
//! and `/ws` path are reconstructed), `port`, `tls` (0/1), `key` (registration
|
||||
//! key), `id`, `name`. The part before the first recognised `key=` is the brand
|
||||
//! name (empty / "NetDesk" means default). Pure + deterministic for unit tests.
|
||||
|
||||
/// Settings decoded from an executable's filename.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct FilenameConfig {
|
||||
/// Product name from the filename prefix (None = default "NetDesk").
|
||||
pub brand: Option<String>,
|
||||
/// Full relay URL, reconstructed from the bare host (+ port/tls).
|
||||
pub server: Option<String>,
|
||||
pub id: Option<String>,
|
||||
pub key: Option<String>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl FilenameConfig {
|
||||
/// True when the filename carried any self-config settings.
|
||||
pub fn has_config(&self) -> bool {
|
||||
self.server.is_some() || self.id.is_some() || self.key.is_some() || self.name.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a filename STEM (no extension) into its encoded settings.
|
||||
pub fn parse_filename_config(stem: &str) -> FilenameConfig {
|
||||
const KEYS: &[&str] = &["server=", "id=", "key=", "name=", "port=", "tls="];
|
||||
// Find where the k=v block starts (the earliest recognised key).
|
||||
let start = KEYS.iter().filter_map(|k| stem.find(k)).min();
|
||||
let Some(start) = start else {
|
||||
return FilenameConfig::default(); // no config encoded (e.g. "NetDesk-Portable")
|
||||
};
|
||||
|
||||
let brand_raw = stem[..start].trim_end_matches('-').trim();
|
||||
let brand = if brand_raw.is_empty() || brand_raw.eq_ignore_ascii_case("netdesk") {
|
||||
None
|
||||
} else {
|
||||
Some(brand_raw.to_string())
|
||||
};
|
||||
|
||||
let mut out = FilenameConfig {
|
||||
brand,
|
||||
..Default::default()
|
||||
};
|
||||
let mut host: Option<String> = None;
|
||||
let mut port: Option<String> = None;
|
||||
let mut tls = true;
|
||||
for part in stem[start..].split(',') {
|
||||
let Some((k, v)) = part.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let v = v.trim().to_string();
|
||||
if v.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match k.trim() {
|
||||
"server" => host = Some(v),
|
||||
"port" => port = Some(v),
|
||||
"tls" => tls = !(v == "0" || v.eq_ignore_ascii_case("false") || v.eq_ignore_ascii_case("no")),
|
||||
"id" => out.id = Some(v),
|
||||
"key" => out.key = Some(v),
|
||||
"name" => out.name = Some(v),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Rebuild a full ws URL from the bare host (filenames can't contain :// or :).
|
||||
if let Some(h) = host {
|
||||
let scheme = if tls { "wss" } else { "ws" };
|
||||
let hostport = match port {
|
||||
Some(p) => format!("{h}:{p}"),
|
||||
None => h,
|
||||
};
|
||||
out.server = Some(format!("{scheme}://{hostport}/ws"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plain_name_has_no_config() {
|
||||
assert_eq!(parse_filename_config("NetDesk"), FilenameConfig::default());
|
||||
// "NetDesk-Portable" must NOT be mistaken for a config.
|
||||
assert_eq!(parse_filename_config("NetDesk-Portable"), FilenameConfig::default());
|
||||
assert!(!parse_filename_config("NetDesk").has_config());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_server_and_key_default_brand() {
|
||||
let c = parse_filename_config("NetDesk-server=relay.corp.example,key=CORP1");
|
||||
assert_eq!(c.server.as_deref(), Some("wss://relay.corp.example/ws"));
|
||||
assert_eq!(c.key.as_deref(), Some("CORP1"));
|
||||
assert_eq!(c.brand, None);
|
||||
assert!(c.has_config());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_brand_port_tls_name_id() {
|
||||
let c = parse_filename_config("AcmeRemote-server=vpn.acme.io,port=8443,tls=1,name=Acme HQ,id=100200300");
|
||||
assert_eq!(c.brand.as_deref(), Some("AcmeRemote"));
|
||||
assert_eq!(c.server.as_deref(), Some("wss://vpn.acme.io:8443/ws"));
|
||||
assert_eq!(c.name.as_deref(), Some("Acme HQ"));
|
||||
assert_eq!(c.id.as_deref(), Some("100200300"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_zero_gives_ws_scheme() {
|
||||
let c = parse_filename_config("NetDesk-server=192.168.1.10,tls=0");
|
||||
assert_eq!(c.server.as_deref(), Some("ws://192.168.1.10/ws"));
|
||||
}
|
||||
}
|
||||
|
||||
/// A runtime brand manifest (`brand.json` dropped next to the exe) for full
|
||||
/// white-labeling without a rebuild: product name, vendor, accent colors, a
|
||||
/// logo PNG (filename, loaded from next to the exe), and a default relay.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct BrandManifest {
|
||||
pub name: String,
|
||||
pub vendor: String,
|
||||
/// Primary accent as `#RRGGBB` (empty = keep the default).
|
||||
pub accent: String,
|
||||
pub accent_hi: String,
|
||||
/// Logo PNG filename, resolved next to the executable.
|
||||
pub logo: String,
|
||||
/// Default relay URL (same role as the filename `server=`).
|
||||
pub server: String,
|
||||
}
|
||||
|
||||
impl BrandManifest {
|
||||
/// The accent parsed to RGB, if a valid `#RRGGBB` was given.
|
||||
pub fn accent_rgb(&self) -> Option<(u8, u8, u8)> {
|
||||
parse_hex_rgb(&self.accent)
|
||||
}
|
||||
pub fn accent_hi_rgb(&self) -> Option<(u8, u8, u8)> {
|
||||
parse_hex_rgb(&self.accent_hi)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `brand.json`. Unknown/missing keys default to empty (a partial manifest
|
||||
/// is fine — only the fields present override). Invalid JSON -> all defaults.
|
||||
pub fn parse_brand_manifest(json: &str) -> BrandManifest {
|
||||
let v: serde_json::Value = serde_json::from_str(json).unwrap_or(serde_json::Value::Null);
|
||||
let s = |k: &str| {
|
||||
v.get(k)
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string()
|
||||
};
|
||||
BrandManifest {
|
||||
name: s("name"),
|
||||
vendor: s("vendor"),
|
||||
accent: s("accent"),
|
||||
accent_hi: s("accent_hi"),
|
||||
logo: s("logo"),
|
||||
server: s("server"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `#RRGGBB` (or `RRGGBB`) into (r, g, b).
|
||||
pub fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
|
||||
let s = s.trim().trim_start_matches('#');
|
||||
if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
|
||||
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
|
||||
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
|
||||
Some((r, g, b))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod manifest_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hex_parses_and_rejects() {
|
||||
assert_eq!(parse_hex_rgb("#22C55E"), Some((34, 197, 94)));
|
||||
assert_eq!(parse_hex_rgb("22c55e"), Some((34, 197, 94)));
|
||||
assert_eq!(parse_hex_rgb("#1E88E5"), Some((30, 136, 229)));
|
||||
assert_eq!(parse_hex_rgb("#12345"), None); // too short
|
||||
assert_eq!(parse_hex_rgb("#zzzzzz"), None); // not hex
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_parses_fields() {
|
||||
let json = r##"{"name":"AcmeRemote","vendor":"Acme Corp","accent":"#1E88E5",
|
||||
"logo":"acme.png","server":"wss://relay.acme.io/ws"}"##;
|
||||
let m = parse_brand_manifest(json);
|
||||
assert_eq!(m.name, "AcmeRemote");
|
||||
assert_eq!(m.vendor, "Acme Corp");
|
||||
assert_eq!(m.accent_rgb(), Some((30, 136, 229)));
|
||||
assert_eq!(m.logo, "acme.png");
|
||||
assert_eq!(m.server, "wss://relay.acme.io/ws");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_and_invalid_manifests() {
|
||||
assert_eq!(parse_brand_manifest(r#"{"name":"OnlyName"}"#).name, "OnlyName");
|
||||
assert_eq!(parse_brand_manifest("not json").name, "");
|
||||
assert_eq!(parse_brand_manifest(r#"{"name":"X"}"#).accent_rgb(), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! CIDR allow-list for encrypted direct-IP access. An empty list allows any
|
||||
//! peer (the admin opts into restriction by listing CIDRs); otherwise the peer
|
||||
//! IP must fall inside one of the comma/space/newline-separated CIDRs. Pure +
|
||||
//! deterministic, so it unit-tests without a socket. IPv4 and IPv6.
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
/// Is `ip` allowed by `list`? An empty/whitespace list allows everything (the
|
||||
/// permissive default); otherwise `ip` must match one entry. A bare address
|
||||
/// (no `/bits`) matches exactly (host route, /32 or /128).
|
||||
pub fn allowed(ip: &IpAddr, list: &str) -> bool {
|
||||
let mut any = false;
|
||||
for e in list.split(|c| c == ',' || c == ' ' || c == '\n' || c == '\t') {
|
||||
let e = e.trim();
|
||||
if e.is_empty() {
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
if cidr_match(ip, e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
!any // no entries -> allow all
|
||||
}
|
||||
|
||||
fn cidr_match(ip: &IpAddr, cidr: &str) -> bool {
|
||||
let (net_str, bits) = match cidr.split_once('/') {
|
||||
Some((n, b)) => (n, b.trim().parse::<u32>().ok()),
|
||||
None => (cidr, None),
|
||||
};
|
||||
let net: IpAddr = match net_str.trim().parse() {
|
||||
Ok(a) => a,
|
||||
Err(_) => return false,
|
||||
};
|
||||
match (ip, net) {
|
||||
(IpAddr::V4(ip), IpAddr::V4(net)) => {
|
||||
let bits = bits.unwrap_or(32).min(32);
|
||||
let mask: u32 = if bits == 0 { 0 } else { u32::MAX << (32 - bits) };
|
||||
(u32::from(*ip) & mask) == (u32::from(net) & mask)
|
||||
}
|
||||
(IpAddr::V6(ip), IpAddr::V6(net)) => {
|
||||
let bits = bits.unwrap_or(128).min(128);
|
||||
let mask: u128 = if bits == 0 { 0 } else { u128::MAX << (128 - bits) };
|
||||
(u128::from(*ip) & mask) == (u128::from(net) & mask)
|
||||
}
|
||||
_ => false, // v4 vs v6 never match
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::IpAddr;
|
||||
|
||||
fn ip(s: &str) -> IpAddr {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_list_allows_all() {
|
||||
assert!(allowed(&ip("8.8.8.8"), ""));
|
||||
assert!(allowed(&ip("10.0.0.1"), " "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_inside_cidr_blocks() {
|
||||
assert!(allowed(&ip("10.1.2.3"), "10.0.0.0/8"));
|
||||
assert!(!allowed(&ip("11.1.2.3"), "10.0.0.0/8"));
|
||||
assert!(allowed(&ip("192.168.1.50"), "192.168.1.0/24"));
|
||||
assert!(!allowed(&ip("192.168.2.50"), "192.168.1.0/24"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_entries_and_exact_host() {
|
||||
let list = "127.0.0.1, 10.0.0.0/8 192.168.0.0/16";
|
||||
assert!(allowed(&ip("127.0.0.1"), list)); // exact host
|
||||
assert!(allowed(&ip("10.9.9.9"), list));
|
||||
assert!(allowed(&ip("192.168.5.5"), list));
|
||||
assert!(!allowed(&ip("172.16.0.1"), list));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_and_v6_do_not_cross_match() {
|
||||
assert!(!allowed(&ip("10.0.0.1"), "::1/128"));
|
||||
assert!(allowed(&ip("::1"), "::1/128"));
|
||||
assert!(allowed(&ip("fe80::1"), "fe80::/16"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
//! End-to-end crypto matching the web viewer exactly, so the native client
|
||||
//! authenticates to and decrypts from the same hosts:
|
||||
//! verifier = hex( PBKDF2-HMAC-SHA256(password, salt, iters, 32) )
|
||||
//! proof = hex( SHA256( nonce_str || verifier ) )
|
||||
//! E2E key = SHA256( ECDH_P256(host_pub, our_priv).x || "e2e" ) -> AES-256-GCM
|
||||
//! frame = AES-GCM(iv = bytes[0..12], ct = bytes[12..])
|
||||
|
||||
use crate::error::{NdError, Result};
|
||||
use aes_gcm::aead::Aead;
|
||||
use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce};
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine};
|
||||
use p256::ecdh::EphemeralSecret;
|
||||
use p256::elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint};
|
||||
use p256::{EncodedPoint, PublicKey};
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Password proof the host verifies. `salt_b64` and `nonce` come from the challenge.
|
||||
pub fn password_proof(password: &str, salt_b64: &str, nonce: &str, iters: u32) -> Result<String> {
|
||||
let salt = B64
|
||||
.decode(salt_b64)
|
||||
.map_err(|e| NdError::Crypto(format!("salt b64: {e}")))?;
|
||||
let mut dk = [0u8; 32];
|
||||
pbkdf2::pbkdf2_hmac::<Sha256>(password.as_bytes(), &salt, iters, &mut dk);
|
||||
let verifier = hex::encode(dk);
|
||||
let mut h = Sha256::new();
|
||||
h.update(nonce.as_bytes());
|
||||
h.update(verifier.as_bytes());
|
||||
Ok(hex::encode(h.finalize()))
|
||||
}
|
||||
|
||||
/// Channel-bound password proof (MITM hardening). Binds the host's ephemeral
|
||||
/// ECDH public key into the proof: `hex(SHA256(nonce || verifier || host_pub))`.
|
||||
/// A malicious relay that swaps the host's `pub` in the challenge can't produce
|
||||
/// a matching proof (it lacks the verifier), and the host verifies against ITS
|
||||
/// OWN pub — so a key swap is detected and auth fails. `host_pub_b64` is the
|
||||
/// exact base64 string the host put in `session.challenge` `pub`.
|
||||
pub fn password_proof_bound(
|
||||
password: &str,
|
||||
salt_b64: &str,
|
||||
nonce: &str,
|
||||
iters: u32,
|
||||
host_pub_b64: &str,
|
||||
) -> Result<String> {
|
||||
let salt = B64
|
||||
.decode(salt_b64)
|
||||
.map_err(|e| NdError::Crypto(format!("salt b64: {e}")))?;
|
||||
let mut dk = [0u8; 32];
|
||||
pbkdf2::pbkdf2_hmac::<Sha256>(password.as_bytes(), &salt, iters, &mut dk);
|
||||
let verifier = hex::encode(dk);
|
||||
Ok(bound_proof_from_verifier(nonce, &verifier, host_pub_b64))
|
||||
}
|
||||
|
||||
/// Shared core of the bound proof, from an already-computed `verifier_hex`.
|
||||
/// Used by the viewer (via [`password_proof_bound`]) and the host verify side
|
||||
/// (which stores `verifier_hex`), so both compute the digest identically.
|
||||
pub fn bound_proof_from_verifier(nonce: &str, verifier_hex: &str, host_pub_b64: &str) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(nonce.as_bytes());
|
||||
h.update(verifier_hex.as_bytes());
|
||||
h.update(host_pub_b64.as_bytes());
|
||||
hex::encode(h.finalize())
|
||||
}
|
||||
|
||||
/// HOST side: verify a viewer's channel-bound proof against the host's OWN
|
||||
/// ephemeral pubkey. `host_pub_b64` is what the host sent in the challenge.
|
||||
/// A relay that swapped the pub can't satisfy this.
|
||||
pub fn verify_proof_bound(
|
||||
nonce: &str,
|
||||
verifier_hex: &str,
|
||||
host_pub_b64: &str,
|
||||
proof: &str,
|
||||
) -> bool {
|
||||
let want = bound_proof_from_verifier(nonce, verifier_hex, host_pub_b64);
|
||||
ct_eq(want.as_bytes(), proof.to_lowercase().as_bytes())
|
||||
}
|
||||
|
||||
/// Our ephemeral ECDH keypair + the derived AES-256-GCM session key.
|
||||
pub struct E2e {
|
||||
pub our_pub_b64: String,
|
||||
pub cipher: Aes256Gcm,
|
||||
}
|
||||
|
||||
/// Derive the end-to-end key from the host's public key (base64 raw, 65-byte point).
|
||||
pub fn derive_e2e(host_pub_b64: &str) -> Result<E2e> {
|
||||
let host_raw = B64
|
||||
.decode(host_pub_b64)
|
||||
.map_err(|e| NdError::Crypto(format!("host pub b64: {e}")))?;
|
||||
let host_pt = EncodedPoint::from_bytes(&host_raw)
|
||||
.map_err(|e| NdError::Crypto(format!("host pub point: {e}")))?;
|
||||
let host_pub = Option::from(PublicKey::from_encoded_point(&host_pt))
|
||||
.ok_or_else(|| NdError::Crypto("invalid host public key".into()))?;
|
||||
|
||||
let secret = EphemeralSecret::random(&mut rand::rngs::OsRng);
|
||||
let our_pub = secret.public_key().to_encoded_point(false); // uncompressed 0x04||X||Y
|
||||
let our_pub_b64 = B64.encode(our_pub.as_bytes());
|
||||
|
||||
let shared = secret.diffie_hellman(&host_pub);
|
||||
let mut h = Sha256::new();
|
||||
h.update(shared.raw_secret_bytes()); // the x-coordinate (32 bytes), as WebCrypto deriveBits
|
||||
h.update(b"e2e");
|
||||
let key_bytes = h.finalize();
|
||||
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key_bytes));
|
||||
|
||||
Ok(E2e {
|
||||
our_pub_b64,
|
||||
cipher,
|
||||
})
|
||||
}
|
||||
|
||||
/// Decrypt one binary frame (iv is the first 12 bytes; the rest is ciphertext+tag).
|
||||
pub fn decrypt_frame(cipher: &Aes256Gcm, data: &[u8]) -> Result<Vec<u8>> {
|
||||
if data.len() < 12 + 16 {
|
||||
return Err(NdError::Crypto("frame too short".into()));
|
||||
}
|
||||
let nonce = Nonce::from_slice(&data[..12]);
|
||||
cipher
|
||||
.decrypt(nonce, &data[12..])
|
||||
.map_err(|_| NdError::Crypto("GCM decrypt failed".into()))
|
||||
}
|
||||
|
||||
/// Encrypt one binary frame for the wire: `iv(12) || AES-GCM(plaintext)`.
|
||||
/// The HOST side of [`decrypt_frame`] — the Android host uses this to seal its
|
||||
/// H.264 access units exactly the way the Python engine does (12-byte random
|
||||
/// nonce prefix), so the same web/desktop/native viewers decrypt them.
|
||||
pub fn encrypt_frame(cipher: &Aes256Gcm, plaintext: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut iv = [0u8; 12];
|
||||
rand::rngs::OsRng.fill_bytes(&mut iv);
|
||||
let nonce = Nonce::from_slice(&iv);
|
||||
let ct = cipher
|
||||
.encrypt(nonce, plaintext)
|
||||
.map_err(|_| NdError::Crypto("GCM encrypt failed".into()))?;
|
||||
let mut out = Vec::with_capacity(12 + ct.len());
|
||||
out.extend_from_slice(&iv);
|
||||
out.extend_from_slice(&ct);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Constant-time byte compare (mirrors Python's `secrets.compare_digest`).
|
||||
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut d = 0u8;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
d |= x ^ y;
|
||||
}
|
||||
d == 0
|
||||
}
|
||||
|
||||
/// HOST side of the auth handshake: verify a viewer's password proof.
|
||||
/// `proof` is what the viewer sent; it must equal `hex(SHA256(nonce || verifier))`
|
||||
/// where `verifier` is the host's stored `verifier_hex`. Matches the Python
|
||||
/// host's `Config.check_proof`.
|
||||
pub fn verify_proof(nonce: &str, verifier_hex: &str, proof: &str) -> bool {
|
||||
let mut h = Sha256::new();
|
||||
h.update(nonce.as_bytes());
|
||||
h.update(verifier_hex.as_bytes());
|
||||
let want = hex::encode(h.finalize());
|
||||
ct_eq(want.as_bytes(), proof.to_lowercase().as_bytes())
|
||||
}
|
||||
|
||||
/// HOST side of the E2E ECDH. Created when the host sends `session.challenge`
|
||||
/// (exposing [`our_pub_b64`] in the challenge), then consumed at `session.auth`
|
||||
/// via [`derive`] with the viewer's public key to yield the shared AES-256-GCM
|
||||
/// cipher. Mirror of [`derive_e2e`] (the viewer side): both sides compute
|
||||
/// `SHA256( ECDH_P256(x) || "e2e" )`, so they end up with the SAME key.
|
||||
pub struct HostE2e {
|
||||
pub our_pub_b64: String,
|
||||
secret: EphemeralSecret,
|
||||
}
|
||||
|
||||
impl HostE2e {
|
||||
/// Generate a fresh ephemeral keypair for one challenge.
|
||||
pub fn new() -> Self {
|
||||
let secret = EphemeralSecret::random(&mut rand::rngs::OsRng);
|
||||
let our_pub = secret.public_key().to_encoded_point(false); // 0x04||X||Y
|
||||
let our_pub_b64 = B64.encode(our_pub.as_bytes());
|
||||
HostE2e {
|
||||
our_pub_b64,
|
||||
secret,
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the AES-256-GCM session cipher from the viewer's public key
|
||||
/// (base64 raw 65-byte point). Consumes the keypair (used once).
|
||||
pub fn derive(self, viewer_pub_b64: &str) -> Result<Aes256Gcm> {
|
||||
let raw = B64
|
||||
.decode(viewer_pub_b64)
|
||||
.map_err(|e| NdError::Crypto(format!("viewer pub b64: {e}")))?;
|
||||
let pt = EncodedPoint::from_bytes(&raw)
|
||||
.map_err(|e| NdError::Crypto(format!("viewer pub point: {e}")))?;
|
||||
let viewer_pub = Option::from(PublicKey::from_encoded_point(&pt))
|
||||
.ok_or_else(|| NdError::Crypto("invalid viewer public key".into()))?;
|
||||
let shared = self.secret.diffie_hellman(&viewer_pub);
|
||||
let mut h = Sha256::new();
|
||||
h.update(shared.raw_secret_bytes());
|
||||
h.update(b"e2e");
|
||||
let key_bytes = h.finalize();
|
||||
Ok(Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key_bytes)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HostE2e {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn host_viewer_e2e_agree_and_frames_roundtrip() {
|
||||
// Host sends its pub in the challenge; viewer derives against it.
|
||||
let host = HostE2e::new();
|
||||
let host_pub = host.our_pub_b64.clone();
|
||||
let viewer = derive_e2e(&host_pub).expect("viewer derive");
|
||||
// Host derives against the viewer's pub -> both keys must match.
|
||||
let host_cipher = host.derive(&viewer.our_pub_b64).expect("host derive");
|
||||
|
||||
// Host-sealed frame decrypts on the viewer (the real media direction).
|
||||
let au = b"\x00\x00\x00\x01fake annex-b access unit";
|
||||
let sealed = encrypt_frame(&host_cipher, au).expect("seal");
|
||||
let opened = decrypt_frame(&viewer.cipher, &sealed).expect("open");
|
||||
assert_eq!(&opened, au);
|
||||
|
||||
// And the reverse (viewer-sealed -> host opens), proving identical keys.
|
||||
let sealed2 = encrypt_frame(&viewer.cipher, au).expect("seal2");
|
||||
let opened2 = decrypt_frame(&host_cipher, &sealed2).expect("open2");
|
||||
assert_eq!(&opened2, au);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totp_matches_python_and_rfc6238() {
|
||||
// RFC 6238 SHA-1 test secret ("12345678901234567890" base32). These are
|
||||
// the exact codes the shipping Python host's totp_now() produced — so
|
||||
// Rust ≡ Python ≡ any authenticator app.
|
||||
let s = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
|
||||
assert_eq!(totp_at(s, 59, 30, 6).unwrap(), "287082");
|
||||
assert_eq!(totp_at(s, 1_111_111_109, 30, 6).unwrap(), "081804");
|
||||
assert_eq!(totp_at(s, 1_234_567_890, 30, 6).unwrap(), "005924");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totp_verify_window_and_reject() {
|
||||
let s = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
|
||||
// A non-numeric / empty code is always rejected.
|
||||
assert!(!totp_verify(s, "", 1));
|
||||
assert!(!totp_verify(s, "abcdef", 1));
|
||||
// The helper that drives verify accepts the matching code for a step and
|
||||
// rejects a wrong one (tested time-independently via totp_at parity).
|
||||
let now_code = totp_at(s, 1_000_000_020, 30, 6).unwrap();
|
||||
let wrong = if now_code == "000000" { "111111" } else { "000000" };
|
||||
assert_ne!(now_code, wrong);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_proof_known_answer_cross_language() {
|
||||
// Shared known-answer vector: Rust, the web viewer (JS sha256Hex), and
|
||||
// the Python host all compute hex(SHA256(nonce||verifier||host_pub)) over
|
||||
// the same UTF-8 string, so this one hex proves all three agree.
|
||||
// (Python produced the identical digest for these inputs.)
|
||||
let got = bound_proof_from_verifier("abc123", "deadbeef", "AAAA_host_pub_b64");
|
||||
assert_eq!(
|
||||
got,
|
||||
"ecc84429d061a1580268f829aa3da0d046f1d6aa8a1c6e86ad46c8deff5d835f"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_proof_detects_pub_swap() {
|
||||
let nonce = "0123456789abcdef0123456789abcdef";
|
||||
let password = "correct horse battery staple";
|
||||
// Salt + verifier MUST come from the same credentials (new_host_credentials
|
||||
// picks a random salt), so the viewer's bound proof and the host's verify
|
||||
// use the same verifier — exactly as a real host stores them.
|
||||
let (salt_b64, iters, verifier) = new_host_credentials(password);
|
||||
// The host's real ephemeral pub vs. a relay-swapped one.
|
||||
let real = HostE2e::new();
|
||||
let swapped = HostE2e::new();
|
||||
// A viewer binding the REAL pub verifies; one binding the swapped pub does not.
|
||||
let good = password_proof_bound(password, &salt_b64, nonce, iters, &real.our_pub_b64)
|
||||
.expect("bound proof");
|
||||
assert!(verify_proof_bound(nonce, &verifier, &real.our_pub_b64, &good));
|
||||
let swapped_proof =
|
||||
password_proof_bound(password, &salt_b64, nonce, iters, &swapped.our_pub_b64)
|
||||
.expect("bound proof");
|
||||
assert!(
|
||||
!verify_proof_bound(nonce, &verifier, &real.our_pub_b64, &swapped_proof),
|
||||
"a proof bound to a swapped pub must NOT verify against the real pub"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_verifies_against_viewer_proof() {
|
||||
let salt = gen_salt();
|
||||
let salt_b64 = B64.encode(salt);
|
||||
let iters = 200_000u32;
|
||||
let nonce = "0123456789abcdef0123456789abcdef";
|
||||
let verifier = verifier_hex("hunter2", &salt, iters);
|
||||
// Viewer computes the proof the way password_proof does.
|
||||
let proof = password_proof("hunter2", &salt_b64, nonce, iters).expect("proof");
|
||||
assert!(verify_proof(nonce, &verifier, &proof), "valid proof accepted");
|
||||
assert!(
|
||||
!verify_proof(nonce, &verifier, &"00".repeat(32)),
|
||||
"wrong proof rejected"
|
||||
);
|
||||
// Wrong password must not verify.
|
||||
let bad = password_proof("wrong", &salt_b64, nonce, iters).expect("bad proof");
|
||||
assert!(!verify_proof(nonce, &verifier, &bad), "wrong password rejected");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host unattended-password credentials. These mirror the Python host engine's
|
||||
// `make_verifier` / `gen_password` (netdesk_host.py) EXACTLY so a verifier the
|
||||
// native panel writes to config.json is accepted by the same engine:
|
||||
// verifier = hex( PBKDF2-HMAC-SHA256(password, salt, iters=200000, dklen=32) )
|
||||
// In config.json `salt` and `verifier` are stored hex-encoded, `iters` = 200000.
|
||||
// (The viewer's host-control module reuses these instead of pulling pbkdf2/rand
|
||||
// into the viewer crate.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// PBKDF2-HMAC-SHA256 verifier (hex) over RAW salt bytes. Matches the host
|
||||
/// engine's `make_verifier` and the `verifier` field in config.json.
|
||||
pub fn verifier_hex(password: &str, salt: &[u8], iters: u32) -> String {
|
||||
let mut dk = [0u8; 32];
|
||||
pbkdf2::pbkdf2_hmac::<Sha256>(password.as_bytes(), salt, iters, &mut dk);
|
||||
hex::encode(dk)
|
||||
}
|
||||
|
||||
/// A fresh random 16-byte salt (matches the host `make_verifier` salt length).
|
||||
pub fn gen_salt() -> [u8; 16] {
|
||||
let mut s = [0u8; 16];
|
||||
rand::rngs::OsRng.fill_bytes(&mut s);
|
||||
s
|
||||
}
|
||||
|
||||
/// Fresh credentials for `password`: returns `(salt_hex, iters, verifier_hex)`
|
||||
/// ready to drop into config.json's `salt` / `iters` / `verifier` fields.
|
||||
pub fn new_password_record(password: &str) -> (String, u32, String) {
|
||||
let salt = gen_salt();
|
||||
let iters = 200_000u32;
|
||||
let verifier = verifier_hex(password, &salt, iters);
|
||||
(hex::encode(salt), iters, verifier)
|
||||
}
|
||||
|
||||
/// Fresh host credentials ready to drop into [`crate::host::HostConfig`]:
|
||||
/// `(salt_b64, iters, verifier_hex)` where `salt_b64` is base64 of the RAW salt
|
||||
/// (what `session.challenge` carries) and `verifier_hex` checks viewer proofs.
|
||||
pub fn new_host_credentials(password: &str) -> (String, u32, String) {
|
||||
let salt = gen_salt();
|
||||
let iters = 200_000u32;
|
||||
let verifier = verifier_hex(password, &salt, iters);
|
||||
(B64.encode(salt), iters, verifier)
|
||||
}
|
||||
|
||||
/// One-click unattended password, no look-alike characters (l/1, o/0) —
|
||||
/// identical alphabet to the host engine's `gen_password`. The alphabet is 32
|
||||
/// chars, so `u32 % 32` is perfectly uniform (2^32 is divisible by 32).
|
||||
pub fn gen_password(n: usize) -> String {
|
||||
const ALPHABET: &[u8] = b"abcdefghijkmnpqrstuvwxyz23456789";
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
(0..n)
|
||||
.map(|_| ALPHABET[(rng.next_u32() as usize) % ALPHABET.len()] as char)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A fresh base32 TOTP secret (32 chars, A–Z + 2–7) — matches the host engine's
|
||||
/// 2FA secret generation (`string.ascii_uppercase + "234567"`).
|
||||
pub fn gen_totp_secret() -> String {
|
||||
const B32: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
(0..32)
|
||||
.map(|_| B32[(rng.next_u32() as usize) % B32.len()] as char)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Decode an RFC 4648 base32 secret (A–Z, 2–7; padding + case tolerated), as the
|
||||
/// TOTP secret is stored. Returns None on an invalid character.
|
||||
fn b32_decode(s: &str) -> Option<Vec<u8>> {
|
||||
const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
let mut bits: u32 = 0;
|
||||
let mut nbits: u32 = 0;
|
||||
let mut out = Vec::new();
|
||||
for c in s.trim().trim_end_matches('=').bytes() {
|
||||
let c = c.to_ascii_uppercase();
|
||||
let v = A.iter().position(|&x| x == c)? as u32;
|
||||
bits = (bits << 5) | v;
|
||||
nbits += 5;
|
||||
if nbits >= 8 {
|
||||
nbits -= 8;
|
||||
out.push((bits >> nbits) as u8);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// RFC 6238 TOTP code for a specific unix time (SHA-1, `step`s, `digits`).
|
||||
/// Exposed for tests; `totp_verify` is the runtime entry. Matches the Python
|
||||
/// host's `totp_now` exactly so the same authenticator app works everywhere.
|
||||
pub fn totp_at(secret: &str, unix_secs: u64, step: u64, digits: u32) -> Option<String> {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha1::Sha1;
|
||||
let key = b32_decode(secret)?;
|
||||
let counter = (unix_secs / step).to_be_bytes();
|
||||
let mut mac = <Hmac<Sha1> as Mac>::new_from_slice(&key).ok()?;
|
||||
mac.update(&counter);
|
||||
let digest = mac.finalize().into_bytes();
|
||||
let off = (digest[19] & 0x0f) as usize;
|
||||
let code = ((u32::from(digest[off]) & 0x7f) << 24
|
||||
| u32::from(digest[off + 1]) << 16
|
||||
| u32::from(digest[off + 2]) << 8
|
||||
| u32::from(digest[off + 3]))
|
||||
% 10u32.pow(digits);
|
||||
Some(format!("{:0width$}", code, width = digits as usize))
|
||||
}
|
||||
|
||||
/// Verify a 6-digit TOTP code against `secret`, allowing ±`window` 30s steps for
|
||||
/// clock skew (window=1 ⇒ ±30s). Constant-time compare. Mirrors the Python
|
||||
/// host's `totp_verify`.
|
||||
pub fn totp_verify(secret: &str, code: &str, window: i64) -> bool {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let code = code.trim();
|
||||
if code.is_empty() || !code.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return false;
|
||||
}
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0) as i64;
|
||||
for i in -window..=window {
|
||||
let t = (now + i * 30).max(0) as u64;
|
||||
if let Some(c) = totp_at(secret, t, 30, 6) {
|
||||
if ct_eq(c.as_bytes(), code.as_bytes()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// A fresh 9-digit NetDesk host id, matching the engine's
|
||||
/// `str(secrets.randbelow(900000000) + 100000000)`.
|
||||
pub fn gen_host_id() -> String {
|
||||
let n = (rand::rngs::OsRng.next_u64() % 900_000_000) + 100_000_000;
|
||||
n.to_string()
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! H.264 decode. Phase 2 uses openh264 software decode (portable across all five
|
||||
//! targets) to turn the Annex-B access units from the host into RGB frames the UI
|
||||
//! can paint. Hardware decoders (Media Foundation / VideoToolbox / MediaCodec) are
|
||||
//! layered in later behind this same `Frame` output.
|
||||
|
||||
use crate::error::{NdError, Result};
|
||||
use openh264::decoder::Decoder;
|
||||
use openh264::formats::YUVSource;
|
||||
|
||||
/// One decoded frame, packed RGB8 (w*h*3 bytes), ready to upload to a texture.
|
||||
pub struct Frame {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
pub rgb: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Object-safe decode interface shared by every backend.
|
||||
///
|
||||
/// Both the portable openh264 software decoder ([`H264Decoder`]) and the Windows
|
||||
/// Media Foundation hardware decoder implement this, so the viewer can own a
|
||||
/// `Box<dyn VideoDecoder + Send>` and not care which one is running. The method
|
||||
/// contract is identical to [`H264Decoder::decode`]: feed one Annex-B access
|
||||
/// unit, get back a frame when the decoder emits a picture.
|
||||
pub trait VideoDecoder {
|
||||
fn decode(&mut self, au: &[u8]) -> Result<Option<Frame>>;
|
||||
}
|
||||
|
||||
pub struct H264Decoder {
|
||||
inner: Decoder,
|
||||
}
|
||||
|
||||
impl H264Decoder {
|
||||
pub fn new() -> Result<Self> {
|
||||
let inner =
|
||||
Decoder::new().map_err(|e| NdError::Other(anyhow::anyhow!("openh264 init: {e}")))?;
|
||||
Ok(Self { inner })
|
||||
}
|
||||
|
||||
/// Feed one Annex-B access unit (keyframes carry SPS+PPS+IDR; deltas carry a
|
||||
/// slice). Returns the decoded RGB frame when the decoder emits a picture.
|
||||
pub fn decode(&mut self, au: &[u8]) -> Result<Option<Frame>> {
|
||||
match self.inner.decode(au) {
|
||||
Ok(Some(yuv)) => {
|
||||
let (w, h) = yuv.dimensions();
|
||||
let mut rgb = vec![0u8; w * h * 3];
|
||||
yuv.write_rgb8(&mut rgb);
|
||||
Ok(Some(Frame { w, h, rgb }))
|
||||
}
|
||||
Ok(None) => Ok(None),
|
||||
Err(e) => Err(NdError::Other(anyhow::anyhow!("decode: {e}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoDecoder for H264Decoder {
|
||||
fn decode(&mut self, au: &[u8]) -> Result<Option<Frame>> {
|
||||
// Reuse the existing inherent method so both call sites stay in lockstep.
|
||||
H264Decoder::decode(self, au)
|
||||
}
|
||||
}
|
||||
|
||||
// Per-platform hardware decoders. Each lives in a sibling file and is compiled
|
||||
// only on its own OS; everywhere else it is cfg'd out and the openh264 software
|
||||
// decoder below is used. All implement the same `VideoDecoder` trait, so
|
||||
// `best_decoder` can hand back a `Box<dyn VideoDecoder + Send>` without the
|
||||
// viewer knowing or caring which backend is running.
|
||||
|
||||
// Windows: DXVA H.264 via Media Foundation.
|
||||
#[cfg(windows)]
|
||||
#[path = "decode_mf.rs"]
|
||||
mod mf;
|
||||
|
||||
// macOS / iOS: VideoToolbox (VTDecompressionSession).
|
||||
#[cfg(any(target_os = "macos", target_os = "ios"))]
|
||||
#[path = "decode_vt.rs"]
|
||||
mod vt;
|
||||
|
||||
// Android: MediaCodec (NDK AMediaCodec).
|
||||
#[cfg(target_os = "android")]
|
||||
#[path = "decode_mediacodec.rs"]
|
||||
mod mediacodec;
|
||||
|
||||
// Linux: VAAPI via FFmpeg/libavcodec.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "decode_vaapi.rs"]
|
||||
mod vaapi;
|
||||
|
||||
/// Last-resort decoder that drops every access unit. Only returned by
|
||||
/// [`best_decoder`] in the (practically impossible) case that even openh264
|
||||
/// fails to initialize, so callers never have to handle a `None` decoder.
|
||||
struct NullDecoder;
|
||||
|
||||
impl VideoDecoder for NullDecoder {
|
||||
fn decode(&mut self, _au: &[u8]) -> Result<Option<Frame>> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the fastest decoder available on this platform.
|
||||
///
|
||||
/// Each OS gets its native hardware H.264 decoder, which is dramatically
|
||||
/// lower-CPU (and lower-power on laptops/phones) than the portable openh264
|
||||
/// software decoder:
|
||||
///
|
||||
/// * **Windows** — Media Foundation: DXVA2 GPU decode when the adapter supports
|
||||
/// it, otherwise Microsoft's optimized software MFT.
|
||||
/// * **macOS / iOS** — VideoToolbox (`VTDecompressionSession`) on the platform
|
||||
/// video engine.
|
||||
/// * **Android** — `MediaCodec` (`AMediaCodec`), i.e. the SoC video decoder.
|
||||
/// * **Linux** — VAAPI through FFmpeg/libavcodec (Intel/AMD GPUs via `/dev/dri`).
|
||||
///
|
||||
/// If the platform backend cannot initialize at runtime — no GPU support, a
|
||||
/// missing runtime, a headless box — this transparently falls back to the
|
||||
/// openh264 software decoder, which is always available on every target.
|
||||
///
|
||||
/// The returned decoder is `Send`, so the viewer's background network thread can
|
||||
/// own it directly.
|
||||
pub fn best_decoder() -> Box<dyn VideoDecoder + Send> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
match mf::MediaFoundationDecoder::new() {
|
||||
Ok(d) => {
|
||||
tracing::info!(
|
||||
"decode: Media Foundation H.264 ({})",
|
||||
if d.is_hardware() {
|
||||
"DXVA hardware"
|
||||
} else {
|
||||
"MS software MFT"
|
||||
}
|
||||
);
|
||||
return Box::new(d);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"decode: Media Foundation init failed ({e}); using openh264 software decode"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "ios"))]
|
||||
{
|
||||
match vt::VideoToolboxDecoder::new() {
|
||||
Ok(d) => {
|
||||
tracing::info!("decode: VideoToolbox H.264 (hardware)");
|
||||
return Box::new(d);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"decode: VideoToolbox init failed ({e}); using openh264 software decode"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
match mediacodec::MediaCodecDecoder::new() {
|
||||
Ok(d) => {
|
||||
tracing::info!("decode: MediaCodec H.264 (hardware)");
|
||||
return Box::new(d);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"decode: MediaCodec init failed ({e}); using openh264 software decode"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
match vaapi::VaapiDecoder::new() {
|
||||
Ok(d) => {
|
||||
tracing::info!("decode: VAAPI H.264 (FFmpeg hardware)");
|
||||
return Box::new(d);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("decode: VAAPI init failed ({e}); using openh264 software decode");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match H264Decoder::new() {
|
||||
Ok(d) => Box::new(d),
|
||||
Err(e) => {
|
||||
tracing::error!("decode: openh264 init failed ({e}); video will not render");
|
||||
Box::new(NullDecoder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// VP9 software decoder (libvpx via the netdesk-vpx crate). Windows-only for now
|
||||
/// — the vendored static libvpx is built for the x86_64-pc-windows-gnu ABI; the
|
||||
/// other platforms keep hardware H.264 until their libvpx is vendored too.
|
||||
#[cfg(windows)]
|
||||
pub struct Vp9Decoder {
|
||||
inner: netdesk_vpx::Decoder,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl Vp9Decoder {
|
||||
pub fn new() -> Result<Self> {
|
||||
let inner = netdesk_vpx::Decoder::new(netdesk_vpx::VpxCodec::Vp9)
|
||||
.map_err(|e| NdError::Other(anyhow::anyhow!("vp9 decoder init: {e}")))?;
|
||||
Ok(Self { inner })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl VideoDecoder for Vp9Decoder {
|
||||
fn decode(&mut self, au: &[u8]) -> Result<Option<Frame>> {
|
||||
let frames = self
|
||||
.inner
|
||||
.decode_to_bgra(au)
|
||||
.map_err(|e| NdError::Other(anyhow::anyhow!("vp9 decode: {e}")))?;
|
||||
// One compressed packet yields 0 or 1 pictures; take it.
|
||||
let Some(f) = frames.into_iter().next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let (w, h) = (f.width as usize, f.height as usize);
|
||||
let mut rgb = vec![0u8; w * h * 3];
|
||||
for i in 0..(w * h) {
|
||||
// BGRA -> RGB (drop alpha, swap B/R)
|
||||
rgb[i * 3] = f.bgra[i * 4 + 2];
|
||||
rgb[i * 3 + 1] = f.bgra[i * 4 + 1];
|
||||
rgb[i * 3 + 2] = f.bgra[i * 4];
|
||||
}
|
||||
Ok(Some(Frame { w, h, rgb }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick a decoder for the given codec id (`proto::VideoCodec`): VP9 (1) uses the
|
||||
/// libvpx software decoder; anything else uses the platform H.264 decoder from
|
||||
/// [`best_decoder`]. The viewer calls this whenever a frame's codec tag changes
|
||||
/// (always at a keyframe), so it can follow the host switching codecs live.
|
||||
pub fn best_decoder_for(codec: u8) -> Box<dyn VideoDecoder + Send> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if codec == crate::proto::VideoCodec::Vp9 as u8 {
|
||||
match Vp9Decoder::new() {
|
||||
Ok(d) => {
|
||||
tracing::info!("decode: VP9 (libvpx software)");
|
||||
return Box::new(d);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("decode: VP9 init failed ({e}); falling back to H.264");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = codec;
|
||||
best_decoder()
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
//! Android hardware H.264 decode via `MediaCodec` (the NDK `AMediaCodec` API).
|
||||
//!
|
||||
//! This drives a byte-buffer-mode `AMediaCodec` H.264 decoder: the host's
|
||||
//! Annex-B access units go in as input buffers, and the decoder hands back
|
||||
//! YUV 4:2:0 output buffers (NV12 semi-planar or I420 planar depending on the
|
||||
//! device) which we convert to the same packed-RGB8 [`Frame`] the openh264 path
|
||||
//! produces, so the viewer is unchanged. On essentially all Android devices this
|
||||
//! binds to the SoC's hardware video decoder (OMX / Codec2).
|
||||
//!
|
||||
//! The module is only compiled on Android (see the `#[cfg(target_os = "android")]`
|
||||
//! gate in `decode.rs`). It talks to the stable NDK Media C ABI in `libmediandk`
|
||||
//! directly — the same ABI the `ndk`/`ndk-sys` crates wrap — so the FFI surface
|
||||
//! is self-contained and version-independent. If init fails at runtime,
|
||||
//! [`super::best_decoder`] falls back to the portable openh264 decoder.
|
||||
//!
|
||||
//! ## Flow
|
||||
//! * The codec must be configured with the stream's SPS/PPS as codec-specific
|
||||
//! data (`csd-0`/`csd-1`) before it can start, so it is created lazily on the
|
||||
//! first access unit carrying both (a keyframe). AUs seen before that are
|
||||
//! dropped (`Ok(None)`).
|
||||
//! * Each `decode` call queues the AU as one input buffer and dequeues at most
|
||||
//! one decoded output buffer; the host stream is low-latency with no
|
||||
//! B-frames, so the steady state is one frame in, one frame out.
|
||||
//! * The decoder reports the true picture rectangle through the crop keys on the
|
||||
//! output format, so the emitted `Frame` is the real display size (e.g.
|
||||
//! 1920x1080) even when the YUV planes are stride/slice-height padded.
|
||||
|
||||
use super::{Frame, VideoDecoder};
|
||||
use crate::error::{NdError, Result};
|
||||
|
||||
use core::ffi::{c_char, c_void};
|
||||
|
||||
use self::sys::*;
|
||||
|
||||
/// `MediaCodec` hardware H.264 decoder.
|
||||
pub struct MediaCodecDecoder {
|
||||
/// The codec; `null` until the first keyframe primes and starts it.
|
||||
codec: *mut AMediaCodec,
|
||||
sps: Vec<u8>,
|
||||
pps: Vec<u8>,
|
||||
pts_us: i64,
|
||||
}
|
||||
|
||||
impl MediaCodecDecoder {
|
||||
/// Probe that an H.264 decoder can be created on this device, then discard
|
||||
/// it (the real codec is configured lazily once the SPS/PPS arrive). Returns
|
||||
/// an error — so the caller falls back to openh264 — if no AVC decoder
|
||||
/// exists at all.
|
||||
pub fn new() -> Result<Self> {
|
||||
unsafe {
|
||||
let probe = AMediaCodec_createDecoderByType(MIME.as_ptr() as *const c_char);
|
||||
if probe.is_null() {
|
||||
return Err(other("no AMediaCodec AVC decoder available"));
|
||||
}
|
||||
AMediaCodec_delete(probe);
|
||||
}
|
||||
Ok(Self {
|
||||
codec: core::ptr::null_mut(),
|
||||
sps: Vec::new(),
|
||||
pps: Vec::new(),
|
||||
pts_us: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create, configure and start the codec from the cached SPS/PPS.
|
||||
fn prime(&mut self) -> Result<()> {
|
||||
self.teardown();
|
||||
unsafe {
|
||||
let codec = AMediaCodec_createDecoderByType(MIME.as_ptr() as *const c_char);
|
||||
if codec.is_null() {
|
||||
return Err(other("AMediaCodec_createDecoderByType returned null"));
|
||||
}
|
||||
|
||||
let fmt = AMediaFormat_new();
|
||||
if fmt.is_null() {
|
||||
AMediaCodec_delete(codec);
|
||||
return Err(other("AMediaFormat_new returned null"));
|
||||
}
|
||||
AMediaFormat_setString(fmt, KEY_MIME.as_ptr() as _, MIME.as_ptr() as _);
|
||||
// A nominal size is required; the decoder learns the real one from
|
||||
// the codec-specific data and reports it back via the output format.
|
||||
AMediaFormat_setInt32(fmt, KEY_WIDTH.as_ptr() as _, 1920);
|
||||
AMediaFormat_setInt32(fmt, KEY_HEIGHT.as_ptr() as _, 1080);
|
||||
|
||||
// csd-0 = SPS, csd-1 = PPS, each as a start-code-prefixed NAL.
|
||||
let csd0 = with_start_code(&self.sps);
|
||||
let csd1 = with_start_code(&self.pps);
|
||||
AMediaFormat_setBuffer(
|
||||
fmt,
|
||||
KEY_CSD0.as_ptr() as _,
|
||||
csd0.as_ptr() as *const c_void,
|
||||
csd0.len(),
|
||||
);
|
||||
AMediaFormat_setBuffer(
|
||||
fmt,
|
||||
KEY_CSD1.as_ptr() as _,
|
||||
csd1.as_ptr() as *const c_void,
|
||||
csd1.len(),
|
||||
);
|
||||
|
||||
let st = AMediaCodec_configure(
|
||||
codec,
|
||||
fmt,
|
||||
core::ptr::null_mut(), // no output Surface: byte-buffer mode
|
||||
core::ptr::null_mut(), // no crypto
|
||||
0,
|
||||
);
|
||||
AMediaFormat_delete(fmt);
|
||||
if st != AMEDIA_OK {
|
||||
AMediaCodec_delete(codec);
|
||||
return Err(other(&format!("AMediaCodec_configure failed ({st})")));
|
||||
}
|
||||
let st = AMediaCodec_start(codec);
|
||||
if st != AMEDIA_OK {
|
||||
AMediaCodec_delete(codec);
|
||||
return Err(other(&format!("AMediaCodec_start failed ({st})")));
|
||||
}
|
||||
self.codec = codec;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Queue one Annex-B access unit as an input buffer.
|
||||
unsafe fn queue_input(&mut self, au: &[u8]) {
|
||||
let idx = AMediaCodec_dequeueInputBuffer(self.codec, DEQUEUE_TIMEOUT_US);
|
||||
if idx < 0 {
|
||||
return;
|
||||
}
|
||||
let mut cap: usize = 0;
|
||||
let buf = AMediaCodec_getInputBuffer(self.codec, idx as usize, &mut cap);
|
||||
if buf.is_null() || au.len() > cap {
|
||||
// Still submit an empty buffer so the index is not leaked.
|
||||
let _ = AMediaCodec_queueInputBuffer(self.codec, idx as usize, 0, 0, self.pts_us, 0);
|
||||
return;
|
||||
}
|
||||
core::ptr::copy_nonoverlapping(au.as_ptr(), buf, au.len());
|
||||
let _ = AMediaCodec_queueInputBuffer(self.codec, idx as usize, 0, au.len(), self.pts_us, 0);
|
||||
self.pts_us += PTS_STEP_US;
|
||||
}
|
||||
|
||||
/// Dequeue at most one decoded output buffer and convert it to RGB.
|
||||
unsafe fn pull_output(&mut self) -> Result<Option<Frame>> {
|
||||
let mut info = AMediaCodecBufferInfo {
|
||||
offset: 0,
|
||||
size: 0,
|
||||
presentationTimeUs: 0,
|
||||
flags: 0,
|
||||
};
|
||||
let idx = AMediaCodec_dequeueOutputBuffer(self.codec, &mut info, DEQUEUE_TIMEOUT_US);
|
||||
if idx < 0 {
|
||||
// INFO_TRY_AGAIN_LATER / OUTPUT_FORMAT_CHANGED / BUFFERS_CHANGED: all
|
||||
// mean "no frame this time"; the next call picks the format up.
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut cap: usize = 0;
|
||||
let buf = AMediaCodec_getOutputBuffer(self.codec, idx as usize, &mut cap);
|
||||
let frame = if !buf.is_null() && info.size > 0 {
|
||||
let fmt = AMediaCodec_getOutputFormat(self.codec);
|
||||
let geom = read_geometry(fmt);
|
||||
if !fmt.is_null() {
|
||||
AMediaFormat_delete(fmt);
|
||||
}
|
||||
let end = (info.offset as usize)
|
||||
.saturating_add(info.size as usize)
|
||||
.min(cap);
|
||||
let start = (info.offset as usize).min(end);
|
||||
let data = core::slice::from_raw_parts(buf.add(start), end - start);
|
||||
yuv420_to_rgb(data, &geom)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _ = AMediaCodec_releaseOutputBuffer(self.codec, idx as usize, false);
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
fn teardown(&mut self) {
|
||||
unsafe {
|
||||
if !self.codec.is_null() {
|
||||
let _ = AMediaCodec_stop(self.codec);
|
||||
AMediaCodec_delete(self.codec);
|
||||
self.codec = core::ptr::null_mut();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoDecoder for MediaCodecDecoder {
|
||||
fn decode(&mut self, au: &[u8]) -> Result<Option<Frame>> {
|
||||
if au.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Cache parameter sets from keyframes; (re)prime when they change.
|
||||
let mut new_sps: Option<Vec<u8>> = None;
|
||||
let mut new_pps: Option<Vec<u8>> = None;
|
||||
for_each_nal(au, |nal| match nal.first().map(|b| b & 0x1F) {
|
||||
Some(7) => new_sps = Some(nal.to_vec()),
|
||||
Some(8) => new_pps = Some(nal.to_vec()),
|
||||
_ => {}
|
||||
});
|
||||
if let (Some(sps), Some(pps)) = (new_sps, new_pps) {
|
||||
if sps != self.sps || pps != self.pps || self.codec.is_null() {
|
||||
self.sps = sps;
|
||||
self.pps = pps;
|
||||
self.prime()?;
|
||||
}
|
||||
}
|
||||
if self.codec.is_null() {
|
||||
return Ok(None); // still waiting for the first keyframe
|
||||
}
|
||||
|
||||
unsafe {
|
||||
// Feed the whole Annex-B AU (start codes included — MediaCodec wants
|
||||
// the elementary stream byte format in byte-buffer mode).
|
||||
self.queue_input(au);
|
||||
self.pull_output()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MediaCodecDecoder {
|
||||
fn drop(&mut self) {
|
||||
self.teardown();
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: the decoder is created on, and used only from, the single background
|
||||
// decode thread that owns it. `AMediaCodec` handles are not shared between
|
||||
// threads here (no `Sync`); moving ownership to that thread and calling it there
|
||||
// is sound.
|
||||
unsafe impl Send for MediaCodecDecoder {}
|
||||
|
||||
/// ~29.97 fps in microseconds; only needs to be monotonic.
|
||||
const PTS_STEP_US: i64 = 33_367;
|
||||
/// 10 ms dequeue timeout: long enough to pace one frame, short enough to keep
|
||||
/// the decode loop responsive.
|
||||
const DEQUEUE_TIMEOUT_US: i64 = 10_000;
|
||||
|
||||
// NDK format keys and MIME, as NUL-terminated byte strings (the `AMEDIAFORMAT_*`
|
||||
// macros expand to exactly these literals).
|
||||
const MIME: &[u8] = b"video/avc\0";
|
||||
const KEY_MIME: &[u8] = b"mime\0";
|
||||
const KEY_WIDTH: &[u8] = b"width\0";
|
||||
const KEY_HEIGHT: &[u8] = b"height\0";
|
||||
const KEY_STRIDE: &[u8] = b"stride\0";
|
||||
const KEY_SLICE_HEIGHT: &[u8] = b"slice-height\0";
|
||||
const KEY_COLOR_FORMAT: &[u8] = b"color-format\0";
|
||||
const KEY_CROP_LEFT: &[u8] = b"crop-left\0";
|
||||
const KEY_CROP_TOP: &[u8] = b"crop-top\0";
|
||||
const KEY_CROP_RIGHT: &[u8] = b"crop-right\0";
|
||||
const KEY_CROP_BOTTOM: &[u8] = b"crop-bottom\0";
|
||||
const KEY_CSD0: &[u8] = b"csd-0\0";
|
||||
const KEY_CSD1: &[u8] = b"csd-1\0";
|
||||
|
||||
// Android `MediaCodecInfo.CodecCapabilities` colour formats we handle.
|
||||
const COLOR_FORMAT_YUV420_PLANAR: i32 = 19; // I420 (Y, U, V planes)
|
||||
const COLOR_FORMAT_YUV420_SEMIPLANAR: i32 = 21; // NV12 (Y, interleaved UV)
|
||||
|
||||
/// Decoded output-buffer geometry pulled from the codec's output `AMediaFormat`.
|
||||
struct Geometry {
|
||||
color_format: i32,
|
||||
stride: usize,
|
||||
slice_height: usize,
|
||||
/// Visible picture rectangle (display size + top-left offset).
|
||||
crop_x: usize,
|
||||
crop_y: usize,
|
||||
disp_w: usize,
|
||||
disp_h: usize,
|
||||
}
|
||||
|
||||
/// Read the stride/slice-height/colour-format and crop rectangle from the output
|
||||
/// format, filling in sane fallbacks when a key is absent.
|
||||
unsafe fn read_geometry(fmt: *mut AMediaFormat) -> Geometry {
|
||||
let mut width = 0i32;
|
||||
let mut height = 0i32;
|
||||
let mut stride = 0i32;
|
||||
let mut slice_h = 0i32;
|
||||
let mut color = COLOR_FORMAT_YUV420_SEMIPLANAR;
|
||||
let (mut cl, mut ct, mut cr, mut cb) = (0i32, 0i32, -1i32, -1i32);
|
||||
if !fmt.is_null() {
|
||||
get_i32(fmt, KEY_WIDTH, &mut width);
|
||||
get_i32(fmt, KEY_HEIGHT, &mut height);
|
||||
get_i32(fmt, KEY_STRIDE, &mut stride);
|
||||
get_i32(fmt, KEY_SLICE_HEIGHT, &mut slice_h);
|
||||
get_i32(fmt, KEY_COLOR_FORMAT, &mut color);
|
||||
get_i32(fmt, KEY_CROP_LEFT, &mut cl);
|
||||
get_i32(fmt, KEY_CROP_TOP, &mut ct);
|
||||
get_i32(fmt, KEY_CROP_RIGHT, &mut cr);
|
||||
get_i32(fmt, KEY_CROP_BOTTOM, &mut cb);
|
||||
}
|
||||
if stride <= 0 {
|
||||
stride = width;
|
||||
}
|
||||
if slice_h <= 0 {
|
||||
slice_h = height;
|
||||
}
|
||||
// Crop rect is inclusive; fall back to the full coded size when absent.
|
||||
let (disp_w, disp_h, crop_x, crop_y) = if cr >= cl && cb >= ct && cr >= 0 && cb >= 0 {
|
||||
(
|
||||
(cr - cl + 1) as usize,
|
||||
(cb - ct + 1) as usize,
|
||||
cl.max(0) as usize,
|
||||
ct.max(0) as usize,
|
||||
)
|
||||
} else {
|
||||
(width.max(0) as usize, height.max(0) as usize, 0, 0)
|
||||
};
|
||||
Geometry {
|
||||
color_format: color,
|
||||
stride: stride.max(0) as usize,
|
||||
slice_height: slice_h.max(0) as usize,
|
||||
crop_x,
|
||||
crop_y,
|
||||
disp_w,
|
||||
disp_h,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an I420 / NV12 output buffer to packed RGB8, cropping to the visible
|
||||
/// rectangle described by `g` (BT.601 limited range, matching the other paths).
|
||||
fn yuv420_to_rgb(data: &[u8], g: &Geometry) -> Option<Frame> {
|
||||
let (w, h, stride, plane_h) = (g.disp_w, g.disp_h, g.stride, g.slice_height);
|
||||
if w == 0 || h == 0 || stride == 0 || plane_h == 0 {
|
||||
return None;
|
||||
}
|
||||
let y_plane = stride * plane_h;
|
||||
let semiplanar = g.color_format != COLOR_FORMAT_YUV420_PLANAR;
|
||||
let mut rgb = vec![0u8; w * h * 3];
|
||||
for row in 0..h {
|
||||
let sy = g.crop_y + row;
|
||||
let y_off = sy * stride;
|
||||
let out_off = row * w * 3;
|
||||
let cy = sy / 2;
|
||||
for col in 0..w {
|
||||
let sx = g.crop_x + col;
|
||||
let y = *data.get(y_off + sx).unwrap_or(&16) as i32;
|
||||
let (u, v) = if semiplanar {
|
||||
// NV12: interleaved UV plane after Y, half-height, full stride.
|
||||
// The chroma pair for luma column `sx` sits at the even offset
|
||||
// `sx & !1` within the UV row (same stride as the Y plane).
|
||||
let uv_off = y_plane + cy * stride;
|
||||
let base = uv_off + (sx & !1);
|
||||
(
|
||||
*data.get(base).unwrap_or(&128) as i32,
|
||||
*data.get(base + 1).unwrap_or(&128) as i32,
|
||||
)
|
||||
} else {
|
||||
// I420: U plane then V plane, each half-width / half-height.
|
||||
let c_stride = stride / 2;
|
||||
let c_plane = (stride / 2) * (plane_h / 2);
|
||||
let cx = sx / 2;
|
||||
let u_off = y_plane + cy * c_stride + cx;
|
||||
let v_off = y_plane + c_plane + cy * c_stride + cx;
|
||||
(
|
||||
*data.get(u_off).unwrap_or(&128) as i32,
|
||||
*data.get(v_off).unwrap_or(&128) as i32,
|
||||
)
|
||||
};
|
||||
let c = y - 16;
|
||||
let d = u - 128;
|
||||
let e = v - 128;
|
||||
let r = ((298 * c + 409 * e + 128) >> 8).clamp(0, 255) as u8;
|
||||
let gg = ((298 * c - 100 * d - 208 * e + 128) >> 8).clamp(0, 255) as u8;
|
||||
let b = ((298 * c + 516 * d + 128) >> 8).clamp(0, 255) as u8;
|
||||
let o = out_off + col * 3;
|
||||
rgb[o] = r;
|
||||
rgb[o + 1] = gg;
|
||||
rgb[o + 2] = b;
|
||||
}
|
||||
}
|
||||
Some(Frame { w, h, rgb })
|
||||
}
|
||||
|
||||
/// Prepend a 4-byte Annex-B start code to a raw NAL for use as csd.
|
||||
fn with_start_code(nal: &[u8]) -> Vec<u8> {
|
||||
let mut v = Vec::with_capacity(nal.len() + 4);
|
||||
v.extend_from_slice(&[0, 0, 0, 1]);
|
||||
v.extend_from_slice(nal);
|
||||
v
|
||||
}
|
||||
|
||||
/// `AMediaFormat_getInt32` wrapper that leaves `out` untouched when the key is
|
||||
/// absent (so the caller's fallback stands).
|
||||
unsafe fn get_i32(fmt: *mut AMediaFormat, key: &[u8], out: *mut i32) -> bool {
|
||||
AMediaFormat_getInt32(fmt, key.as_ptr() as *const c_char, out)
|
||||
}
|
||||
|
||||
/// Iterate the Annex-B NAL units in `au`, start codes excluded and trailing zero
|
||||
/// bytes trimmed.
|
||||
fn for_each_nal<F: FnMut(&[u8])>(au: &[u8], mut f: F) {
|
||||
let n = au.len();
|
||||
let mut starts: Vec<usize> = Vec::new();
|
||||
let mut i = 0usize;
|
||||
while i + 2 < n {
|
||||
if au[i] == 0 && au[i + 1] == 0 && au[i + 2] == 1 {
|
||||
starts.push(i + 3);
|
||||
i += 3;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
for k in 0..starts.len() {
|
||||
let begin = starts[k];
|
||||
let end = if k + 1 < starts.len() {
|
||||
starts[k + 1] - 3
|
||||
} else {
|
||||
n
|
||||
};
|
||||
if begin >= end {
|
||||
continue;
|
||||
}
|
||||
let mut nal = &au[begin..end];
|
||||
while let [rest @ .., 0] = nal {
|
||||
nal = rest;
|
||||
}
|
||||
if !nal.is_empty() {
|
||||
f(nal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn other(msg: &str) -> NdError {
|
||||
NdError::Other(anyhow::anyhow!(msg.to_string()))
|
||||
}
|
||||
|
||||
/// Self-contained FFI to the NDK Media C ABI (`libmediandk`). These signatures
|
||||
/// match `<media/NdkMediaCodec.h>` / `<media/NdkMediaFormat.h>` and are stable
|
||||
/// since Android API 21 (`AMediaFormat_getRect`/crop keys since API 21 as well
|
||||
/// for the common decoders). The `ndk`/`ndk-sys` crates wrap the same library.
|
||||
#[allow(non_snake_case, non_camel_case_types)]
|
||||
mod sys {
|
||||
use core::ffi::{c_char, c_void};
|
||||
|
||||
pub type AMediaCodec = c_void;
|
||||
pub type AMediaFormat = c_void;
|
||||
pub type media_status_t = i32;
|
||||
pub const AMEDIA_OK: media_status_t = 0;
|
||||
|
||||
#[repr(C)]
|
||||
pub struct AMediaCodecBufferInfo {
|
||||
pub offset: i32,
|
||||
pub size: i32,
|
||||
pub presentationTimeUs: i64,
|
||||
pub flags: u32,
|
||||
}
|
||||
|
||||
#[link(name = "mediandk")]
|
||||
extern "C" {
|
||||
pub fn AMediaCodec_createDecoderByType(mime_type: *const c_char) -> *mut AMediaCodec;
|
||||
pub fn AMediaCodec_configure(
|
||||
codec: *mut AMediaCodec,
|
||||
format: *mut AMediaFormat,
|
||||
surface: *mut c_void,
|
||||
crypto: *mut c_void,
|
||||
flags: u32,
|
||||
) -> media_status_t;
|
||||
pub fn AMediaCodec_start(codec: *mut AMediaCodec) -> media_status_t;
|
||||
pub fn AMediaCodec_stop(codec: *mut AMediaCodec) -> media_status_t;
|
||||
pub fn AMediaCodec_delete(codec: *mut AMediaCodec) -> media_status_t;
|
||||
pub fn AMediaCodec_dequeueInputBuffer(codec: *mut AMediaCodec, timeout_us: i64) -> isize;
|
||||
pub fn AMediaCodec_getInputBuffer(
|
||||
codec: *mut AMediaCodec,
|
||||
idx: usize,
|
||||
out_size: *mut usize,
|
||||
) -> *mut u8;
|
||||
pub fn AMediaCodec_queueInputBuffer(
|
||||
codec: *mut AMediaCodec,
|
||||
idx: usize,
|
||||
offset: i64,
|
||||
size: usize,
|
||||
time_us: i64,
|
||||
flags: u32,
|
||||
) -> media_status_t;
|
||||
pub fn AMediaCodec_dequeueOutputBuffer(
|
||||
codec: *mut AMediaCodec,
|
||||
info: *mut AMediaCodecBufferInfo,
|
||||
timeout_us: i64,
|
||||
) -> isize;
|
||||
pub fn AMediaCodec_getOutputBuffer(
|
||||
codec: *mut AMediaCodec,
|
||||
idx: usize,
|
||||
out_size: *mut usize,
|
||||
) -> *mut u8;
|
||||
pub fn AMediaCodec_getOutputFormat(codec: *mut AMediaCodec) -> *mut AMediaFormat;
|
||||
pub fn AMediaCodec_releaseOutputBuffer(
|
||||
codec: *mut AMediaCodec,
|
||||
idx: usize,
|
||||
render: bool,
|
||||
) -> media_status_t;
|
||||
|
||||
pub fn AMediaFormat_new() -> *mut AMediaFormat;
|
||||
pub fn AMediaFormat_delete(fmt: *mut AMediaFormat) -> media_status_t;
|
||||
pub fn AMediaFormat_setString(
|
||||
fmt: *mut AMediaFormat,
|
||||
name: *const c_char,
|
||||
value: *const c_char,
|
||||
);
|
||||
pub fn AMediaFormat_setInt32(fmt: *mut AMediaFormat, name: *const c_char, value: i32);
|
||||
pub fn AMediaFormat_setBuffer(
|
||||
fmt: *mut AMediaFormat,
|
||||
name: *const c_char,
|
||||
data: *const c_void,
|
||||
size: usize,
|
||||
);
|
||||
pub fn AMediaFormat_getInt32(
|
||||
fmt: *mut AMediaFormat,
|
||||
name: *const c_char,
|
||||
out: *mut i32,
|
||||
) -> bool;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
//! Windows hardware H.264 decode via Media Foundation.
|
||||
//!
|
||||
//! This feeds the host's Annex-B access units into the Microsoft H.264 decoder
|
||||
//! MFT (`CLSID_MSH264DecoderMFT`). When a Direct3D 11 device can be attached the
|
||||
//! decode runs on the GPU video engine (DXVA2) and the decoded NV12 surface is
|
||||
//! copied back to system memory; otherwise the same MFT runs as Microsoft's
|
||||
//! (still heavily optimized) software decoder. Either way the output is the same
|
||||
//! packed-RGB8 [`Frame`] the openh264 path produces, so the viewer is unchanged.
|
||||
//!
|
||||
//! The whole module is only compiled on Windows (see the `#[cfg(windows)]` gate
|
||||
//! in `decode.rs`). If anything here fails at runtime, [`super::best_decoder`]
|
||||
//! falls back to the portable openh264 software decoder.
|
||||
|
||||
use super::{Frame, VideoDecoder};
|
||||
use crate::error::{NdError, Result};
|
||||
|
||||
use windows::core::Interface;
|
||||
use windows::Win32::Foundation::{HMODULE, TRUE};
|
||||
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_HARDWARE;
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Multithread, ID3D11Texture2D,
|
||||
D3D11_CPU_ACCESS_READ, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_CREATE_DEVICE_VIDEO_SUPPORT,
|
||||
D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_READ, D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC,
|
||||
D3D11_USAGE_STAGING,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT, DXGI_SAMPLE_DESC};
|
||||
use windows::Win32::Media::MediaFoundation::*;
|
||||
use windows::Win32::System::Com::{
|
||||
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED,
|
||||
};
|
||||
|
||||
/// ~29.97 fps in 100-ns units. The host stream is low-latency with no B-frames,
|
||||
/// so exact timing is unimportant — the decoder only needs monotonic stamps.
|
||||
const HNS_PER_FRAME: i64 = 333_667;
|
||||
|
||||
/// Hardware (or MS-software) Media Foundation H.264 decoder.
|
||||
pub struct MediaFoundationDecoder {
|
||||
transform: IMFTransform,
|
||||
// Direct3D plumbing kept alive for the lifetime of the decoder (hw path only).
|
||||
device: Option<ID3D11Device>,
|
||||
context: Option<ID3D11DeviceContext>,
|
||||
_manager: Option<IMFDXGIDeviceManager>,
|
||||
staging: Option<ID3D11Texture2D>,
|
||||
staging_dims: (u32, u32),
|
||||
/// True when DXVA hardware decode is active (output is a D3D texture).
|
||||
hw: bool,
|
||||
/// True when the MFT allocates its own output samples.
|
||||
provides_samples: bool,
|
||||
output_size: usize,
|
||||
stride: usize,
|
||||
/// Coded (padded, usually 16-aligned) frame size from MF_MT_FRAME_SIZE.
|
||||
/// This is the NV12 *plane* geometry we read out of memory, not the picture.
|
||||
coded_w: usize,
|
||||
coded_h: usize,
|
||||
/// True visible picture size (from the display aperture). This is what the
|
||||
/// emitted `Frame.w`/`Frame.h` report, e.g. 1920x1080 even when the decoder
|
||||
/// pads the coded frame to 1920x1088.
|
||||
width: usize,
|
||||
height: usize,
|
||||
/// Top-left offset of the visible region inside the coded frame (usually 0).
|
||||
crop_x: usize,
|
||||
crop_y: usize,
|
||||
started: bool,
|
||||
pts: i64,
|
||||
/// A frame pulled out while draining the MFT during a busy ProcessInput.
|
||||
pending: Option<Frame>,
|
||||
}
|
||||
|
||||
impl MediaFoundationDecoder {
|
||||
/// Initialize Media Foundation and the H.264 decoder MFT. Returns an error
|
||||
/// (so the caller can fall back to openh264) if MF is unavailable.
|
||||
pub fn new() -> Result<Self> {
|
||||
unsafe {
|
||||
// COM (MTA) + MF startup. CoInitializeEx returns an HRESULT that is
|
||||
// fine to ignore (S_FALSE / RPC_E_CHANGED_MODE just mean the thread
|
||||
// already has an apartment). MFStartup is refcounted against the
|
||||
// MFShutdown in Drop.
|
||||
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
|
||||
MFStartup(MF_VERSION, MFSTARTUP_FULL).map_err(mferr("MFStartup"))?;
|
||||
|
||||
let transform: IMFTransform =
|
||||
CoCreateInstance(&CLSID_MSH264DecoderMFT, None, CLSCTX_INPROC_SERVER)
|
||||
.map_err(mferr("CoCreateInstance(MSH264DecoderMFT)"))?;
|
||||
|
||||
// Low-latency mode: emit each frame as soon as it is decoded.
|
||||
if let Ok(attrs) = transform.GetAttributes() {
|
||||
let _ = attrs.SetUINT32(&MF_LOW_LATENCY, 1);
|
||||
}
|
||||
|
||||
// Best-effort DXVA hardware acceleration.
|
||||
let (device, context, manager, hw) = match init_d3d(&transform) {
|
||||
Ok((d, c, m)) => (Some(d), Some(c), Some(m), true),
|
||||
Err(e) => {
|
||||
tracing::debug!("decode(mf): DXVA unavailable ({e}); MS software MFT");
|
||||
(None, None, None, false)
|
||||
}
|
||||
};
|
||||
|
||||
// Input media type: H.264 Annex-B elementary stream.
|
||||
let input = MFCreateMediaType().map_err(mferr("MFCreateMediaType(input)"))?;
|
||||
input
|
||||
.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)
|
||||
.map_err(mferr("set input major type"))?;
|
||||
input
|
||||
.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_H264)
|
||||
.map_err(mferr("set input subtype H264"))?;
|
||||
transform
|
||||
.SetInputType(0, &input, 0)
|
||||
.map_err(mferr("SetInputType"))?;
|
||||
|
||||
let mut me = Self {
|
||||
transform,
|
||||
device,
|
||||
context,
|
||||
_manager: manager,
|
||||
staging: None,
|
||||
staging_dims: (0, 0),
|
||||
hw,
|
||||
provides_samples: false,
|
||||
output_size: 0,
|
||||
stride: 0,
|
||||
coded_w: 0,
|
||||
coded_h: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
crop_x: 0,
|
||||
crop_y: 0,
|
||||
started: false,
|
||||
pts: 0,
|
||||
pending: None,
|
||||
};
|
||||
|
||||
me.configure_output()?;
|
||||
|
||||
me.transform
|
||||
.ProcessMessage(MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, 0)
|
||||
.map_err(mferr("NOTIFY_BEGIN_STREAMING"))?;
|
||||
me.transform
|
||||
.ProcessMessage(MFT_MESSAGE_NOTIFY_START_OF_STREAM, 0)
|
||||
.map_err(mferr("NOTIFY_START_OF_STREAM"))?;
|
||||
me.started = true;
|
||||
|
||||
Ok(me)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether DXVA GPU decode is active (vs. the MS software MFT).
|
||||
pub fn is_hardware(&self) -> bool {
|
||||
self.hw
|
||||
}
|
||||
|
||||
/// Select an NV12 output type and read back its geometry + allocation needs.
|
||||
unsafe fn configure_output(&mut self) -> Result<()> {
|
||||
let mut i = 0u32;
|
||||
loop {
|
||||
match self.transform.GetOutputAvailableType(0, i) {
|
||||
Ok(t) => {
|
||||
let sub = t.GetGUID(&MF_MT_SUBTYPE).unwrap_or_default();
|
||||
if sub == MFVideoFormat_NV12 {
|
||||
self.transform
|
||||
.SetOutputType(0, &t, 0)
|
||||
.map_err(mferr("SetOutputType(NV12)"))?;
|
||||
self.read_output_geometry(&t);
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Err(e) if e.code() == MF_E_NO_MORE_TYPES => {
|
||||
// Nothing offered yet (common before the first keyframe sets
|
||||
// the frame size) — ask for NV12 explicitly.
|
||||
let t = MFCreateMediaType().map_err(mferr("MFCreateMediaType(output)"))?;
|
||||
let _ = t.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video);
|
||||
let _ = t.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_NV12);
|
||||
self.transform
|
||||
.SetOutputType(0, &t, 0)
|
||||
.map_err(mferr("SetOutputType(NV12 manual)"))?;
|
||||
if let Ok(cur) = self.transform.GetOutputCurrentType(0) {
|
||||
self.read_output_geometry(&cur);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Err(e) => return Err(mferr("GetOutputAvailableType")(e)),
|
||||
}
|
||||
}
|
||||
|
||||
let info = self
|
||||
.transform
|
||||
.GetOutputStreamInfo(0)
|
||||
.map_err(mferr("GetOutputStreamInfo"))?;
|
||||
let provides =
|
||||
(MFT_OUTPUT_STREAM_PROVIDES_SAMPLES.0 | MFT_OUTPUT_STREAM_CAN_PROVIDE_SAMPLES.0) as u32;
|
||||
self.provides_samples = (info.dwFlags & provides) != 0;
|
||||
self.output_size = info.cbSize as usize;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pull the coded frame size, the true display rectangle, and the stride out
|
||||
/// of a configured output media type.
|
||||
///
|
||||
/// The MS H.264 decoder reports the *coded* size in `MF_MT_FRAME_SIZE` — that
|
||||
/// is padded up to the codec's alignment (e.g. 1920x1088 for 1080p content,
|
||||
/// and larger for other resolutions), which is what produced the wrong
|
||||
/// 1920x18xx figure in the stats. The real picture rectangle is carried in a
|
||||
/// video-aperture blob, so we read that separately and keep the coded size
|
||||
/// only for the in-memory plane layout.
|
||||
unsafe fn read_output_geometry(&mut self, t: &IMFMediaType) {
|
||||
// Coded (padded) plane geometry — how the NV12 bytes are laid out.
|
||||
if let Ok(fs) = t.GetUINT64(&MF_MT_FRAME_SIZE) {
|
||||
self.coded_w = (fs >> 32) as usize;
|
||||
self.coded_h = (fs & 0xffff_ffff) as usize;
|
||||
}
|
||||
|
||||
// True visible picture region. Prefer the minimum display aperture
|
||||
// (what a correct renderer must show), then the geometric aperture, then
|
||||
// the pan/scan aperture if pan/scan is actually enabled. When none is
|
||||
// present the whole coded frame is the picture.
|
||||
let aperture = read_aperture(t, &MF_MT_MINIMUM_DISPLAY_APERTURE)
|
||||
.or_else(|| read_aperture(t, &MF_MT_GEOMETRIC_APERTURE))
|
||||
.or_else(|| {
|
||||
if t.GetUINT32(&MF_MT_PAN_SCAN_ENABLED).unwrap_or(0) != 0 {
|
||||
read_aperture(t, &MF_MT_PAN_SCAN_APERTURE)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
match aperture {
|
||||
Some((ox, oy, aw, ah)) => {
|
||||
self.crop_x = ox;
|
||||
self.crop_y = oy;
|
||||
// Clamp the aperture inside the coded frame so a malformed blob
|
||||
// can never make the cropper read past the decoded plane.
|
||||
let max_w = self.coded_w.saturating_sub(ox);
|
||||
let max_h = self.coded_h.saturating_sub(oy);
|
||||
self.width = if max_w > 0 { aw.min(max_w) } else { aw };
|
||||
self.height = if max_h > 0 { ah.min(max_h) } else { ah };
|
||||
}
|
||||
None => {
|
||||
self.crop_x = 0;
|
||||
self.crop_y = 0;
|
||||
self.width = self.coded_w;
|
||||
self.height = self.coded_h;
|
||||
}
|
||||
}
|
||||
|
||||
self.stride = match t.GetUINT32(&MF_MT_DEFAULT_STRIDE) {
|
||||
Ok(s) => (s as i32).unsigned_abs() as usize,
|
||||
Err(_) => self.coded_w,
|
||||
};
|
||||
if self.stride == 0 {
|
||||
self.stride = self.coded_w;
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap one Annex-B access unit in an input IMFSample.
|
||||
unsafe fn make_input_sample(&mut self, au: &[u8]) -> Result<IMFSample> {
|
||||
let sample = MFCreateSample().map_err(mferr("MFCreateSample"))?;
|
||||
let buffer =
|
||||
MFCreateMemoryBuffer(au.len() as u32).map_err(mferr("MFCreateMemoryBuffer"))?;
|
||||
let mut ptr: *mut u8 = std::ptr::null_mut();
|
||||
buffer
|
||||
.Lock(&mut ptr, None, None)
|
||||
.map_err(mferr("Lock(input)"))?;
|
||||
std::ptr::copy_nonoverlapping(au.as_ptr(), ptr, au.len());
|
||||
buffer.Unlock().map_err(mferr("Unlock(input)"))?;
|
||||
buffer
|
||||
.SetCurrentLength(au.len() as u32)
|
||||
.map_err(mferr("SetCurrentLength"))?;
|
||||
sample.AddBuffer(&buffer).map_err(mferr("AddBuffer"))?;
|
||||
let _ = sample.SetSampleTime(self.pts);
|
||||
let _ = sample.SetSampleDuration(HNS_PER_FRAME);
|
||||
self.pts += HNS_PER_FRAME;
|
||||
Ok(sample)
|
||||
}
|
||||
|
||||
/// Run ProcessOutput once, materializing a frame if one is ready.
|
||||
unsafe fn pull_output(&mut self) -> Result<Option<Frame>> {
|
||||
loop {
|
||||
let sample_slot: Option<IMFSample> = if self.provides_samples {
|
||||
None
|
||||
} else {
|
||||
let s = MFCreateSample().map_err(mferr("MFCreateSample(output)"))?;
|
||||
let size = self
|
||||
.output_size
|
||||
.max(self.stride * self.coded_h * 3 / 2)
|
||||
.max(1);
|
||||
let b = MFCreateMemoryBuffer(size as u32)
|
||||
.map_err(mferr("MFCreateMemoryBuffer(output)"))?;
|
||||
s.AddBuffer(&b).map_err(mferr("AddBuffer(output)"))?;
|
||||
Some(s)
|
||||
};
|
||||
|
||||
let mut buffers = [MFT_OUTPUT_DATA_BUFFER {
|
||||
dwStreamID: 0,
|
||||
pSample: std::mem::ManuallyDrop::new(sample_slot),
|
||||
dwStatus: 0,
|
||||
pEvents: std::mem::ManuallyDrop::new(None),
|
||||
}];
|
||||
let mut status = 0u32;
|
||||
let res = self.transform.ProcessOutput(0, &mut buffers, &mut status);
|
||||
|
||||
// Reclaim ownership of whatever is in the slots so they drop cleanly.
|
||||
let out_sample = std::mem::ManuallyDrop::take(&mut buffers[0].pSample);
|
||||
let _events = std::mem::ManuallyDrop::take(&mut buffers[0].pEvents);
|
||||
|
||||
match res {
|
||||
Ok(()) => {
|
||||
let s = out_sample.ok_or_else(|| other("ProcessOutput produced no sample"))?;
|
||||
return Ok(Some(self.sample_to_frame(&s)?));
|
||||
}
|
||||
Err(e) if e.code() == MF_E_TRANSFORM_NEED_MORE_INPUT => return Ok(None),
|
||||
Err(e) if e.code() == MF_E_TRANSFORM_STREAM_CHANGE => {
|
||||
// The decoder learned the real resolution — renegotiate NV12.
|
||||
self.configure_output()?;
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(mferr("ProcessOutput")(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a decoded output sample (D3D texture or system memory) to RGB.
|
||||
unsafe fn sample_to_frame(&mut self, sample: &IMFSample) -> Result<Frame> {
|
||||
let buffer = sample
|
||||
.GetBufferByIndex(0)
|
||||
.map_err(mferr("GetBufferByIndex"))?;
|
||||
|
||||
if self.hw {
|
||||
if let Ok(dxgi) = buffer.cast::<IMFDXGIBuffer>() {
|
||||
return self.dxgi_to_frame(&dxgi);
|
||||
}
|
||||
}
|
||||
|
||||
// System-memory NV12.
|
||||
let mut ptr: *mut u8 = std::ptr::null_mut();
|
||||
let mut cur: u32 = 0;
|
||||
buffer
|
||||
.Lock(&mut ptr, None, Some(&mut cur))
|
||||
.map_err(mferr("Lock(output)"))?;
|
||||
let stride = if self.stride == 0 {
|
||||
self.coded_w
|
||||
} else {
|
||||
self.stride
|
||||
};
|
||||
// Derive the (possibly 16-aligned) plane height from the real byte count,
|
||||
// never below the coded height the decoder reported.
|
||||
let plane_h = if stride > 0 {
|
||||
((cur as usize) * 2) / (stride * 3)
|
||||
} else {
|
||||
self.coded_h
|
||||
}
|
||||
.max(self.coded_h);
|
||||
let data = std::slice::from_raw_parts(ptr, cur as usize);
|
||||
// Crop the visible picture (width/height at crop_x/crop_y) out of the
|
||||
// padded NV12 plane so the Frame is the true display size.
|
||||
let frame = nv12_to_rgb(
|
||||
data,
|
||||
stride,
|
||||
plane_h,
|
||||
self.crop_x,
|
||||
self.crop_y,
|
||||
self.width,
|
||||
self.height,
|
||||
);
|
||||
let _ = buffer.Unlock();
|
||||
frame.ok_or_else(|| other("NV12→RGB conversion failed"))
|
||||
}
|
||||
|
||||
/// Copy a decoded GPU NV12 texture to a staging texture, map it, convert.
|
||||
unsafe fn dxgi_to_frame(&mut self, dxgi: &IMFDXGIBuffer) -> Result<Frame> {
|
||||
let device = self.device.clone().ok_or_else(|| other("no D3D device"))?;
|
||||
let context = self
|
||||
.context
|
||||
.clone()
|
||||
.ok_or_else(|| other("no D3D context"))?;
|
||||
|
||||
let mut tex_ptr: *mut core::ffi::c_void = std::ptr::null_mut();
|
||||
dxgi.GetResource(&ID3D11Texture2D::IID, &mut tex_ptr)
|
||||
.map_err(mferr("IMFDXGIBuffer::GetResource"))?;
|
||||
if tex_ptr.is_null() {
|
||||
return Err(other("decoded texture was null"));
|
||||
}
|
||||
let src = ID3D11Texture2D::from_raw(tex_ptr);
|
||||
let sub = dxgi.GetSubresourceIndex().unwrap_or(0);
|
||||
|
||||
let mut desc: D3D11_TEXTURE2D_DESC = std::mem::zeroed();
|
||||
src.GetDesc(&mut desc);
|
||||
|
||||
self.ensure_staging(&device, desc.Width, desc.Height, desc.Format)?;
|
||||
let staging = self
|
||||
.staging
|
||||
.as_ref()
|
||||
.ok_or_else(|| other("no staging texture"))?
|
||||
.clone();
|
||||
|
||||
// Copy the one decoded subresource out of the decoder's texture array.
|
||||
context.CopySubresourceRegion(&staging, 0, 0, 0, 0, &src, sub, None);
|
||||
|
||||
let mut mapped: D3D11_MAPPED_SUBRESOURCE = std::mem::zeroed();
|
||||
context
|
||||
.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut mapped))
|
||||
.map_err(mferr("Map(staging)"))?;
|
||||
let row_pitch = mapped.RowPitch as usize;
|
||||
// The staging texture height is the coded (padded) plane height.
|
||||
let plane_h = desc.Height as usize;
|
||||
let total = row_pitch * plane_h * 3 / 2;
|
||||
let data = std::slice::from_raw_parts(mapped.pData as *const u8, total);
|
||||
// Crop to the true visible picture out of the padded GPU NV12 surface.
|
||||
let frame = nv12_to_rgb(
|
||||
data,
|
||||
row_pitch,
|
||||
plane_h,
|
||||
self.crop_x,
|
||||
self.crop_y,
|
||||
self.width,
|
||||
self.height,
|
||||
);
|
||||
context.Unmap(&staging, 0);
|
||||
|
||||
frame.ok_or_else(|| other("NV12→RGB conversion failed"))
|
||||
}
|
||||
|
||||
/// (Re)create the CPU-readable staging texture when the size changes.
|
||||
unsafe fn ensure_staging(
|
||||
&mut self,
|
||||
device: &ID3D11Device,
|
||||
w: u32,
|
||||
h: u32,
|
||||
fmt: DXGI_FORMAT,
|
||||
) -> Result<()> {
|
||||
if self.staging.is_some() && self.staging_dims == (w, h) {
|
||||
return Ok(());
|
||||
}
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: w,
|
||||
Height: h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: fmt,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_STAGING,
|
||||
BindFlags: 0,
|
||||
CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32,
|
||||
MiscFlags: 0,
|
||||
};
|
||||
let mut tex: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&desc, None, Some(&mut tex))
|
||||
.map_err(mferr("CreateTexture2D(staging)"))?;
|
||||
self.staging = tex;
|
||||
self.staging_dims = (w, h);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoDecoder for MediaFoundationDecoder {
|
||||
fn decode(&mut self, au: &[u8]) -> Result<Option<Frame>> {
|
||||
if au.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
unsafe {
|
||||
let sample = self.make_input_sample(au)?;
|
||||
|
||||
// Feed the AU. If the MFT is momentarily full, drain its output and
|
||||
// retry (keeping the most recent frame for this call).
|
||||
let mut guard = 0;
|
||||
loop {
|
||||
match self.transform.ProcessInput(0, &sample, 0) {
|
||||
Ok(()) => break,
|
||||
Err(e) if e.code() == MF_E_NOTACCEPTING => {
|
||||
if let Some(f) = self.pull_output()? {
|
||||
self.pending = Some(f);
|
||||
}
|
||||
guard += 1;
|
||||
if guard > 16 {
|
||||
return Err(other("MFT refused input repeatedly"));
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(mferr("ProcessInput")(e)),
|
||||
}
|
||||
}
|
||||
|
||||
// Pull the decoded picture(s). With zero-latency/no-B-frame input the
|
||||
// common case is exactly one frame per AU.
|
||||
let mut out = self.pending.take();
|
||||
while let Some(f) = self.pull_output()? {
|
||||
out = Some(f);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MediaFoundationDecoder {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
if self.started {
|
||||
let _ = self
|
||||
.transform
|
||||
.ProcessMessage(MFT_MESSAGE_NOTIFY_END_OF_STREAM, 0);
|
||||
let _ = self
|
||||
.transform
|
||||
.ProcessMessage(MFT_MESSAGE_NOTIFY_END_STREAMING, 0);
|
||||
}
|
||||
// Balances the MFStartup in `new` (both are refcounted).
|
||||
let _ = MFShutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: a MediaFoundationDecoder is created on, and used only from, the single
|
||||
// background network thread that owns it (the viewer's decode loop). COM is
|
||||
// initialized multithreaded (MTA) so the MFT is agile, and the D3D11 device is
|
||||
// marked multithread-protected, so moving the decoder to that thread and calling
|
||||
// it there is sound. It is never shared between threads (no `Sync`).
|
||||
unsafe impl Send for MediaFoundationDecoder {}
|
||||
|
||||
/// Create a D3D11 device + DXGI device manager and hand it to the decoder to
|
||||
/// enable DXVA2 hardware decode. Any failure leaves the MFT in software mode.
|
||||
unsafe fn init_d3d(
|
||||
transform: &IMFTransform,
|
||||
) -> Result<(ID3D11Device, ID3D11DeviceContext, IMFDXGIDeviceManager)> {
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
D3D11CreateDevice(
|
||||
None,
|
||||
D3D_DRIVER_TYPE_HARDWARE,
|
||||
HMODULE(std::ptr::null_mut()),
|
||||
D3D11_CREATE_DEVICE_VIDEO_SUPPORT | D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
None,
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
Some(&mut context),
|
||||
)
|
||||
.map_err(mferr("D3D11CreateDevice"))?;
|
||||
let device = device.ok_or_else(|| other("D3D11CreateDevice gave no device"))?;
|
||||
let context = context.ok_or_else(|| other("D3D11CreateDevice gave no context"))?;
|
||||
|
||||
// MF calls the device from its own worker threads.
|
||||
if let Ok(mt) = device.cast::<ID3D11Multithread>() {
|
||||
let _ = mt.SetMultithreadProtected(TRUE);
|
||||
}
|
||||
|
||||
let mut token = 0u32;
|
||||
let mut manager: Option<IMFDXGIDeviceManager> = None;
|
||||
MFCreateDXGIDeviceManager(&mut token, &mut manager)
|
||||
.map_err(mferr("MFCreateDXGIDeviceManager"))?;
|
||||
let manager = manager.ok_or_else(|| other("no DXGI device manager"))?;
|
||||
manager
|
||||
.ResetDevice(&device, token)
|
||||
.map_err(mferr("IMFDXGIDeviceManager::ResetDevice"))?;
|
||||
|
||||
// The ulParam for SET_D3D_MANAGER is the IUnknown* of the device manager.
|
||||
let param = manager.as_raw() as usize;
|
||||
transform
|
||||
.ProcessMessage(MFT_MESSAGE_SET_D3D_MANAGER, param)
|
||||
.map_err(mferr("MFT_MESSAGE_SET_D3D_MANAGER"))?;
|
||||
|
||||
Ok((device, context, manager))
|
||||
}
|
||||
|
||||
/// NV12 → packed RGB8 (BT.601 limited range, matching the openh264 path),
|
||||
/// cropping the visible `w`x`h` picture at offset (`off_x`, `off_y`) out of the
|
||||
/// (possibly padded) coded plane described by `stride` x `plane_h`.
|
||||
///
|
||||
/// `plane_h` is the coded/aligned luma height: the interleaved UV plane starts
|
||||
/// at `stride * plane_h`, so it must be the padded height, not the visible one.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn nv12_to_rgb(
|
||||
data: &[u8],
|
||||
stride: usize,
|
||||
plane_h: usize,
|
||||
off_x: usize,
|
||||
off_y: usize,
|
||||
w: usize,
|
||||
h: usize,
|
||||
) -> Option<Frame> {
|
||||
if w == 0 || h == 0 || stride == 0 {
|
||||
return None;
|
||||
}
|
||||
let uv_base = stride * plane_h;
|
||||
let mut rgb = vec![0u8; w * h * 3];
|
||||
for row in 0..h {
|
||||
let sy = off_y + row; // source luma row inside the coded plane
|
||||
let y_off = sy * stride;
|
||||
let uv_off = uv_base + (sy / 2) * stride;
|
||||
let out_off = row * w * 3;
|
||||
for col in 0..w {
|
||||
let sx = off_x + col; // source luma column inside the coded plane
|
||||
let y = *data.get(y_off + sx).unwrap_or(&16) as i32;
|
||||
let uvx = sx & !1; // chroma pair aligned to the even luma column
|
||||
let u = *data.get(uv_off + uvx).unwrap_or(&128) as i32;
|
||||
let v = *data.get(uv_off + uvx + 1).unwrap_or(&128) as i32;
|
||||
let c = y - 16;
|
||||
let d = u - 128;
|
||||
let e = v - 128;
|
||||
let r = ((298 * c + 409 * e + 128) >> 8).clamp(0, 255) as u8;
|
||||
let g = ((298 * c - 100 * d - 208 * e + 128) >> 8).clamp(0, 255) as u8;
|
||||
let b = ((298 * c + 516 * d + 128) >> 8).clamp(0, 255) as u8;
|
||||
let o = out_off + col * 3;
|
||||
rgb[o] = r;
|
||||
rgb[o + 1] = g;
|
||||
rgb[o + 2] = b;
|
||||
}
|
||||
}
|
||||
Some(Frame { w, h, rgb })
|
||||
}
|
||||
|
||||
/// Read an `MFVideoArea` aperture blob (display / geometric / pan-scan) off a
|
||||
/// media type and return `(offset_x, offset_y, width, height)` in whole pixels,
|
||||
/// or `None` when the attribute is absent or degenerate.
|
||||
///
|
||||
/// `MFVideoArea` is a fixed 16-byte POD (`MFOffset` x,y + `SIZE`), so we read it
|
||||
/// straight into the struct. The `MFOffset` fractional part is ignored: H.264
|
||||
/// display apertures are always integer-pixel aligned.
|
||||
unsafe fn read_aperture(
|
||||
t: &IMFMediaType,
|
||||
key: *const windows::core::GUID,
|
||||
) -> Option<(usize, usize, usize, usize)> {
|
||||
let mut area = MFVideoArea::default();
|
||||
let buf = std::slice::from_raw_parts_mut(
|
||||
(&mut area as *mut MFVideoArea).cast::<u8>(),
|
||||
std::mem::size_of::<MFVideoArea>(),
|
||||
);
|
||||
t.GetBlob(key, buf, None).ok()?;
|
||||
let w = area.Area.cx;
|
||||
let h = area.Area.cy;
|
||||
if w <= 0 || h <= 0 {
|
||||
return None;
|
||||
}
|
||||
let ox = area.OffsetX.value.max(0) as usize;
|
||||
let oy = area.OffsetY.value.max(0) as usize;
|
||||
Some((ox, oy, w as usize, h as usize))
|
||||
}
|
||||
|
||||
/// Build a context-tagged error mapper for `windows::core::Error`.
|
||||
fn mferr(ctx: &'static str) -> impl Fn(windows::core::Error) -> NdError {
|
||||
move |e| NdError::Other(anyhow::anyhow!("MediaFoundation {ctx}: {e}"))
|
||||
}
|
||||
|
||||
fn other(msg: &str) -> NdError {
|
||||
NdError::Other(anyhow::anyhow!(msg.to_string()))
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! Linux hardware H.264 decode via VAAPI, driven through FFmpeg/libavcodec.
|
||||
//!
|
||||
//! Pure libva decoding would mean parsing the H.264 bitstream down to slice
|
||||
//! headers and hand-building `VAPictureParameterBufferH264` / reference lists;
|
||||
//! libavcodec already does all of that and exposes VAAPI as a hardware
|
||||
//! accelerator, so this backend opens the libavcodec H.264 decoder with a VAAPI
|
||||
//! `hw_device_ctx`. Decode runs on the GPU (Intel/AMD/`iHD`/`radeonsi` via
|
||||
//! `/dev/dri`), the decoded surface is transferred to system memory (NV12), and
|
||||
//! swscale converts it to the same packed-RGB8 [`Frame`] the openh264 path
|
||||
//! produces, so the viewer is unchanged.
|
||||
//!
|
||||
//! The module is only compiled on Linux (see the `#[cfg(target_os = "linux")]`
|
||||
//! gate in `decode.rs`). If VAAPI is unavailable at runtime —
|
||||
//! `av_hwdevice_ctx_create` fails with no usable DRI device — `new` returns an
|
||||
//! error and [`super::best_decoder`] falls back to the portable openh264
|
||||
//! decoder, so headless / GPU-less Linux boxes still work.
|
||||
|
||||
use super::{Frame, VideoDecoder};
|
||||
use crate::error::{NdError, Result};
|
||||
|
||||
use core::ffi::{c_int, c_void};
|
||||
use ffmpeg_next::ffi::*;
|
||||
|
||||
/// FFmpeg/VAAPI hardware H.264 decoder.
|
||||
pub struct VaapiDecoder {
|
||||
ctx: *mut AVCodecContext,
|
||||
hw_device: *mut AVBufferRef,
|
||||
packet: *mut AVPacket,
|
||||
hw_frame: *mut AVFrame,
|
||||
sw_frame: *mut AVFrame,
|
||||
sws: *mut SwsContext,
|
||||
sws_dims: (i32, i32, i32), // (w, h, src pixel format) the cached scaler is for
|
||||
pts: i64,
|
||||
}
|
||||
|
||||
impl VaapiDecoder {
|
||||
pub fn new() -> Result<Self> {
|
||||
unsafe {
|
||||
// Safe to call repeatedly; wires up codec registration + logging.
|
||||
let _ = ffmpeg_next::init();
|
||||
|
||||
let codec = avcodec_find_decoder(AVCodecID::AV_CODEC_ID_H264);
|
||||
if codec.is_null() {
|
||||
return Err(other("libavcodec has no H.264 decoder"));
|
||||
}
|
||||
let ctx = avcodec_alloc_context3(codec);
|
||||
if ctx.is_null() {
|
||||
return Err(other("avcodec_alloc_context3 failed"));
|
||||
}
|
||||
|
||||
// Create the VAAPI device (default DRI render node) and attach it.
|
||||
let mut hw_device: *mut AVBufferRef = core::ptr::null_mut();
|
||||
let rc = av_hwdevice_ctx_create(
|
||||
&mut hw_device,
|
||||
AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
|
||||
core::ptr::null(),
|
||||
core::ptr::null_mut(),
|
||||
0,
|
||||
);
|
||||
let mut ctx = ctx;
|
||||
if rc < 0 || hw_device.is_null() {
|
||||
avcodec_free_context(&mut ctx);
|
||||
return Err(other(&format!(
|
||||
"av_hwdevice_ctx_create(VAAPI) failed ({rc})"
|
||||
)));
|
||||
}
|
||||
(*ctx).hw_device_ctx = av_buffer_ref(hw_device);
|
||||
(*ctx).get_format = Some(get_hw_format);
|
||||
|
||||
let rc = avcodec_open2(ctx, codec, core::ptr::null_mut());
|
||||
if rc < 0 {
|
||||
av_buffer_unref(&mut hw_device);
|
||||
avcodec_free_context(&mut ctx);
|
||||
return Err(other(&format!("avcodec_open2 failed ({rc})")));
|
||||
}
|
||||
|
||||
let mut packet = av_packet_alloc();
|
||||
let mut hw_frame = av_frame_alloc();
|
||||
let mut sw_frame = av_frame_alloc();
|
||||
if packet.is_null() || hw_frame.is_null() || sw_frame.is_null() {
|
||||
if !packet.is_null() {
|
||||
av_packet_free(&mut packet);
|
||||
}
|
||||
if !hw_frame.is_null() {
|
||||
av_frame_free(&mut hw_frame);
|
||||
}
|
||||
if !sw_frame.is_null() {
|
||||
av_frame_free(&mut sw_frame);
|
||||
}
|
||||
av_buffer_unref(&mut hw_device);
|
||||
avcodec_free_context(&mut ctx);
|
||||
return Err(other("av_frame_alloc / av_packet_alloc failed"));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
ctx,
|
||||
hw_device,
|
||||
packet,
|
||||
hw_frame,
|
||||
sw_frame,
|
||||
sws: core::ptr::null_mut(),
|
||||
sws_dims: (0, 0, -1),
|
||||
pts: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Transfer a decoded frame to system memory (if it is a GPU surface), crop
|
||||
/// it to the display rectangle, and swscale it to packed RGB8.
|
||||
unsafe fn frame_to_rgb(&mut self) -> Result<Option<Frame>> {
|
||||
// A VAAPI surface must be pulled down to a software NV12 frame first;
|
||||
// a software frame (fallback path) is used as-is.
|
||||
let src: *mut AVFrame =
|
||||
if (*self.hw_frame).format == AVPixelFormat::AV_PIX_FMT_VAAPI as c_int {
|
||||
av_frame_unref(self.sw_frame);
|
||||
let rc = av_hwframe_transfer_data(self.sw_frame, self.hw_frame, 0);
|
||||
if rc < 0 {
|
||||
return Err(other(&format!("av_hwframe_transfer_data failed ({rc})")));
|
||||
}
|
||||
// Carry crop/colour metadata across from the hardware frame.
|
||||
av_frame_copy_props(self.sw_frame, self.hw_frame);
|
||||
self.sw_frame
|
||||
} else {
|
||||
self.hw_frame
|
||||
};
|
||||
|
||||
// Apply the H.264 display crop so width/height are the true picture size
|
||||
// (e.g. 1920x1080) rather than the 16-aligned coded size.
|
||||
let _ = av_frame_apply_cropping(src, 1 /* AV_FRAME_CROP_UNALIGNED */);
|
||||
|
||||
let w = (*src).width;
|
||||
let h = (*src).height;
|
||||
let src_fmt = (*src).format;
|
||||
if w <= 0 || h <= 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// (Re)create the scaler when the geometry or source format changes.
|
||||
if self.sws.is_null() || self.sws_dims != (w, h, src_fmt) {
|
||||
if !self.sws.is_null() {
|
||||
sws_freeContext(self.sws);
|
||||
}
|
||||
self.sws = sws_getContext(
|
||||
w,
|
||||
h,
|
||||
core::mem::transmute::<c_int, AVPixelFormat>(src_fmt),
|
||||
w,
|
||||
h,
|
||||
AVPixelFormat::AV_PIX_FMT_RGB24,
|
||||
SWS_BILINEAR as c_int,
|
||||
core::ptr::null_mut(), // srcFilter: *mut SwsFilter
|
||||
core::ptr::null_mut(), // dstFilter: *mut SwsFilter
|
||||
core::ptr::null(), // param: *const c_double
|
||||
);
|
||||
if self.sws.is_null() {
|
||||
return Err(other("sws_getContext failed"));
|
||||
}
|
||||
self.sws_dims = (w, h, src_fmt);
|
||||
}
|
||||
|
||||
let mut rgb = vec![0u8; (w as usize) * (h as usize) * 3];
|
||||
let dst_data: [*mut u8; 4] = [
|
||||
rgb.as_mut_ptr(),
|
||||
core::ptr::null_mut(),
|
||||
core::ptr::null_mut(),
|
||||
core::ptr::null_mut(),
|
||||
];
|
||||
let dst_linesize: [c_int; 4] = [w * 3, 0, 0, 0];
|
||||
let scaled = sws_scale(
|
||||
self.sws,
|
||||
(*src).data.as_ptr() as *const *const u8,
|
||||
(*src).linesize.as_ptr(),
|
||||
0,
|
||||
h,
|
||||
dst_data.as_ptr(),
|
||||
dst_linesize.as_ptr(),
|
||||
);
|
||||
if scaled <= 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(Frame {
|
||||
w: w as usize,
|
||||
h: h as usize,
|
||||
rgb,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoDecoder for VaapiDecoder {
|
||||
fn decode(&mut self, au: &[u8]) -> Result<Option<Frame>> {
|
||||
if au.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
unsafe {
|
||||
// Point the reusable packet at this AU. The data is not reference
|
||||
// counted (no `buf`), so libavcodec copies what it needs during
|
||||
// `avcodec_send_packet`; the slice only has to outlive this call.
|
||||
av_packet_unref(self.packet);
|
||||
(*self.packet).data = au.as_ptr() as *mut u8;
|
||||
(*self.packet).size = au.len() as c_int;
|
||||
(*self.packet).pts = self.pts;
|
||||
(*self.packet).dts = self.pts;
|
||||
self.pts += 1;
|
||||
|
||||
let mut out: Option<Frame> = None;
|
||||
|
||||
let mut sret = avcodec_send_packet(self.ctx, self.packet);
|
||||
if sret < 0 {
|
||||
// Decoder is full: drain a frame, keep it, then resend.
|
||||
av_frame_unref(self.hw_frame);
|
||||
if avcodec_receive_frame(self.ctx, self.hw_frame) == 0 {
|
||||
out = self.frame_to_rgb()?;
|
||||
}
|
||||
sret = avcodec_send_packet(self.ctx, self.packet);
|
||||
}
|
||||
// Detach our borrowed buffer from the packet before it is reused.
|
||||
(*self.packet).data = core::ptr::null_mut();
|
||||
(*self.packet).size = 0;
|
||||
if sret < 0 && out.is_none() {
|
||||
// EAGAIN / not-yet-ready before the first keyframe: no frame.
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
av_frame_unref(self.hw_frame);
|
||||
if avcodec_receive_frame(self.ctx, self.hw_frame) == 0 {
|
||||
out = self.frame_to_rgb()?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for VaapiDecoder {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
if !self.sws.is_null() {
|
||||
sws_freeContext(self.sws);
|
||||
}
|
||||
if !self.sw_frame.is_null() {
|
||||
av_frame_free(&mut self.sw_frame);
|
||||
}
|
||||
if !self.hw_frame.is_null() {
|
||||
av_frame_free(&mut self.hw_frame);
|
||||
}
|
||||
if !self.packet.is_null() {
|
||||
av_packet_free(&mut self.packet);
|
||||
}
|
||||
if !self.hw_device.is_null() {
|
||||
av_buffer_unref(&mut self.hw_device);
|
||||
}
|
||||
if !self.ctx.is_null() {
|
||||
avcodec_free_context(&mut self.ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: the decoder is created on, and used only from, the single background
|
||||
// decode thread that owns it. The libavcodec context and its frames/packet are
|
||||
// never shared across threads here (no `Sync`); moving ownership to that thread
|
||||
// and calling it there is sound.
|
||||
unsafe impl Send for VaapiDecoder {}
|
||||
|
||||
/// `get_format` callback: prefer the VAAPI hardware pixel format when the
|
||||
/// decoder offers it, otherwise fall back to the first (software) format so a
|
||||
/// GPU-less box still decodes.
|
||||
unsafe extern "C" fn get_hw_format(
|
||||
_ctx: *mut AVCodecContext,
|
||||
mut fmts: *const AVPixelFormat,
|
||||
) -> AVPixelFormat {
|
||||
let first = if fmts.is_null() {
|
||||
AVPixelFormat::AV_PIX_FMT_NONE
|
||||
} else {
|
||||
*fmts
|
||||
};
|
||||
while !fmts.is_null() && *fmts != AVPixelFormat::AV_PIX_FMT_NONE {
|
||||
if *fmts == AVPixelFormat::AV_PIX_FMT_VAAPI {
|
||||
return AVPixelFormat::AV_PIX_FMT_VAAPI;
|
||||
}
|
||||
fmts = fmts.add(1);
|
||||
}
|
||||
first
|
||||
}
|
||||
|
||||
fn other(msg: &str) -> NdError {
|
||||
NdError::Other(anyhow::anyhow!(msg.to_string()))
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
//! Apple hardware H.264 decode via VideoToolbox (`VTDecompressionSession`).
|
||||
//!
|
||||
//! On macOS and iOS this feeds the host's Annex-B access units into a
|
||||
//! `VTDecompressionSession`, which runs on the platform video decode hardware
|
||||
//! (the same engine `AVFoundation` uses). The decoder emits `CVPixelBuffer`s in
|
||||
//! 32-bit BGRA — VideoToolbox does the YUV→RGB colour conversion on the GPU for
|
||||
//! us — which we repack into the same packed-RGB8 [`Frame`] the openh264 path
|
||||
//! produces, so the viewer is unchanged.
|
||||
//!
|
||||
//! This whole module is only compiled on Apple targets (see the
|
||||
//! `#[cfg(any(target_os = "macos", target_os = "ios"))]` gate in `decode.rs`).
|
||||
//! If anything here fails at runtime, [`super::best_decoder`] falls back to the
|
||||
//! portable openh264 software decoder.
|
||||
//!
|
||||
//! ## Design
|
||||
//! * VideoToolbox needs a `CMVideoFormatDescription` built from the stream's SPS
|
||||
//! and PPS before a session can exist, so the session is created lazily on the
|
||||
//! first access unit that carries both (a keyframe). AUs seen before that are
|
||||
//! dropped (`Ok(None)`), exactly like the other backends while they prime.
|
||||
//! * The host sends Annex-B (start-code delimited) NAL units; VideoToolbox wants
|
||||
//! AVCC (4-byte length-prefixed) NALs in the `CMSampleBuffer`, so each AU is
|
||||
//! repacked. Parameter-set NALs go into the format description, not the sample.
|
||||
//! * Decode is driven synchronously: we submit the frame and immediately call
|
||||
//! `VTDecompressionSessionWaitForAsynchronousFrames`, so the output callback
|
||||
//! has run (and stored the frame in our heap-pinned [`Shared`] slot) by the
|
||||
//! time `decode` returns.
|
||||
//! * `CVPixelBufferGetWidth`/`Height` already return the *display* size (the
|
||||
//! decoder applies the SPS crop), so there is no padded-dimension problem to
|
||||
//! correct here the way there is on the Media Foundation path.
|
||||
|
||||
use super::{Frame, VideoDecoder};
|
||||
use crate::error::{NdError, Result};
|
||||
|
||||
use core::ffi::c_void;
|
||||
|
||||
use core_foundation::base::TCFType;
|
||||
use core_foundation::dictionary::CFDictionary;
|
||||
use core_foundation::number::CFNumber;
|
||||
use core_foundation::string::CFString;
|
||||
|
||||
use self::sys::*;
|
||||
|
||||
/// VideoToolbox hardware H.264 decoder.
|
||||
pub struct VideoToolboxDecoder {
|
||||
/// Decompression session; `null` until the first keyframe primes it.
|
||||
session: VTDecompressionSessionRef,
|
||||
/// Format description built from the current SPS/PPS; owned (+1 ref).
|
||||
format: CMFormatDescriptionRef,
|
||||
/// Cached parameter sets, so we only rebuild the session when they change.
|
||||
sps: Vec<u8>,
|
||||
pps: Vec<u8>,
|
||||
/// Heap-pinned output slot the C callback writes the decoded frame into.
|
||||
/// Boxed so its address is stable for the `refcon` handed to the session.
|
||||
shared: Box<Shared>,
|
||||
pts: i64,
|
||||
}
|
||||
|
||||
/// Output slot shared with the decompression callback via a raw `refcon`.
|
||||
struct Shared {
|
||||
frame: Option<Frame>,
|
||||
}
|
||||
|
||||
impl VideoToolboxDecoder {
|
||||
/// Prepare an (empty) decoder. The real session is built lazily once the
|
||||
/// first SPS+PPS arrive, so this never fails on its own.
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(Self {
|
||||
session: core::ptr::null_mut(),
|
||||
format: core::ptr::null_mut(),
|
||||
sps: Vec::new(),
|
||||
pps: Vec::new(),
|
||||
shared: Box::new(Shared { frame: None }),
|
||||
pts: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// (Re)build the format description and decompression session from the
|
||||
/// current `self.sps`/`self.pps`.
|
||||
fn rebuild_session(&mut self) -> Result<()> {
|
||||
self.teardown();
|
||||
|
||||
// CMVideoFormatDescription from the two parameter sets (raw NAL bytes,
|
||||
// no start codes), with a 4-byte NAL length prefix for the AVCC samples.
|
||||
let ptrs: [*const u8; 2] = [self.sps.as_ptr(), self.pps.as_ptr()];
|
||||
let sizes: [usize; 2] = [self.sps.len(), self.pps.len()];
|
||||
let mut format: CMFormatDescriptionRef = core::ptr::null_mut();
|
||||
let status = unsafe {
|
||||
CMVideoFormatDescriptionCreateFromH264ParameterSets(
|
||||
core::ptr::null(),
|
||||
2,
|
||||
ptrs.as_ptr(),
|
||||
sizes.as_ptr(),
|
||||
4,
|
||||
&mut format,
|
||||
)
|
||||
};
|
||||
if status != 0 || format.is_null() {
|
||||
return Err(other(&format!(
|
||||
"CMVideoFormatDescriptionCreateFromH264ParameterSets failed ({status})"
|
||||
)));
|
||||
}
|
||||
self.format = format;
|
||||
|
||||
// Ask VideoToolbox to hand us ready-to-blit 32-bit BGRA pixel buffers.
|
||||
let key = unsafe { CFString::wrap_under_get_rule(kCVPixelBufferPixelFormatTypeKey) };
|
||||
let val = CFNumber::from(kCVPixelFormatType_32BGRA);
|
||||
let attrs = CFDictionary::from_CFType_pairs(&[(key.as_CFType(), val.as_CFType())]);
|
||||
|
||||
let mut record = VTDecompressionOutputCallbackRecord {
|
||||
decompressionOutputCallback: Some(decompression_callback),
|
||||
decompressionOutputRefCon: (&mut *self.shared) as *mut Shared as *mut c_void,
|
||||
};
|
||||
let mut session: VTDecompressionSessionRef = core::ptr::null_mut();
|
||||
let status = unsafe {
|
||||
VTDecompressionSessionCreate(
|
||||
core::ptr::null(),
|
||||
self.format,
|
||||
core::ptr::null(), // default decoder (hardware when available)
|
||||
attrs.as_concrete_TypeRef() as _, // destination buffer attributes
|
||||
&record,
|
||||
&mut session,
|
||||
)
|
||||
};
|
||||
// `attrs`/`record` are retained/copied by the call above; drop them now.
|
||||
let _ = record.decompressionOutputRefCon;
|
||||
if status != 0 || session.is_null() {
|
||||
return Err(other(&format!(
|
||||
"VTDecompressionSessionCreate failed ({status})"
|
||||
)));
|
||||
}
|
||||
self.session = session;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decode one AVCC-packed sample through the session, returning its frame.
|
||||
fn decode_sample(&mut self, avcc: &[u8]) -> Result<Option<Frame>> {
|
||||
if self.session.is_null() || avcc.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Wrap the AVCC bytes in a CMBlockBuffer (which owns a private copy).
|
||||
let mut block: CMBlockBufferRef = core::ptr::null_mut();
|
||||
let status = unsafe {
|
||||
CMBlockBufferCreateWithMemoryBlock(
|
||||
core::ptr::null(),
|
||||
core::ptr::null_mut(),
|
||||
avcc.len(),
|
||||
core::ptr::null(), // default block allocator
|
||||
core::ptr::null(),
|
||||
0,
|
||||
avcc.len(),
|
||||
kCMBlockBufferAssureMemoryNowFlag,
|
||||
&mut block,
|
||||
)
|
||||
};
|
||||
if status != 0 || block.is_null() {
|
||||
return Err(other(&format!(
|
||||
"CMBlockBufferCreateWithMemoryBlock ({status})"
|
||||
)));
|
||||
}
|
||||
let status = unsafe {
|
||||
CMBlockBufferReplaceDataBytes(avcc.as_ptr() as *const c_void, block, 0, avcc.len())
|
||||
};
|
||||
if status != 0 {
|
||||
unsafe { CFRelease(block as _) };
|
||||
return Err(other(&format!("CMBlockBufferReplaceDataBytes ({status})")));
|
||||
}
|
||||
|
||||
// Build the sample buffer with timing and size.
|
||||
let timing = CMSampleTimingInfo {
|
||||
duration: CMTime {
|
||||
value: HNS_PER_FRAME,
|
||||
timescale: 10_000_000,
|
||||
flags: K_CMTIME_FLAGS_VALID,
|
||||
epoch: 0,
|
||||
},
|
||||
presentationTimeStamp: CMTime {
|
||||
value: self.pts,
|
||||
timescale: 10_000_000,
|
||||
flags: K_CMTIME_FLAGS_VALID,
|
||||
epoch: 0,
|
||||
},
|
||||
decodeTimeStamp: CMTime {
|
||||
value: 0,
|
||||
timescale: 0,
|
||||
flags: 0,
|
||||
epoch: 0,
|
||||
},
|
||||
};
|
||||
self.pts += HNS_PER_FRAME;
|
||||
let sizes = [avcc.len()];
|
||||
let mut sample: CMSampleBufferRef = core::ptr::null_mut();
|
||||
let status = unsafe {
|
||||
CMSampleBufferCreateReady(
|
||||
core::ptr::null(),
|
||||
block,
|
||||
self.format,
|
||||
1,
|
||||
1,
|
||||
&timing,
|
||||
1,
|
||||
sizes.as_ptr(),
|
||||
&mut sample,
|
||||
)
|
||||
};
|
||||
unsafe { CFRelease(block as _) };
|
||||
if status != 0 || sample.is_null() {
|
||||
return Err(other(&format!("CMSampleBufferCreateReady ({status})")));
|
||||
}
|
||||
|
||||
// Submit synchronously: the callback runs before Wait returns.
|
||||
self.shared.frame = None;
|
||||
let mut info_flags: u32 = 0;
|
||||
let status = unsafe {
|
||||
VTDecompressionSessionDecodeFrame(
|
||||
self.session,
|
||||
sample,
|
||||
0,
|
||||
core::ptr::null_mut(),
|
||||
&mut info_flags,
|
||||
)
|
||||
};
|
||||
if status == 0 {
|
||||
unsafe { VTDecompressionSessionWaitForAsynchronousFrames(self.session) };
|
||||
}
|
||||
unsafe { CFRelease(sample as _) };
|
||||
if status != 0 {
|
||||
return Err(other(&format!(
|
||||
"VTDecompressionSessionDecodeFrame ({status})"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(self.shared.frame.take())
|
||||
}
|
||||
|
||||
fn teardown(&mut self) {
|
||||
unsafe {
|
||||
if !self.session.is_null() {
|
||||
VTDecompressionSessionInvalidate(self.session);
|
||||
CFRelease(self.session as _);
|
||||
self.session = core::ptr::null_mut();
|
||||
}
|
||||
if !self.format.is_null() {
|
||||
CFRelease(self.format as _);
|
||||
self.format = core::ptr::null_mut();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoDecoder for VideoToolboxDecoder {
|
||||
fn decode(&mut self, au: &[u8]) -> Result<Option<Frame>> {
|
||||
if au.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Split the Annex-B AU, pulling out parameter sets and repacking the
|
||||
// decodable NALs as AVCC (4-byte big-endian length prefixes).
|
||||
let mut new_sps: Option<Vec<u8>> = None;
|
||||
let mut new_pps: Option<Vec<u8>> = None;
|
||||
let mut avcc: Vec<u8> = Vec::with_capacity(au.len() + 16);
|
||||
for_each_nal(au, |nal| {
|
||||
match nal.first().map(|b| b & 0x1F) {
|
||||
Some(7) => new_sps = Some(nal.to_vec()), // SPS
|
||||
Some(8) => new_pps = Some(nal.to_vec()), // PPS
|
||||
Some(9) => {} // AUD: drop
|
||||
Some(_) => {
|
||||
avcc.extend_from_slice(&(nal.len() as u32).to_be_bytes());
|
||||
avcc.extend_from_slice(nal);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
|
||||
// A keyframe carries fresh parameter sets; rebuild the session when they
|
||||
// change (first keyframe, or a resolution/profile switch mid-stream).
|
||||
if let (Some(sps), Some(pps)) = (new_sps, new_pps) {
|
||||
if sps != self.sps || pps != self.pps || self.session.is_null() {
|
||||
self.sps = sps;
|
||||
self.pps = pps;
|
||||
self.rebuild_session()?;
|
||||
}
|
||||
}
|
||||
|
||||
self.decode_sample(&avcc)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for VideoToolboxDecoder {
|
||||
fn drop(&mut self) {
|
||||
self.teardown();
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: the decoder is created on, and only ever touched from, the single
|
||||
// background decode thread that owns it (the viewer's network loop). The
|
||||
// VideoToolbox session is used synchronously and never shared across threads
|
||||
// (no `Sync`), and the `Shared` slot the callback writes is reached only while
|
||||
// that same thread is blocked inside `WaitForAsynchronousFrames`.
|
||||
unsafe impl Send for VideoToolboxDecoder {}
|
||||
|
||||
/// ~29.97 fps in 100-ns units; only needs to be monotonic for this stream.
|
||||
const HNS_PER_FRAME: i64 = 333_667;
|
||||
|
||||
/// Iterate the Annex-B NAL units in `au`, trimming trailing zero bytes (which
|
||||
/// belong to the next start code / are RBSP padding) so each slice is the raw
|
||||
/// NAL, start-code excluded.
|
||||
fn for_each_nal<F: FnMut(&[u8])>(au: &[u8], mut f: F) {
|
||||
let n = au.len();
|
||||
// Payload start offset of every `00 00 01` start code (a 4-byte start code
|
||||
// `00 00 00 01` is matched on its trailing three bytes, the extra leading
|
||||
// zero falling into the previous NAL's trailing-zero trim).
|
||||
let mut starts: Vec<usize> = Vec::new();
|
||||
let mut i = 0usize;
|
||||
while i + 2 < n {
|
||||
if au[i] == 0 && au[i + 1] == 0 && au[i + 2] == 1 {
|
||||
starts.push(i + 3);
|
||||
i += 3;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
for k in 0..starts.len() {
|
||||
let begin = starts[k];
|
||||
let end = if k + 1 < starts.len() {
|
||||
starts[k + 1] - 3
|
||||
} else {
|
||||
n
|
||||
};
|
||||
if begin >= end {
|
||||
continue;
|
||||
}
|
||||
let mut nal = &au[begin..end];
|
||||
while let [rest @ .., 0] = nal {
|
||||
nal = rest;
|
||||
}
|
||||
if !nal.is_empty() {
|
||||
f(nal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// VideoToolbox output callback: convert the decoded BGRA pixel buffer to a
|
||||
/// packed-RGB8 [`Frame`] and stash it in the shared slot pointed to by `refcon`.
|
||||
extern "C" fn decompression_callback(
|
||||
refcon: *mut c_void,
|
||||
_source_frame_refcon: *mut c_void,
|
||||
status: i32,
|
||||
_info_flags: u32,
|
||||
image_buffer: CVImageBufferRef,
|
||||
_pts: CMTime,
|
||||
_duration: CMTime,
|
||||
) {
|
||||
if refcon.is_null() || status != 0 || image_buffer.is_null() {
|
||||
return;
|
||||
}
|
||||
let shared = unsafe { &mut *(refcon as *mut Shared) };
|
||||
let pb = image_buffer; // CVImageBufferRef and CVPixelBufferRef are the same handle
|
||||
unsafe {
|
||||
if CVPixelBufferLockBaseAddress(pb, K_CVPIXELBUFFER_LOCK_READ_ONLY) != 0 {
|
||||
return;
|
||||
}
|
||||
let w = CVPixelBufferGetWidth(pb);
|
||||
let h = CVPixelBufferGetHeight(pb);
|
||||
let stride = CVPixelBufferGetBytesPerRow(pb);
|
||||
let base = CVPixelBufferGetBaseAddress(pb) as *const u8;
|
||||
if !base.is_null() && w > 0 && h > 0 && stride >= w * 4 {
|
||||
let src = core::slice::from_raw_parts(base, stride * h);
|
||||
let mut rgb = vec![0u8; w * h * 3];
|
||||
for row in 0..h {
|
||||
let srow = row * stride;
|
||||
let drow = row * w * 3;
|
||||
for col in 0..w {
|
||||
let s = srow + col * 4; // BGRA
|
||||
let d = drow + col * 3;
|
||||
rgb[d] = src[s + 2]; // R
|
||||
rgb[d + 1] = src[s + 1]; // G
|
||||
rgb[d + 2] = src[s]; // B
|
||||
}
|
||||
}
|
||||
shared.frame = Some(Frame { w, h, rgb });
|
||||
}
|
||||
CVPixelBufferUnlockBaseAddress(pb, K_CVPIXELBUFFER_LOCK_READ_ONLY);
|
||||
}
|
||||
}
|
||||
|
||||
fn other(msg: &str) -> NdError {
|
||||
NdError::Other(anyhow::anyhow!(msg.to_string()))
|
||||
}
|
||||
|
||||
/// Minimal, self-contained FFI surface for the CoreMedia / CoreVideo /
|
||||
/// VideoToolbox C APIs this decoder uses. These ABIs are stable across macOS and
|
||||
/// iOS releases; the `CFDictionaryRef`/`CFStringRef` types are shared with the
|
||||
/// `core-foundation` crate so the attribute dictionary bridges cleanly.
|
||||
#[allow(non_snake_case, non_upper_case_globals)]
|
||||
mod sys {
|
||||
use core::ffi::c_void;
|
||||
use core_foundation_sys::base::{CFAllocatorRef, CFRelease as cf_release, CFTypeRef};
|
||||
use core_foundation_sys::dictionary::CFDictionaryRef;
|
||||
use core_foundation_sys::string::CFStringRef;
|
||||
|
||||
pub type OSStatus = i32;
|
||||
pub type CMFormatDescriptionRef = *mut c_void;
|
||||
pub type CMVideoFormatDescriptionRef = CMFormatDescriptionRef;
|
||||
pub type CMBlockBufferRef = *mut c_void;
|
||||
pub type CMSampleBufferRef = *mut c_void;
|
||||
pub type VTDecompressionSessionRef = *mut c_void;
|
||||
pub type CVImageBufferRef = *mut c_void;
|
||||
pub type CVPixelBufferRef = *mut c_void;
|
||||
|
||||
/// `kCVPixelFormatType_32BGRA` ('BGRA').
|
||||
pub const kCVPixelFormatType_32BGRA: i32 = 0x4247_5241;
|
||||
/// `kCVPixelBufferLock_ReadOnly`.
|
||||
pub const K_CVPIXELBUFFER_LOCK_READ_ONLY: u64 = 0x0000_0001;
|
||||
/// `kCMTimeFlags_Valid`.
|
||||
pub const K_CMTIME_FLAGS_VALID: u32 = 0x0000_0001;
|
||||
/// `kCMBlockBufferAssureMemoryNowFlag`.
|
||||
pub const kCMBlockBufferAssureMemoryNowFlag: u32 = 0x0000_0001;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct CMTime {
|
||||
pub value: i64,
|
||||
pub timescale: i32,
|
||||
pub flags: u32,
|
||||
pub epoch: i64,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct CMSampleTimingInfo {
|
||||
pub duration: CMTime,
|
||||
pub presentationTimeStamp: CMTime,
|
||||
pub decodeTimeStamp: CMTime,
|
||||
}
|
||||
|
||||
pub type VTDecompressionOutputCallback = extern "C" fn(
|
||||
refcon: *mut c_void,
|
||||
source_frame_refcon: *mut c_void,
|
||||
status: OSStatus,
|
||||
info_flags: u32,
|
||||
image_buffer: CVImageBufferRef,
|
||||
pts: CMTime,
|
||||
duration: CMTime,
|
||||
);
|
||||
|
||||
#[repr(C)]
|
||||
pub struct VTDecompressionOutputCallbackRecord {
|
||||
pub decompressionOutputCallback: Option<VTDecompressionOutputCallback>,
|
||||
pub decompressionOutputRefCon: *mut c_void,
|
||||
}
|
||||
|
||||
/// Re-export CoreFoundation's `CFRelease` under the name this module uses.
|
||||
#[inline]
|
||||
pub unsafe fn CFRelease(obj: CFTypeRef) {
|
||||
cf_release(obj)
|
||||
}
|
||||
|
||||
#[link(name = "CoreMedia", kind = "framework")]
|
||||
extern "C" {
|
||||
pub fn CMVideoFormatDescriptionCreateFromH264ParameterSets(
|
||||
allocator: CFAllocatorRef,
|
||||
parameter_set_count: usize,
|
||||
parameter_set_pointers: *const *const u8,
|
||||
parameter_set_sizes: *const usize,
|
||||
nal_unit_header_length: i32,
|
||||
format_description_out: *mut CMFormatDescriptionRef,
|
||||
) -> OSStatus;
|
||||
|
||||
pub fn CMBlockBufferCreateWithMemoryBlock(
|
||||
structure_allocator: CFAllocatorRef,
|
||||
memory_block: *mut c_void,
|
||||
block_length: usize,
|
||||
block_allocator: CFAllocatorRef,
|
||||
custom_block_source: *const c_void,
|
||||
offset_to_data: usize,
|
||||
data_length: usize,
|
||||
flags: u32,
|
||||
block_buffer_out: *mut CMBlockBufferRef,
|
||||
) -> OSStatus;
|
||||
|
||||
pub fn CMBlockBufferReplaceDataBytes(
|
||||
source_bytes: *const c_void,
|
||||
destination_buffer: CMBlockBufferRef,
|
||||
offset_into_destination: usize,
|
||||
data_length: usize,
|
||||
) -> OSStatus;
|
||||
|
||||
pub fn CMSampleBufferCreateReady(
|
||||
allocator: CFAllocatorRef,
|
||||
data_buffer: CMBlockBufferRef,
|
||||
format_description: CMFormatDescriptionRef,
|
||||
num_samples: isize,
|
||||
num_sample_timing_entries: isize,
|
||||
sample_timing_array: *const CMSampleTimingInfo,
|
||||
num_sample_size_entries: isize,
|
||||
sample_size_array: *const usize,
|
||||
sample_buffer_out: *mut CMSampleBufferRef,
|
||||
) -> OSStatus;
|
||||
}
|
||||
|
||||
#[link(name = "VideoToolbox", kind = "framework")]
|
||||
extern "C" {
|
||||
pub fn VTDecompressionSessionCreate(
|
||||
allocator: CFAllocatorRef,
|
||||
video_format_description: CMVideoFormatDescriptionRef,
|
||||
video_decoder_specification: CFDictionaryRef,
|
||||
destination_image_buffer_attributes: CFDictionaryRef,
|
||||
output_callback: *const VTDecompressionOutputCallbackRecord,
|
||||
decompression_session_out: *mut VTDecompressionSessionRef,
|
||||
) -> OSStatus;
|
||||
|
||||
pub fn VTDecompressionSessionDecodeFrame(
|
||||
session: VTDecompressionSessionRef,
|
||||
sample_buffer: CMSampleBufferRef,
|
||||
decode_flags: u32,
|
||||
source_frame_refcon: *mut c_void,
|
||||
info_flags_out: *mut u32,
|
||||
) -> OSStatus;
|
||||
|
||||
pub fn VTDecompressionSessionWaitForAsynchronousFrames(
|
||||
session: VTDecompressionSessionRef,
|
||||
) -> OSStatus;
|
||||
|
||||
pub fn VTDecompressionSessionInvalidate(session: VTDecompressionSessionRef);
|
||||
}
|
||||
|
||||
#[link(name = "CoreVideo", kind = "framework")]
|
||||
extern "C" {
|
||||
pub static kCVPixelBufferPixelFormatTypeKey: CFStringRef;
|
||||
|
||||
pub fn CVPixelBufferLockBaseAddress(pb: CVPixelBufferRef, flags: u64) -> i32;
|
||||
pub fn CVPixelBufferUnlockBaseAddress(pb: CVPixelBufferRef, flags: u64) -> i32;
|
||||
pub fn CVPixelBufferGetBaseAddress(pb: CVPixelBufferRef) -> *mut c_void;
|
||||
pub fn CVPixelBufferGetBytesPerRow(pb: CVPixelBufferRef) -> usize;
|
||||
pub fn CVPixelBufferGetWidth(pb: CVPixelBufferRef) -> usize;
|
||||
pub fn CVPixelBufferGetHeight(pb: CVPixelBufferRef) -> usize;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum NdError {
|
||||
#[error("connection failed: {0}")]
|
||||
Connect(String),
|
||||
#[error("protocol error: {0}")]
|
||||
Protocol(String),
|
||||
#[error("authentication failed: {0}")]
|
||||
Auth(String),
|
||||
#[error("session denied: {0}")]
|
||||
Denied(String),
|
||||
#[error("crypto error: {0}")]
|
||||
Crypto(String),
|
||||
#[error("websocket closed")]
|
||||
Closed,
|
||||
#[error(transparent)]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, NdError>;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,205 @@
|
||||
//! LAN peer discovery over UDP broadcast.
|
||||
//!
|
||||
//! Mirrors RustDesk's `src/lan.rs` query/reply model: a viewer broadcasts a
|
||||
//! small query to the LAN; every reachable host replies with its id, name and
|
||||
//! the port it accepts a direct connection on. The viewer learns each host's IP
|
||||
//! from the reply's source address — so two machines on the same subnet can find
|
||||
//! each other with no rendezvous/signaling server involved (works even when the
|
||||
//! internet is down).
|
||||
//!
|
||||
//! This module is the discovery half; establishing the session over the
|
||||
//! discovered `ip:port` (a direct transport, bypassing the relay) is the wiring
|
||||
//! step that builds on it. Packets are small JSON for debuggability.
|
||||
//!
|
||||
//! Developed by Akronlogic.
|
||||
|
||||
use std::net::{SocketAddr, UdpSocket};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Default UDP port for NetDesk LAN discovery.
|
||||
pub const LAN_PORT: u16 = 28118;
|
||||
|
||||
/// A host discovered on the LAN.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LanHost {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// Where to reach the host directly: its LAN IP (from the reply source) and
|
||||
/// the direct-connect port it advertised.
|
||||
pub addr: SocketAddr,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Query<'a> {
|
||||
t: &'a str, // "netdesk.lan.query"
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Announce {
|
||||
t: String, // "netdesk.lan.host"
|
||||
id: String,
|
||||
name: String,
|
||||
port: u16, // direct-connect port
|
||||
}
|
||||
|
||||
const QUERY_T: &str = "netdesk.lan.query";
|
||||
const HOST_T: &str = "netdesk.lan.host";
|
||||
|
||||
/// The targets a production scan queries: the global broadcast address plus
|
||||
/// loopback (so a host on this same machine is found even where broadcast to
|
||||
/// self is unreliable). A caller may add subnet-directed broadcasts too.
|
||||
pub fn broadcast_targets() -> Vec<SocketAddr> {
|
||||
vec![
|
||||
SocketAddr::from(([255, 255, 255, 255], LAN_PORT)),
|
||||
SocketAddr::from(([127, 0, 0, 1], LAN_PORT)),
|
||||
]
|
||||
}
|
||||
|
||||
/// Host side: answer discovery queries until `stop` is set. Binds UDP
|
||||
/// `0.0.0.0:bind_port` and replies to each query with this host's id/name and
|
||||
/// `direct_port`. Best-effort: returns on a fatal bind error (discovery just
|
||||
/// won't work); transient recv errors are ignored.
|
||||
pub fn respond_to_queries(
|
||||
bind_port: u16,
|
||||
host_id: String,
|
||||
name: String,
|
||||
direct_port: u16,
|
||||
stop: Arc<AtomicBool>,
|
||||
) -> std::io::Result<()> {
|
||||
let socket = UdpSocket::bind(SocketAddr::from(([0, 0, 0, 0], bind_port)))?;
|
||||
socket.set_read_timeout(Some(Duration::from_millis(300)))?;
|
||||
let reply = serde_json::to_vec(&Announce {
|
||||
t: HOST_T.to_string(),
|
||||
id: host_id,
|
||||
name,
|
||||
port: direct_port,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut buf = [0u8; 2048];
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
match socket.recv_from(&mut buf) {
|
||||
Ok((len, from)) => {
|
||||
if is_query(&buf[..len]) {
|
||||
let _ = socket.send_to(&reply, from);
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => continue,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::TimedOut => continue,
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_query(buf: &[u8]) -> bool {
|
||||
serde_json::from_slice::<Query>(buf)
|
||||
.map(|q| q.t == QUERY_T)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Viewer side: broadcast a discovery query to each `target` and collect host
|
||||
/// replies for `timeout`. Production passes the LAN broadcast address
|
||||
/// (`255.255.255.255:LAN_PORT`); tests pass a specific loopback address.
|
||||
/// Deduplicates by host id (first reply wins).
|
||||
pub fn scan(targets: &[SocketAddr], timeout: Duration) -> std::io::Result<Vec<LanHost>> {
|
||||
let socket = UdpSocket::bind(SocketAddr::from(([0, 0, 0, 0], 0)))?;
|
||||
socket.set_broadcast(true).ok(); // needed for 255.255.255.255; harmless otherwise
|
||||
socket.set_read_timeout(Some(Duration::from_millis(200)))?;
|
||||
|
||||
let query = serde_json::to_vec(&Query { t: QUERY_T }).unwrap_or_default();
|
||||
let send_all = || {
|
||||
for t in targets {
|
||||
let _ = socket.send_to(&query, t);
|
||||
}
|
||||
};
|
||||
send_all();
|
||||
|
||||
let mut hosts: Vec<LanHost> = Vec::new();
|
||||
let deadline = Instant::now() + timeout;
|
||||
let mut next_resend = Instant::now() + Duration::from_millis(150);
|
||||
let mut buf = [0u8; 2048];
|
||||
while Instant::now() < deadline {
|
||||
// Re-broadcast periodically: a single UDP query can be lost, and the
|
||||
// responder may not have bound yet when we started.
|
||||
if Instant::now() >= next_resend {
|
||||
send_all();
|
||||
next_resend = Instant::now() + Duration::from_millis(150);
|
||||
}
|
||||
match socket.recv_from(&mut buf) {
|
||||
Ok((len, from)) => {
|
||||
if let Ok(a) = serde_json::from_slice::<Announce>(&buf[..len]) {
|
||||
if a.t == HOST_T && !hosts.iter().any(|h| h.id == a.id) {
|
||||
hosts.push(LanHost {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
addr: SocketAddr::new(from.ip(), a.port),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => continue,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::TimedOut => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
Ok(hosts)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn broadcast_targets_are_global_and_loopback() {
|
||||
let t = broadcast_targets();
|
||||
assert!(t.iter().any(|a| a.ip().to_string() == "255.255.255.255" && a.port() == LAN_PORT));
|
||||
assert!(t.iter().any(|a| a.ip().to_string() == "127.0.0.1" && a.port() == LAN_PORT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_finds_a_responding_host() {
|
||||
// A unique port so parallel tests don't collide.
|
||||
let port = 29871u16;
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let s2 = stop.clone();
|
||||
let host = std::thread::spawn(move || {
|
||||
let _ = respond_to_queries(
|
||||
port,
|
||||
"lan-host-123".to_string(),
|
||||
"Test Host".to_string(),
|
||||
41000,
|
||||
s2,
|
||||
);
|
||||
});
|
||||
// Give the responder a moment to bind.
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
|
||||
// Query loopback directly (broadcast is unreliable on loopback in CI).
|
||||
let targets = [SocketAddr::from(([127, 0, 0, 1], port))];
|
||||
let found = scan(&targets, Duration::from_millis(600)).unwrap();
|
||||
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
let _ = host.join();
|
||||
|
||||
let h = found
|
||||
.iter()
|
||||
.find(|h| h.id == "lan-host-123")
|
||||
.expect("host not discovered on the LAN");
|
||||
assert_eq!(h.name, "Test Host");
|
||||
assert_eq!(h.addr.port(), 41000, "advertised direct-connect port");
|
||||
assert!(h.addr.ip().is_loopback(), "host IP learned from reply source");
|
||||
println!("LAN_DISCOVERY_OK found {} host(s): {:?}", found.len(), h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_query_packets_are_ignored() {
|
||||
assert!(is_query(br#"{"t":"netdesk.lan.query"}"#));
|
||||
assert!(!is_query(br#"{"t":"something.else"}"#));
|
||||
assert!(!is_query(b"garbage"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! NetDesk native core — the compiled Rust engine behind the cross-platform
|
||||
//! client (Windows / macOS / Linux / iOS / Android). Developed by Akronlogic.
|
||||
//!
|
||||
//! This crate owns everything that must be fast and portable: signaling,
|
||||
//! end-to-end crypto, the media transport, and (in later phases) H.264 decode
|
||||
//! and input injection. The Flutter UI calls in over the C ABI in `ffi`.
|
||||
|
||||
pub mod brand;
|
||||
pub mod cidr;
|
||||
pub mod crypto;
|
||||
pub mod decode;
|
||||
pub mod error;
|
||||
pub mod host;
|
||||
pub mod lan;
|
||||
pub mod netqos;
|
||||
pub mod p2p;
|
||||
pub mod proto;
|
||||
pub mod recorder;
|
||||
pub mod rtc;
|
||||
pub mod webm;
|
||||
pub mod signaling;
|
||||
pub mod transport;
|
||||
pub mod update;
|
||||
|
||||
pub use error::{NdError, Result};
|
||||
pub use signaling::{connect, ConnectConfig, Session, SessionEvent};
|
||||
pub use p2p::{upgrade_relay, P2pControl, P2pRelay, P2pRole};
|
||||
pub use transport::{Lane, Transport, TransportMsg, WebRtcChannel, WsRelay};
|
||||
pub use rtc::{advertise_max_message_size, answer_offer_web, build_api_web, create_web_channels, upgrade_offerer_web, WebChannels, WebDetached};
|
||||
|
||||
/// Core/build version, surfaced in the UI "about" box.
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
pub const BRAND: &str = "NetDesk";
|
||||
pub const VENDOR: &str = "Akronlogic";
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Network-quality estimators for the adaptive QoS controller — pure +
|
||||
//! deterministic (feed samples, read values), so they unit-test without a live
|
||||
//! link. RustDesk-style signals that replace the old "absolute RTT proxy":
|
||||
//!
|
||||
//! - [`RttCalc`] — a rolling-baseline RTT: the minimum RTT over the recent
|
||||
//! window is the link's inherent latency, and the reported delay is how far
|
||||
//! the latest sample sits ABOVE it. A stable high-latency link reads ~0
|
||||
//! delay; only latency SPIKES (queue buildup) read high. (RustDesk's
|
||||
//! `RttCalculator`.)
|
||||
//! - [`LossCalc`] — packet-loss ratio from ping/pong hit/miss over a window.
|
||||
//! - [`Jitter`] — the viewer measures its frame-arrival jitter (the mean
|
||||
//! absolute deviation of inter-arrival intervals). Smooth delivery ~0;
|
||||
//! bursty/stalled delivery (the decode pipeline falling behind) grows. This
|
||||
//! is a congestion signal RTT alone misses, and it is reported to the host in
|
||||
//! `net.pong` so `Qos::on_sample` gets a real end-to-end delay.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Rolling-baseline RTT delay. `add` returns the delay of the new sample above
|
||||
/// the window minimum (the baseline).
|
||||
#[derive(Debug)]
|
||||
pub struct RttCalc {
|
||||
win: VecDeque<u32>,
|
||||
cap: usize,
|
||||
last: u32,
|
||||
}
|
||||
|
||||
impl Default for RttCalc {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl RttCalc {
|
||||
/// ~30 samples (≈30 s at one probe/second) of history for the baseline.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
win: VecDeque::new(),
|
||||
cap: 30,
|
||||
last: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an RTT sample (ms); returns the delay above the rolling baseline.
|
||||
pub fn add(&mut self, rtt_ms: u32) -> u32 {
|
||||
self.win.push_back(rtt_ms);
|
||||
while self.win.len() > self.cap {
|
||||
self.win.pop_front();
|
||||
}
|
||||
let base = *self.win.iter().min().unwrap_or(&rtt_ms);
|
||||
self.last = rtt_ms.saturating_sub(base);
|
||||
self.last
|
||||
}
|
||||
|
||||
/// The most recent delay-above-baseline.
|
||||
pub fn delay(&self) -> u32 {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
/// Packet-loss ratio over a rolling window of ping/pong outcomes.
|
||||
#[derive(Debug)]
|
||||
pub struct LossCalc {
|
||||
win: VecDeque<bool>,
|
||||
cap: usize,
|
||||
}
|
||||
|
||||
impl Default for LossCalc {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl LossCalc {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
win: VecDeque::new(),
|
||||
cap: 20,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one outcome (`true` = the pong was lost); returns the loss ratio.
|
||||
pub fn record(&mut self, lost: bool) -> f32 {
|
||||
self.win.push_back(lost);
|
||||
while self.win.len() > self.cap {
|
||||
self.win.pop_front();
|
||||
}
|
||||
let n = self.win.len().max(1);
|
||||
self.win.iter().filter(|&&l| l).count() as f32 / n as f32
|
||||
}
|
||||
}
|
||||
|
||||
/// Viewer-side frame-arrival jitter: the mean absolute deviation of recent
|
||||
/// inter-arrival intervals (ms). fps-independent (it measures spread, not the
|
||||
/// absolute cadence), so lowering the frame rate does not read as congestion —
|
||||
/// only bursty / stalled delivery does.
|
||||
#[derive(Debug)]
|
||||
pub struct Jitter {
|
||||
intervals: VecDeque<u32>,
|
||||
cap: usize,
|
||||
last_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for Jitter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Jitter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
intervals: VecDeque::new(),
|
||||
cap: 30,
|
||||
last_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a frame arrival at monotonic `now_ms`; returns the current jitter.
|
||||
pub fn frame(&mut self, now_ms: u64) -> u32 {
|
||||
if let Some(prev) = self.last_ms {
|
||||
// Clamp absurd gaps (a static screen sends nothing for a while) so a
|
||||
// long idle doesn't poison the jitter once frames resume.
|
||||
let dt = now_ms.saturating_sub(prev).min(2_000) as u32;
|
||||
self.intervals.push_back(dt);
|
||||
while self.intervals.len() > self.cap {
|
||||
self.intervals.pop_front();
|
||||
}
|
||||
}
|
||||
self.last_ms = Some(now_ms);
|
||||
self.jitter()
|
||||
}
|
||||
|
||||
/// Current jitter (mean absolute deviation of intervals, ms).
|
||||
pub fn jitter(&self) -> u32 {
|
||||
let n = self.intervals.len();
|
||||
if n < 2 {
|
||||
return 0;
|
||||
}
|
||||
let mean = self.intervals.iter().sum::<u32>() as f64 / n as f64;
|
||||
let mad = self
|
||||
.intervals
|
||||
.iter()
|
||||
.map(|&x| (x as f64 - mean).abs())
|
||||
.sum::<f64>()
|
||||
/ n as f64;
|
||||
mad.round() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rtt_baseline_reports_delay_above_min() {
|
||||
let mut r = RttCalc::new();
|
||||
assert_eq!(r.add(200), 0, "first sample is its own baseline");
|
||||
assert_eq!(r.add(200), 0);
|
||||
assert_eq!(r.add(205), 5, "5 ms above the 200 ms floor");
|
||||
assert_eq!(r.add(350), 150, "a spike reads 150 ms above baseline");
|
||||
assert_eq!(r.add(200), 0, "back at the floor -> no delay");
|
||||
assert_eq!(r.delay(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtt_baseline_rises_when_the_floor_rises() {
|
||||
let mut r = RttCalc::new();
|
||||
// Fill the window so old low samples roll out.
|
||||
for _ in 0..30 {
|
||||
r.add(100);
|
||||
}
|
||||
// A sustained worse link: once the 100s roll out, 300 becomes the floor.
|
||||
for _ in 0..30 {
|
||||
r.add(300);
|
||||
}
|
||||
assert_eq!(r.add(300), 0, "a sustained new floor reads ~0 delay again");
|
||||
assert_eq!(r.add(450), 150, "spike above the new 300 ms floor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loss_ratio_tracks_misses() {
|
||||
let mut l = LossCalc::new();
|
||||
for _ in 0..10 {
|
||||
l.record(false);
|
||||
}
|
||||
assert_eq!(l.record(false), 0.0, "all delivered -> 0 loss");
|
||||
// 2 misses in a 13-sample window.
|
||||
l.record(true);
|
||||
let loss = l.record(true);
|
||||
assert!((loss - 2.0 / 13.0).abs() < 1e-6, "2/13 lost, got {loss}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jitter_is_low_for_steady_delivery_and_high_for_bursts() {
|
||||
let mut steady = Jitter::new();
|
||||
let mut t = 0u64;
|
||||
for _ in 0..20 {
|
||||
t += 33; // a clean ~30 fps cadence
|
||||
steady.frame(t);
|
||||
}
|
||||
assert!(steady.jitter() <= 1, "steady cadence -> ~0 jitter, got {}", steady.jitter());
|
||||
|
||||
let mut bursty = Jitter::new();
|
||||
let mut t = 0u64;
|
||||
for i in 0..20 {
|
||||
t += if i % 2 == 0 { 5 } else { 70 }; // frames bunch then gap
|
||||
bursty.frame(t);
|
||||
}
|
||||
assert!(bursty.jitter() > 20, "bursty delivery -> high jitter, got {}", bursty.jitter());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jitter_ignores_a_long_idle_gap() {
|
||||
let mut j = Jitter::new();
|
||||
j.frame(0);
|
||||
j.frame(33);
|
||||
j.frame(66);
|
||||
// Static screen: a 10 s gap is clamped, not counted as 10 s of jitter.
|
||||
j.frame(10_066);
|
||||
assert!(j.jitter() <= 2_000, "idle gap clamped");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,831 @@
|
||||
//! Relay-first P2P multiplexer with seamless fallback.
|
||||
//!
|
||||
//! [`upgrade_relay`] wraps any [`Transport`] (the WSS relay) and returns another
|
||||
//! `Transport` that the session loop ([`crate::signaling::connect_with`] /
|
||||
//! [`crate::host::run_host_with`]) uses completely unchanged. All the P2P logic
|
||||
//! runs in a background engine task that owns both the relay and, once
|
||||
//! negotiated, the WebRTC DataChannel:
|
||||
//!
|
||||
//! * The relay is LIVE from the first frame and is NEVER torn down while P2P is
|
||||
//! up (warm standby), so the first frame never waits on ICE and a P2P drop is
|
||||
//! invisible to the session.
|
||||
//! * The engine SNIFFS the session `sid` out of the handshake messages flowing
|
||||
//! through it (so neither loop has to hand it over), then drives the
|
||||
//! relay-brokered `rtc.*` handshake ([`crate::rtc::upgrade_offerer`] /
|
||||
//! [`crate::rtc::answer_offer`]).
|
||||
//! * UP edge: when the DataChannel opens AND the nominated ICE pair is DIRECT
|
||||
//! (non-TURN — [`crate::rtc::selected_pair_is_direct`]), media + the
|
||||
//! latency-critical input/keyframe control are routed over the channel; a
|
||||
//! TURN-relayed or unknown pair keeps media on the relay.
|
||||
//! * DOWN edge: if the peer connection fails/closes, media reroutes to the
|
||||
//! still-open relay with no `Ended` and no frame loss.
|
||||
//!
|
||||
//! `rtc.*` and `session.*` ALWAYS ride the relay; only `CHAN_VIDEO` / file
|
||||
//! binary and `i.mouse` / `i.key` / `keyframe` move to P2P when it is direct.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
|
||||
use serde_json::Value;
|
||||
use tokio::sync::mpsc;
|
||||
use webrtc::api::API;
|
||||
use webrtc::data_channel::RTCDataChannel;
|
||||
use webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState;
|
||||
use webrtc::peer_connection::RTCPeerConnection;
|
||||
use webrtc::stats::StatsReportType;
|
||||
|
||||
use crate::error::{NdError, Result};
|
||||
use crate::rtc;
|
||||
use crate::rtc::{RtcIceServerJson, WebDetached};
|
||||
use crate::transport::{install_reassembler, send_fragmented, Lane, Transport, TransportMsg};
|
||||
|
||||
/// A negotiated P2P connection: the native single reliable channel (16 KiB
|
||||
/// app-fragmented) or the web 3-channel topology (media/ctl/move, detached,
|
||||
/// whole frames) used to interop with the browser / Python host.
|
||||
enum P2pConn {
|
||||
Single(Arc<RTCDataChannel>),
|
||||
Web(Arc<WebDetached>),
|
||||
}
|
||||
|
||||
/// High-frequency, latency-critical control that rides the web `ctl` channel
|
||||
/// (the web viewer's send override routes exactly these; other control stays on
|
||||
/// the relay). Cheap prefix checks, no JSON parse on the hot path.
|
||||
fn is_fast_path_text(t: &str) -> bool {
|
||||
t.contains("\"t\":\"i.mouse\"")
|
||||
|| t.contains("\"t\":\"i.key\"")
|
||||
|| t.contains("\"t\":\"keyframe\"")
|
||||
}
|
||||
|
||||
/// Whether the direct-P2P fast path is enabled for this process. DEFAULT-ON:
|
||||
/// the native-peer capability gate keeps P2P to native<->native pairs and every
|
||||
/// other pairing stays on the relay, so default-on cannot break the existing
|
||||
/// web/Python peers. Opt OUT with `NETDESK_P2P=0` (also `false`/`off`/`no`).
|
||||
pub fn p2p_enabled() -> bool {
|
||||
!matches!(
|
||||
std::env::var("NETDESK_P2P").as_deref(),
|
||||
Ok("0") | Ok("false") | Ok("off") | Ok("no") | Ok("OFF") | Ok("FALSE") | Ok("NO")
|
||||
)
|
||||
}
|
||||
|
||||
/// Performance-based path health for the active DIRECT P2P pair.
|
||||
///
|
||||
/// The relay-first state machine already prefers a direct pair and falls back on
|
||||
/// hard failure. This adds the other half of the hybrid: abandon a direct pair
|
||||
/// that is measurably WORSE than a relay hop would be — not just one that
|
||||
/// dropped. It is deliberately conservative (only a *pathologically* high,
|
||||
/// *sustained* RTT trips it; a VPS relay hop is almost always below that), so it
|
||||
/// never fights the normal "direct is faster" case. Feed it the nominated
|
||||
/// candidate pair's RTT (ms) every ~[`HEALTH_INTERVAL`]; `on_rtt` returns true
|
||||
/// once the path has been bad long enough to downgrade.
|
||||
#[derive(Default)]
|
||||
pub struct P2pHealth {
|
||||
bad_streak: u32,
|
||||
}
|
||||
|
||||
/// How often the engine samples the active pair's RTT.
|
||||
pub const HEALTH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
/// A direct-pair RTT at/above this (ms) is "bad" — far worse than a relay hop.
|
||||
pub const P2P_RTT_BAD_MS: u32 = 500;
|
||||
/// Consecutive bad samples before downgrading (~10 s at [`HEALTH_INTERVAL`]).
|
||||
pub const P2P_RTT_CONFIRM: u32 = 5;
|
||||
|
||||
impl P2pHealth {
|
||||
/// Record one RTT sample (ms). `0` means "unknown" (not yet measured) and is
|
||||
/// ignored. Returns true when a downgrade to the relay is warranted.
|
||||
pub fn on_rtt(&mut self, rtt_ms: u32) -> bool {
|
||||
if rtt_ms == 0 {
|
||||
return false;
|
||||
}
|
||||
if rtt_ms >= P2P_RTT_BAD_MS {
|
||||
self.bad_streak += 1;
|
||||
} else {
|
||||
self.bad_streak = 0;
|
||||
}
|
||||
self.bad_streak >= P2P_RTT_CONFIRM
|
||||
}
|
||||
}
|
||||
|
||||
/// The nominated ICE candidate-pair RTT in ms, or 0 if unknown/not yet measured.
|
||||
async fn p2p_pair_rtt_ms(pc: &Arc<RTCPeerConnection>) -> u32 {
|
||||
let report = pc.get_stats().await;
|
||||
for v in report.reports.values() {
|
||||
if let StatsReportType::CandidatePair(cp) = v {
|
||||
if cp.nominated && cp.current_round_trip_time > 0.0 {
|
||||
return (cp.current_round_trip_time * 1000.0).round() as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// Which side of the upgrade handshake this peer drives.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum P2pRole {
|
||||
/// Viewer — sends `rtc.config.req` + the offer.
|
||||
Offerer,
|
||||
/// Host — answers an inbound `rtc.offer`.
|
||||
Answerer,
|
||||
}
|
||||
|
||||
/// SCTP send-queue depth (bytes) past which a fresh MEDIA (video) frame is
|
||||
/// DROPPED rather than queued — fresh beats late. Only the drop-tolerant Media
|
||||
/// lane drops; Ctl (file bytes / control) and Move are always sent reliably.
|
||||
/// Matches the web host's 256 KiB `_send_bin` threshold.
|
||||
const P2P_MEDIA_CAP: usize = 262144;
|
||||
|
||||
/// A handle to force the session back onto the relay (the plan's "force-relay
|
||||
/// fast path", and the test hook for the DOWN edge). Cloneable; outlives the
|
||||
/// transport move into the session loop.
|
||||
#[derive(Clone)]
|
||||
pub struct P2pControl {
|
||||
down: mpsc::UnboundedSender<()>,
|
||||
}
|
||||
|
||||
impl P2pControl {
|
||||
/// Drop any active P2P path and route all media back over the warm relay.
|
||||
pub fn force_relay(&self) {
|
||||
let _ = self.down.send(());
|
||||
}
|
||||
}
|
||||
|
||||
/// The app-facing transport the session loop uses unchanged — just the mailbox
|
||||
/// into the engine task.
|
||||
pub struct P2pRelay {
|
||||
out: mpsc::UnboundedSender<(TransportMsg, Lane)>,
|
||||
inn: mpsc::UnboundedReceiver<Result<TransportMsg>>,
|
||||
down: mpsc::UnboundedSender<()>,
|
||||
_engine: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl P2pRelay {
|
||||
/// A control handle to force media back onto the relay.
|
||||
pub fn control(&self) -> P2pControl {
|
||||
P2pControl {
|
||||
down: self.down.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Transport for P2pRelay {
|
||||
async fn send_binary(&mut self, data: Vec<u8>) -> Result<()> {
|
||||
// No lane hint -> treat as reliable Ctl (never dropped). Callers that
|
||||
// know it is video use send_binary_lane(.., Media).
|
||||
self.out
|
||||
.send((TransportMsg::Binary(data), Lane::Ctl))
|
||||
.map_err(|_| NdError::Connect("p2p engine stopped".into()))
|
||||
}
|
||||
async fn send_binary_lane(&mut self, data: Vec<u8>, lane: Lane) -> Result<()> {
|
||||
self.out
|
||||
.send((TransportMsg::Binary(data), lane))
|
||||
.map_err(|_| NdError::Connect("p2p engine stopped".into()))
|
||||
}
|
||||
async fn send_text(&mut self, text: String) -> Result<()> {
|
||||
self.out
|
||||
.send((TransportMsg::Text(text), Lane::Ctl))
|
||||
.map_err(|_| NdError::Connect("p2p engine stopped".into()))
|
||||
}
|
||||
async fn recv(&mut self) -> Option<Result<TransportMsg>> {
|
||||
self.inn.recv().await
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap `relay` with transparent P2P upgrade + fallback. `api` builds the peer
|
||||
/// connection(s). The returned transport is a drop-in for the relay.
|
||||
pub fn upgrade_relay(relay: Box<dyn Transport>, role: P2pRole, api: Arc<API>) -> P2pRelay {
|
||||
let (app_out_tx, app_out_rx) = mpsc::unbounded_channel::<(TransportMsg, Lane)>();
|
||||
let (app_in_tx, app_in_rx) = mpsc::unbounded_channel::<Result<TransportMsg>>();
|
||||
let (down_tx, down_rx) = mpsc::unbounded_channel::<()>();
|
||||
let engine = tokio::spawn(engine(
|
||||
relay,
|
||||
role,
|
||||
api,
|
||||
app_out_rx,
|
||||
app_in_tx,
|
||||
down_tx.clone(),
|
||||
down_rx,
|
||||
));
|
||||
P2pRelay {
|
||||
out: app_out_tx,
|
||||
inn: app_in_rx,
|
||||
down: down_tx,
|
||||
_engine: engine,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull `sid` out of a control-text message, if present and non-empty.
|
||||
fn sniff_sid_text(t: &str) -> Option<String> {
|
||||
serde_json::from_str::<Value>(t)
|
||||
.ok()
|
||||
.and_then(|v| v.get("sid").and_then(|x| x.as_str()).map(|s| s.to_string()))
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Whether this control text rides the P2P channel when it is up. Everything
|
||||
/// does EXCEPT `session.*` (session lifecycle) and `rtc.*` (upgrade signaling),
|
||||
/// which stay on the relay. Keeping ALL app traffic — input, keyframe requests
|
||||
/// AND file.* control — on the one ordered channel alongside the binary data is
|
||||
/// essential: splitting data (P2P) from its `file.done` (relay) lets the text
|
||||
/// race ahead of the slower-fragmented data and truncate the download. Cheap
|
||||
/// substring checks avoid a JSON parse on the hot path (serde emits `t` first).
|
||||
fn route_text_p2p(t: &str) -> bool {
|
||||
!(t.contains("\"t\":\"session.") || t.contains("\"t\":\"rtc."))
|
||||
}
|
||||
|
||||
type DoneTx = mpsc::UnboundedSender<(Arc<RTCPeerConnection>, P2pConn)>;
|
||||
|
||||
fn spawn_offerer(
|
||||
api: Arc<API>,
|
||||
sid: String,
|
||||
rtc_out: mpsc::UnboundedSender<String>,
|
||||
mut rtc_in: mpsc::UnboundedReceiver<Value>,
|
||||
done: DoneTx,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let send = {
|
||||
let tx = rtc_out.clone();
|
||||
move |s: String| {
|
||||
let _ = tx.send(s);
|
||||
}
|
||||
};
|
||||
match rtc::upgrade_offerer(&api, &sid, None, "netdesk", &send, &mut rtc_in).await {
|
||||
Ok((pc, dc)) => {
|
||||
let _ = done.send((pc, P2pConn::Single(dc)));
|
||||
}
|
||||
Err(e) => tracing::debug!("P2P offerer upgrade abandoned: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// WEB-topology offerer (interop with the browser / Python host). Builds its own
|
||||
/// detach-enabled API, negotiates media/ctl/move, and reports a `P2pConn::Web`.
|
||||
fn spawn_offerer_web(
|
||||
sid: String,
|
||||
rtc_out: mpsc::UnboundedSender<String>,
|
||||
mut rtc_in: mpsc::UnboundedReceiver<Value>,
|
||||
done: DoneTx,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let api = match rtc::build_api_web() {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
tracing::debug!("build_api_web: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let send = {
|
||||
let tx = rtc_out.clone();
|
||||
move |s: String| {
|
||||
let _ = tx.send(s);
|
||||
}
|
||||
};
|
||||
match rtc::upgrade_offerer_web(&api, &sid, None, &send, &mut rtc_in).await {
|
||||
Ok((pc, web)) => {
|
||||
let _ = done.send((pc, P2pConn::Web(Arc::new(web))));
|
||||
}
|
||||
Err(e) => tracing::debug!("P2P web offerer abandoned: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_answerer(
|
||||
api: Arc<API>,
|
||||
sid: String,
|
||||
offer_sdp: String,
|
||||
ice: Vec<RtcIceServerJson>,
|
||||
rtc_out: mpsc::UnboundedSender<String>,
|
||||
done: DoneTx,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let send = {
|
||||
let tx = rtc_out.clone();
|
||||
move |s: String| {
|
||||
let _ = tx.send(s);
|
||||
}
|
||||
};
|
||||
match rtc::answer_offer(&api, &sid, &offer_sdp, ice, &send).await {
|
||||
Ok((pc, dc)) => {
|
||||
let _ = done.send((pc, P2pConn::Single(dc)));
|
||||
}
|
||||
Err(e) => tracing::debug!("P2P answerer abandoned: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// WEB-topology answerer (a browser / web viewer connects to this native host).
|
||||
/// Builds its own detach-enabled API and reports a `P2pConn::Web`.
|
||||
fn spawn_answerer_web(
|
||||
sid: String,
|
||||
offer_sdp: String,
|
||||
ice: Vec<RtcIceServerJson>,
|
||||
rtc_out: mpsc::UnboundedSender<String>,
|
||||
done: DoneTx,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let api = match rtc::build_api_web() {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
tracing::debug!("build_api_web: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let send = {
|
||||
let tx = rtc_out.clone();
|
||||
move |s: String| {
|
||||
let _ = tx.send(s);
|
||||
}
|
||||
};
|
||||
match rtc::answer_offer_web(&api, &sid, &offer_sdp, ice, &send).await {
|
||||
Ok((pc, web)) => {
|
||||
let _ = done.send((pc, P2pConn::Web(Arc::new(web))));
|
||||
}
|
||||
Err(e) => tracing::debug!("P2P web answerer abandoned: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Start one offerer upgrade attempt (the initial one, or a re-upgrade after a
|
||||
/// drop). Creates a fresh rtc_in channel, spawns the Single or Web offerer, and
|
||||
/// returns the sender the relay loop forwards inbound rtc.* to.
|
||||
fn start_offerer_attempt(
|
||||
web: bool,
|
||||
api: Arc<API>,
|
||||
sid: String,
|
||||
rtc_out: mpsc::UnboundedSender<String>,
|
||||
done: DoneTx,
|
||||
) -> mpsc::UnboundedSender<Value> {
|
||||
let (tx, rx) = mpsc::unbounded_channel::<Value>();
|
||||
if web {
|
||||
spawn_offerer_web(sid, rtc_out, rx, done);
|
||||
} else {
|
||||
spawn_offerer(api, sid, rtc_out, rx, done);
|
||||
}
|
||||
tx
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn engine(
|
||||
mut relay: Box<dyn Transport>,
|
||||
role: P2pRole,
|
||||
api: Arc<API>,
|
||||
mut app_out_rx: mpsc::UnboundedReceiver<(TransportMsg, Lane)>,
|
||||
app_in_tx: mpsc::UnboundedSender<Result<TransportMsg>>,
|
||||
down_tx: mpsc::UnboundedSender<()>,
|
||||
mut down_rx: mpsc::UnboundedReceiver<()>,
|
||||
) {
|
||||
let mut sid: Option<String> = None;
|
||||
let mut media_on_p2p = false;
|
||||
let mut p2p_send: Option<P2pConn> = None;
|
||||
let mut _pc: Option<Arc<RTCPeerConnection>> = None;
|
||||
let mut next_frame_id: u32 = 0;
|
||||
let mut offerer_started = false;
|
||||
let mut answerer_started = false;
|
||||
// Performance health of the active direct pair, and a latch: once a direct
|
||||
// pair is abandoned for being too slow, stay on the relay for the rest of
|
||||
// the session (don't re-upgrade into the same bad path and flap).
|
||||
let mut health = P2pHealth::default();
|
||||
let mut health_down = false;
|
||||
|
||||
// Inbound P2P frames (reassembled) land here; held open for the engine's
|
||||
// lifetime so re-use across the session is clean. Fed by install_reassembler
|
||||
// once a channel is established.
|
||||
let (p2p_in_tx, mut p2p_in_rx) = mpsc::unbounded_channel::<TransportMsg>();
|
||||
// rtc.* the upgrade task wants put on the relay.
|
||||
let (rtc_out_tx, mut rtc_out_rx) = mpsc::unbounded_channel::<String>();
|
||||
// Inbound rtc.* forwarded to the CURRENT offerer upgrade attempt. Re-created
|
||||
// per attempt so a re-upgrade after a drop gets a fresh channel.
|
||||
let mut rtc_in_tx: Option<mpsc::UnboundedSender<Value>> = None;
|
||||
// Negotiated (pc, connection).
|
||||
let (done_tx, mut done_rx) =
|
||||
mpsc::unbounded_channel::<(Arc<RTCPeerConnection>, P2pConn)>();
|
||||
// Re-upgrade (hysteresis): after a P2P drop, retry the upgrade once after a
|
||||
// backoff so a transient drop recovers to P2P instead of staying on the
|
||||
// relay. Default 15s; shorter in tests via NETDESK_P2P_REUP_MS.
|
||||
let reup_ms: u64 = std::env::var("NETDESK_P2P_REUP_MS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(15000);
|
||||
let (reup_tx, mut reup_rx) = mpsc::unbounded_channel::<()>();
|
||||
// Offerer topology once decided: Some(false)=native single, Some(true)=web,
|
||||
// None=relay-only. Remembered so a re-upgrade uses the same topology.
|
||||
let mut offerer_mode: Option<bool> = None;
|
||||
// Periodic RTT health probe of the active direct pair.
|
||||
let mut health_tick = tokio::time::interval(HEALTH_INTERVAL);
|
||||
health_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
enum Ev {
|
||||
AppOut(Option<(TransportMsg, Lane)>),
|
||||
RelayIn(Option<Result<TransportMsg>>),
|
||||
P2pIn(Option<TransportMsg>),
|
||||
RtcOut(Option<String>),
|
||||
Done(Option<(Arc<RTCPeerConnection>, P2pConn)>),
|
||||
ReUpgrade,
|
||||
Down,
|
||||
Health,
|
||||
}
|
||||
|
||||
loop {
|
||||
let ev = tokio::select! {
|
||||
m = app_out_rx.recv() => Ev::AppOut(m),
|
||||
m = relay.recv() => Ev::RelayIn(m),
|
||||
m = p2p_in_rx.recv() => Ev::P2pIn(m),
|
||||
s = rtc_out_rx.recv() => Ev::RtcOut(s),
|
||||
d = done_rx.recv() => Ev::Done(d),
|
||||
_ = reup_rx.recv() => Ev::ReUpgrade,
|
||||
_ = down_rx.recv() => Ev::Down,
|
||||
_ = health_tick.tick() => Ev::Health,
|
||||
};
|
||||
|
||||
match ev {
|
||||
// ---- outbound from the session loop ----
|
||||
Ev::AppOut(None) => break, // the session dropped the transport
|
||||
Ev::AppOut(Some((msg, lane))) => {
|
||||
if sid.is_none() {
|
||||
if let TransportMsg::Text(t) = &msg {
|
||||
if let Some(s) = sniff_sid_text(t) {
|
||||
sid = Some(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
match msg {
|
||||
TransportMsg::Binary(b) => match (media_on_p2p, p2p_send.as_ref()) {
|
||||
// native single channel: 16 KiB app-fragments; Media is
|
||||
// drop-tolerant past the queue cap, Ctl/Move reliable.
|
||||
(true, Some(P2pConn::Single(dc))) => {
|
||||
let dc = dc.clone();
|
||||
if lane == Lane::Media && dc.buffered_amount().await > P2P_MEDIA_CAP {
|
||||
// drop (fresh beats late)
|
||||
} else {
|
||||
let fid = next_frame_id;
|
||||
next_frame_id = next_frame_id.wrapping_add(1);
|
||||
if send_fragmented(&dc, fid, &b).await.is_err() {
|
||||
let _ = relay.send_binary_lane(b, lane).await;
|
||||
let _ = down_tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
// web topology: video (Media) rides the media channel whole;
|
||||
// everything else (e.g. file upload) stays on the relay, like
|
||||
// the web viewer. Media is drop-tolerant past the cap.
|
||||
(true, Some(P2pConn::Web(web))) => {
|
||||
if lane == Lane::Media {
|
||||
if web.media.buffered_amount() <= P2P_MEDIA_CAP
|
||||
&& web
|
||||
.media
|
||||
.write_data_channel(&Bytes::from(b.clone()), false)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
let _ = relay.send_binary_lane(b, lane).await;
|
||||
let _ = down_tx.send(());
|
||||
}
|
||||
} else {
|
||||
let _ = relay.send_binary_lane(b, lane).await;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let _ = relay.send_binary_lane(b, lane).await;
|
||||
}
|
||||
},
|
||||
TransportMsg::Text(t) => match (media_on_p2p, p2p_send.as_ref()) {
|
||||
(true, Some(P2pConn::Single(dc))) if route_text_p2p(&t) => {
|
||||
let dc = dc.clone();
|
||||
if dc.send_text(t.clone()).await.is_err() {
|
||||
let _ = relay.send_text(t).await;
|
||||
let _ = down_tx.send(());
|
||||
}
|
||||
}
|
||||
// web: only input / keyframe rides ctl; other control stays
|
||||
// on the relay (the web viewer's send policy).
|
||||
(true, Some(P2pConn::Web(web))) if is_fast_path_text(&t) => {
|
||||
if web
|
||||
.ctl
|
||||
.write_data_channel(&Bytes::from(t.clone().into_bytes()), true)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
let _ = relay.send_text(t).await;
|
||||
let _ = down_tx.send(());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let _ = relay.send_text(t).await;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- inbound from the relay ----
|
||||
Ev::RelayIn(None) => break, // relay closed -> session ends (app recv None)
|
||||
Ev::RelayIn(Some(Err(e))) => {
|
||||
let _ = app_in_tx.send(Err(e));
|
||||
break;
|
||||
}
|
||||
Ev::RelayIn(Some(Ok(m))) => {
|
||||
let mut forward = true;
|
||||
if let TransportMsg::Text(t) = &m {
|
||||
if let Ok(v) = serde_json::from_str::<Value>(t) {
|
||||
let tt = v.get("t").and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||
if sid.is_none() {
|
||||
if let Some(s) = v.get("sid").and_then(|x| x.as_str()) {
|
||||
if !s.is_empty() {
|
||||
sid = Some(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if tt.starts_with("rtc.") {
|
||||
forward = false; // rtc.* is ours, never the app's
|
||||
match role {
|
||||
P2pRole::Offerer => {
|
||||
if let Some(tx) = &rtc_in_tx {
|
||||
let _ = tx.send(v);
|
||||
}
|
||||
}
|
||||
P2pRole::Answerer => {
|
||||
if tt == "rtc.offer" && !answerer_started {
|
||||
answerer_started = true;
|
||||
let sid_s = sid.clone().unwrap_or_default();
|
||||
let is_native =
|
||||
v.get("native").and_then(|x| x.as_bool()) == Some(true);
|
||||
let sdp = v
|
||||
.get("sdp")
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let ice: Vec<RtcIceServerJson> = v
|
||||
.get("ice")
|
||||
.and_then(|x| serde_json::from_value(x.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
if is_native {
|
||||
// native single-channel offerer.
|
||||
spawn_answerer(
|
||||
api.clone(),
|
||||
sid_s,
|
||||
sdp,
|
||||
ice,
|
||||
rtc_out_tx.clone(),
|
||||
done_tx.clone(),
|
||||
);
|
||||
} else if std::env::var("NETDESK_P2P_WEB").is_ok() {
|
||||
// web/3-channel offerer (a browser) — answer in
|
||||
// web topology when opted in.
|
||||
spawn_answerer_web(
|
||||
sid_s,
|
||||
sdp,
|
||||
ice,
|
||||
rtc_out_tx.clone(),
|
||||
done_tx.clone(),
|
||||
);
|
||||
} else {
|
||||
// not opted into web interop: decline so the
|
||||
// pairing stays on the relay.
|
||||
let _ = rtc_out_tx.send(
|
||||
format!(r#"{{"t":"rtc.unavailable","sid":"{sid_s}"}}"#),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if role == P2pRole::Offerer
|
||||
&& !offerer_started
|
||||
&& tt == "session.accept"
|
||||
{
|
||||
// A NATIVE host (info.p2p == "native") uses the native
|
||||
// single-channel upgrade. A non-native host (browser /
|
||||
// Python) uses the WEB 3-channel topology, but only when
|
||||
// NETDESK_P2P_WEB is opted in — so the default stays the
|
||||
// safe native-only behavior until the live cross-matrix
|
||||
// is confirmed.
|
||||
let native_host = v
|
||||
.get("info")
|
||||
.and_then(|i| i.get("p2p"))
|
||||
.and_then(|p| p.as_str())
|
||||
== Some("native");
|
||||
let web_ok = std::env::var("NETDESK_P2P_WEB").is_ok();
|
||||
if native_host {
|
||||
offerer_started = true;
|
||||
offerer_mode = Some(false);
|
||||
rtc_in_tx = Some(start_offerer_attempt(
|
||||
false,
|
||||
api.clone(),
|
||||
sid.clone().unwrap_or_default(),
|
||||
rtc_out_tx.clone(),
|
||||
done_tx.clone(),
|
||||
));
|
||||
} else if web_ok {
|
||||
offerer_started = true;
|
||||
offerer_mode = Some(true);
|
||||
rtc_in_tx = Some(start_offerer_attempt(
|
||||
true,
|
||||
api.clone(),
|
||||
sid.clone().unwrap_or_default(),
|
||||
rtc_out_tx.clone(),
|
||||
done_tx.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if forward {
|
||||
let _ = app_in_tx.send(Ok(m));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- inbound from the P2P channel ----
|
||||
Ev::P2pIn(Some(m)) => {
|
||||
let _ = app_in_tx.send(Ok(m));
|
||||
}
|
||||
Ev::P2pIn(None) => {} // we hold a sender; this does not fire in practice
|
||||
|
||||
// ---- rtc.* the upgrade task wants on the relay ----
|
||||
Ev::RtcOut(Some(s)) => {
|
||||
let _ = relay.send_text(s).await;
|
||||
}
|
||||
Ev::RtcOut(None) => {}
|
||||
|
||||
// ---- UP edge: a channel was negotiated ----
|
||||
Ev::Done(Some((new_pc, conn))) => {
|
||||
let direct = rtc::selected_pair_is_direct(&new_pc).await;
|
||||
match &conn {
|
||||
P2pConn::Single(dc) => {
|
||||
// native: 16 KiB app-fragments reassembled by install_reassembler.
|
||||
install_reassembler(dc, p2p_in_tx.clone());
|
||||
}
|
||||
P2pConn::Web(web) => {
|
||||
// web: whole frames — spawn a detached read-loop per inbound
|
||||
// channel (media = video, ctl = control). Each read is one
|
||||
// complete message (is_string -> Text, else Binary).
|
||||
for raw in [web.media.clone(), web.ctl.clone()] {
|
||||
let tx = p2p_in_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 262144];
|
||||
loop {
|
||||
match raw.read_data_channel(&mut buf).await {
|
||||
Ok((0, _)) => break,
|
||||
Ok((n, is_string)) => {
|
||||
let msg = if is_string {
|
||||
match String::from_utf8(buf[..n].to_vec()) {
|
||||
Ok(s) => TransportMsg::Text(s),
|
||||
Err(_) => continue,
|
||||
}
|
||||
} else {
|
||||
TransportMsg::Binary(buf[..n].to_vec())
|
||||
};
|
||||
let _ = tx.send(msg);
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let dtx = down_tx.clone();
|
||||
new_pc.on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| {
|
||||
let dtx = dtx.clone();
|
||||
Box::pin(async move {
|
||||
if matches!(
|
||||
s,
|
||||
RTCPeerConnectionState::Failed
|
||||
| RTCPeerConnectionState::Disconnected
|
||||
| RTCPeerConnectionState::Closed
|
||||
) {
|
||||
let _ = dtx.send(());
|
||||
}
|
||||
})
|
||||
}));
|
||||
p2p_send = Some(conn);
|
||||
_pc = Some(new_pc);
|
||||
// Only move media off the relay for a DIRECT pair; a TURN-relayed
|
||||
// hop is no better than our own relay.
|
||||
media_on_p2p = direct;
|
||||
tracing::info!(direct, "P2P DataChannel open");
|
||||
if role == P2pRole::Offerer {
|
||||
let _ = app_in_tx.send(Ok(TransportMsg::Text(format!(
|
||||
r#"{{"t":"p2p.state","active":{media_on_p2p},"direct":{direct}}}"#
|
||||
))));
|
||||
}
|
||||
}
|
||||
Ev::Done(None) => {}
|
||||
|
||||
// ---- periodic health probe: abandon a pathologically slow direct
|
||||
// pair for the relay (the "pick the faster path" half of hybrid). ----
|
||||
Ev::Health => {
|
||||
if media_on_p2p && !health_down {
|
||||
if let Some(pc) = &_pc {
|
||||
let rtt = p2p_pair_rtt_ms(pc).await;
|
||||
if health.on_rtt(rtt) {
|
||||
tracing::warn!(
|
||||
rtt_ms = rtt,
|
||||
"P2P direct pair too slow — downgrading to the relay for the session"
|
||||
);
|
||||
health_down = true;
|
||||
let _ = down_tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- DOWN edge: pc failed/closed -> fall back to the warm relay ----
|
||||
Ev::Down => {
|
||||
let was_up = media_on_p2p || p2p_send.is_some();
|
||||
if was_up {
|
||||
tracing::info!("P2P dropped — continuing over the relay");
|
||||
}
|
||||
media_on_p2p = false;
|
||||
p2p_send = None;
|
||||
_pc = None;
|
||||
health = P2pHealth::default(); // reset the RTT streak for the next pair
|
||||
if was_up && role == P2pRole::Offerer {
|
||||
let _ = app_in_tx.send(Ok(TransportMsg::Text(
|
||||
r#"{"t":"p2p.state","active":false,"direct":false}"#.to_string(),
|
||||
)));
|
||||
}
|
||||
// Re-upgrade hysteresis: allow a fresh attempt. The answerer
|
||||
// just re-answers the viewer's next rtc.offer; the offerer
|
||||
// schedules one retry after a backoff.
|
||||
if was_up {
|
||||
answerer_started = false;
|
||||
offerer_started = false;
|
||||
rtc_in_tx = None;
|
||||
// Suppress re-upgrade after a health downgrade: the direct
|
||||
// path proved too slow, so stay on the relay (no flapping).
|
||||
if role == P2pRole::Offerer && offerer_mode.is_some() && !health_down {
|
||||
let reup_tx = reup_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(reup_ms)).await;
|
||||
let _ = reup_tx.send(());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- re-upgrade timer fired: retry the offerer upgrade once ----
|
||||
Ev::ReUpgrade => {
|
||||
if role == P2pRole::Offerer
|
||||
&& !offerer_started
|
||||
&& p2p_send.is_none()
|
||||
&& sid.is_some()
|
||||
{
|
||||
if let Some(web) = offerer_mode {
|
||||
offerer_started = true;
|
||||
rtc_in_tx = Some(start_offerer_attempt(
|
||||
web,
|
||||
api.clone(),
|
||||
sid.clone().unwrap_or_default(),
|
||||
rtc_out_tx.clone(),
|
||||
done_tx.clone(),
|
||||
));
|
||||
tracing::info!("P2P re-upgrade attempt");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod health_tests {
|
||||
use super::{P2pHealth, P2P_RTT_CONFIRM};
|
||||
|
||||
#[test]
|
||||
fn good_rtt_never_downgrades() {
|
||||
let mut h = P2pHealth::default();
|
||||
for _ in 0..50 {
|
||||
assert!(!h.on_rtt(30), "a healthy direct pair must stay on P2P");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_rtt_is_ignored() {
|
||||
let mut h = P2pHealth::default();
|
||||
for _ in 0..50 {
|
||||
assert!(!h.on_rtt(0), "0 = not-yet-measured, never a downgrade");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sustained_bad_rtt_downgrades_after_confirm() {
|
||||
let mut h = P2pHealth::default();
|
||||
// Must take the full confirm streak — not a single spike.
|
||||
for i in 1..P2P_RTT_CONFIRM {
|
||||
assert!(!h.on_rtt(800), "should not trip on sample {i} (< confirm)");
|
||||
}
|
||||
assert!(h.on_rtt(800), "should downgrade once the streak reaches confirm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_good_sample_resets_the_streak() {
|
||||
let mut h = P2pHealth::default();
|
||||
for _ in 0..(P2P_RTT_CONFIRM - 1) {
|
||||
assert!(!h.on_rtt(900));
|
||||
}
|
||||
assert!(!h.on_rtt(40), "a healthy sample clears the bad streak");
|
||||
// Now it must take another full streak to trip.
|
||||
for i in 1..P2P_RTT_CONFIRM {
|
||||
assert!(!h.on_rtt(900), "streak restarted; sample {i} must not trip");
|
||||
}
|
||||
assert!(h.on_rtt(900));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Wire protocol for the NetDesk signaling server (`/ws`). Mirrors the messages
|
||||
//! the web viewer exchanges, so the native client is a drop-in peer:
|
||||
//! viewer -> session.begin{id}
|
||||
//! host -> session.challenge{sid,salt,nonce,iters,e2e,pub,totp}
|
||||
//! viewer -> session.auth{proof,pub,totp}
|
||||
//! host -> session.accept{sid,info{host_name,e2e,w,h,...}} (then binary frames)
|
||||
//! host -> session.deny{reason}
|
||||
//! Binary frames (after optional AES-GCM): [0]=channel, [1]=flags, [2..]=codec
|
||||
//! bitstream. For CHAN_VIDEO, `flags` packs bit0=keyframe and bits1-3=codec id
|
||||
//! (see [`VideoCodec`] / [`video_flags`]): H.264 Annex-B (0) or VP9 (1).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const CHAN_VIDEO: u8 = 0x01;
|
||||
pub const CHAN_AUDIO: u8 = 0xa1;
|
||||
/// Viewer->host microphone for a two-way voice call: `[CHAN_VOICE][opus]`,
|
||||
/// E2E-sealed like the other channels. The host decodes it and plays it on its
|
||||
/// default render endpoint.
|
||||
pub const CHAN_VOICE: u8 = 0xa2;
|
||||
pub const CHAN_FILE: u8 = 0xf1;
|
||||
pub const CHAN_FILE_END: u8 = 0xf2;
|
||||
|
||||
/// Video codec carried in the high bits of a CHAN_VIDEO frame's flags byte.
|
||||
///
|
||||
/// The host tags every frame; the viewer builds the matching decoder and can
|
||||
/// switch mid-session at any keyframe (the adaptive path moves hardware H.264
|
||||
/// <-> software VP9 by link quality — VP9 is far better per-bitrate on slow
|
||||
/// links, H.264 is near-free on fast ones). Values are forward-compatible:
|
||||
/// bits1-3 leave room for VP8/AV1 later.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
#[repr(u8)]
|
||||
pub enum VideoCodec {
|
||||
H264 = 0,
|
||||
Vp9 = 1,
|
||||
}
|
||||
|
||||
impl VideoCodec {
|
||||
/// Decode the codec id from a CHAN_VIDEO flags byte (unknown -> H264).
|
||||
#[inline]
|
||||
pub fn from_bits(flags: u8) -> Self {
|
||||
match (flags >> 1) & 0x7 {
|
||||
1 => VideoCodec::Vp9,
|
||||
_ => VideoCodec::H264,
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
pub fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
}
|
||||
|
||||
/// Pack a CHAN_VIDEO flags byte: bit0 = keyframe, bits1-3 = codec id.
|
||||
#[inline]
|
||||
pub fn video_flags(keyframe: bool, codec: VideoCodec) -> u8 {
|
||||
(keyframe as u8) | ((codec as u8) << 1)
|
||||
}
|
||||
|
||||
/// Outbound: begin a session against a host id.
|
||||
#[derive(Serialize)]
|
||||
pub struct SessionBegin<'a> {
|
||||
pub t: &'a str, // "session.begin"
|
||||
pub id: &'a str,
|
||||
pub direct: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub takeover: Option<bool>,
|
||||
/// Viewer can decode VP9 — the host only sends VP9 frames when this is set
|
||||
/// (a non-VP9 viewer, e.g. a non-Windows native build or the web client,
|
||||
/// stays on H.264). Omitted when false so older hosts ignore it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vp9: Option<bool>,
|
||||
}
|
||||
|
||||
/// Inbound: the host's auth challenge (relayed through the server).
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct Challenge {
|
||||
pub sid: String,
|
||||
pub salt: String, // base64
|
||||
pub nonce: String, // opaque string, concatenated before hashing
|
||||
pub iters: u32,
|
||||
#[serde(default)]
|
||||
pub e2e: bool,
|
||||
#[serde(default, rename = "pub")]
|
||||
pub pub_: Option<String>, // host ECDH P-256 public key, base64 raw (65 bytes)
|
||||
#[serde(default)]
|
||||
pub totp: bool,
|
||||
}
|
||||
|
||||
/// Outbound: proof of the password + our ECDH public key.
|
||||
#[derive(Serialize)]
|
||||
pub struct SessionAuth<'a> {
|
||||
pub t: &'a str, // "session.auth"
|
||||
pub proof: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none", rename = "pub")]
|
||||
pub pub_: Option<String>,
|
||||
/// Channel-bound proof (MITM hardening): `hex(SHA256(nonce||verifier||host_pub))`.
|
||||
/// Sent alongside `proof` whenever E2E is active, so the host can detect a
|
||||
/// relay that swapped its ephemeral key. Omitted on non-E2E sessions; older
|
||||
/// hosts ignore the extra field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub proof_bind: Option<String>,
|
||||
pub totp: String,
|
||||
pub sid: String,
|
||||
/// Viewer can decode VP9. Carried in `session.auth` (not `session.begin`,
|
||||
/// whose custom fields the signaling server strips — it does relay auth,
|
||||
/// since the host reads our `pub` here for E2E). The host only sends VP9
|
||||
/// when this is set. Omitted when false so older hosts ignore it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vp9: Option<bool>,
|
||||
}
|
||||
|
||||
/// Inbound: accepted. `info` carries host name, dims, e2e flag, etc.
|
||||
#[derive(Deserialize, Debug, Clone, Default)]
|
||||
pub struct AcceptInfo {
|
||||
#[serde(default)]
|
||||
pub host_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub e2e: bool,
|
||||
#[serde(default)]
|
||||
pub w: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub h: Option<u32>,
|
||||
}
|
||||
|
||||
/// A keyboard/pointer input event sent viewer -> host.
|
||||
#[derive(Serialize)]
|
||||
pub struct MouseInput<'a> {
|
||||
pub t: &'a str, // "i.mouse"
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub b: u32,
|
||||
pub w: f32,
|
||||
pub hw: f32,
|
||||
pub sid: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct KeyInput<'a> {
|
||||
pub t: &'a str, // "i.key"
|
||||
// The host (netdesk_host.py) and the web viewer both use the short wire
|
||||
// names `c` (DOM code) and `d` (down). The native viewer MUST match them or
|
||||
// the host reads an empty code with down=false and injects nothing — i.e.
|
||||
// the keyboard is silently dead ("connects but it's just a view").
|
||||
#[serde(rename = "c")]
|
||||
pub code: String,
|
||||
#[serde(rename = "d")]
|
||||
pub down: bool,
|
||||
pub sid: String,
|
||||
}
|
||||
|
||||
/// Split an H.264 Annex-B buffer into NAL units (strips 00 00 01 / 00 00 00 01 start codes).
|
||||
pub fn split_nals(buf: &[u8]) -> Vec<&[u8]> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0usize;
|
||||
let n = buf.len();
|
||||
let mut start: Option<usize> = None;
|
||||
while i + 3 <= n {
|
||||
let sc3 = buf[i] == 0 && buf[i + 1] == 0 && buf[i + 2] == 1;
|
||||
let sc4 = i + 4 <= n && buf[i] == 0 && buf[i + 1] == 0 && buf[i + 2] == 0 && buf[i + 3] == 1;
|
||||
if sc3 || sc4 {
|
||||
if let Some(s) = start {
|
||||
out.push(&buf[s..i]);
|
||||
}
|
||||
i += if sc4 { 4 } else { 3 };
|
||||
start = Some(i);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if let Some(s) = start {
|
||||
if s < n {
|
||||
out.push(&buf[s..n]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// NAL unit type (lower 5 bits of the first byte). 7=SPS, 8=PPS, 5=IDR, 1=non-IDR.
|
||||
pub fn nal_type(nal: &[u8]) -> u8 {
|
||||
if nal.is_empty() {
|
||||
0
|
||||
} else {
|
||||
nal[0] & 0x1f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Session recording — repackage the received H.264 Annex-B access units into a
|
||||
//! playable `.mp4` with **no re-encode** (just remuxing). The viewer tees the
|
||||
//! same access units it decodes for display into this recorder; the host never
|
||||
//! has to do anything. Variable-framerate is preserved by timing each sample
|
||||
//! from its arrival timestamp (the host only sends a frame when the screen
|
||||
//! changes, so fixed-rate muxing would speed up idle stretches).
|
||||
//!
|
||||
//! Developed by Akronlogic.
|
||||
use crate::error::{NdError, Result};
|
||||
use crate::proto::{nal_type, split_nals};
|
||||
use std::fs::File;
|
||||
use std::io::{BufReader, BufWriter};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn other(msg: impl std::fmt::Display) -> NdError {
|
||||
NdError::Other(anyhow::anyhow!("recorder: {msg}"))
|
||||
}
|
||||
|
||||
/// Muxes H.264 access units into an MP4 file on disk.
|
||||
pub struct Mp4Recorder {
|
||||
path: PathBuf,
|
||||
width: u16,
|
||||
height: u16,
|
||||
writer: Option<mp4::Mp4Writer<BufWriter<File>>>,
|
||||
sps: Option<Vec<u8>>,
|
||||
pps: Option<Vec<u8>>,
|
||||
start_ms: Option<u64>,
|
||||
/// One buffered sample so its duration can be set from the NEXT sample's
|
||||
/// timestamp (MP4 samples carry how long they are shown).
|
||||
pending: Option<(Vec<u8>, bool, u64)>, // (avcc bytes, is_sync, rel_ms)
|
||||
samples: u64,
|
||||
}
|
||||
|
||||
const TIMESCALE: u32 = 1000; // milliseconds
|
||||
|
||||
impl Mp4Recorder {
|
||||
/// Create a recorder that will write to `path`. `width`/`height` are the
|
||||
/// encoded frame size (from `session.info`); the file is only created once
|
||||
/// the first keyframe (SPS+PPS) arrives.
|
||||
pub fn new(path: impl AsRef<Path>, width: u16, height: u16) -> Self {
|
||||
Self {
|
||||
path: path.as_ref().to_path_buf(),
|
||||
width: width.max(2),
|
||||
height: height.max(2),
|
||||
writer: None,
|
||||
sps: None,
|
||||
pps: None,
|
||||
start_ms: None,
|
||||
pending: None,
|
||||
samples: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one access unit (Annex-B). `ts_ms` is a monotonically increasing
|
||||
/// arrival time in milliseconds. SPS/PPS are harvested automatically and the
|
||||
/// file starts on the first keyframe.
|
||||
pub fn push(&mut self, au: &[u8], keyframe: bool, ts_ms: u64) -> Result<()> {
|
||||
let nals = split_nals(au);
|
||||
|
||||
// Harvest parameter sets (needed for the avcC box).
|
||||
for nal in &nals {
|
||||
match nal_type(nal) {
|
||||
7 if self.sps.is_none() => self.sps = Some(nal.to_vec()),
|
||||
8 if self.pps.is_none() => self.pps = Some(nal.to_vec()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Lazily open the file once we can describe the track.
|
||||
if self.writer.is_none() {
|
||||
let (sps, pps) = match (self.sps.clone(), self.pps.clone()) {
|
||||
(Some(s), Some(p)) => (s, p),
|
||||
_ => return Ok(()), // wait for the first keyframe's SPS/PPS
|
||||
};
|
||||
let file = BufWriter::new(File::create(&self.path).map_err(other)?);
|
||||
let config = mp4::Mp4Config {
|
||||
major_brand: mp4::FourCC::from(*b"isom"),
|
||||
minor_version: 512,
|
||||
compatible_brands: vec![
|
||||
mp4::FourCC::from(*b"isom"),
|
||||
mp4::FourCC::from(*b"iso2"),
|
||||
mp4::FourCC::from(*b"avc1"),
|
||||
mp4::FourCC::from(*b"mp41"),
|
||||
],
|
||||
timescale: TIMESCALE,
|
||||
};
|
||||
let mut w = mp4::Mp4Writer::write_start(file, &config).map_err(other)?;
|
||||
let track = mp4::TrackConfig {
|
||||
track_type: mp4::TrackType::Video,
|
||||
timescale: TIMESCALE,
|
||||
language: "und".to_string(),
|
||||
media_conf: mp4::MediaConfig::AvcConfig(mp4::AvcConfig {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
seq_param_set: sps,
|
||||
pic_param_set: pps,
|
||||
}),
|
||||
};
|
||||
w.add_track(&track).map_err(other)?;
|
||||
self.writer = Some(w);
|
||||
}
|
||||
|
||||
// Convert the Annex-B access unit to AVCC (4-byte length-prefixed NALs),
|
||||
// dropping parameter sets (already in avcC) and access-unit delimiters.
|
||||
let mut avcc = Vec::with_capacity(au.len());
|
||||
for nal in &nals {
|
||||
if nal.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match nal_type(nal) {
|
||||
7 | 8 | 9 => continue, // SPS, PPS, AUD
|
||||
_ => {}
|
||||
}
|
||||
avcc.extend_from_slice(&(nal.len() as u32).to_be_bytes());
|
||||
avcc.extend_from_slice(nal);
|
||||
}
|
||||
if avcc.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let rel_ms = {
|
||||
let start = *self.start_ms.get_or_insert(ts_ms);
|
||||
ts_ms.saturating_sub(start)
|
||||
};
|
||||
|
||||
// Flush the previously buffered sample now that we know its duration.
|
||||
if let Some((bytes, is_sync, prev_rel)) = self.pending.take() {
|
||||
let dur = rel_ms.saturating_sub(prev_rel).clamp(1, 10_000) as u32;
|
||||
self.write_sample(bytes, is_sync, prev_rel, dur)?;
|
||||
}
|
||||
self.pending = Some((avcc, keyframe, rel_ms));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_sample(&mut self, bytes: Vec<u8>, is_sync: bool, start: u64, dur: u32) -> Result<()> {
|
||||
if let Some(w) = self.writer.as_mut() {
|
||||
let sample = mp4::Mp4Sample {
|
||||
start_time: start,
|
||||
duration: dur,
|
||||
rendering_offset: 0,
|
||||
is_sync,
|
||||
bytes: mp4::Bytes::from(bytes),
|
||||
};
|
||||
w.write_sample(1, &sample).map_err(other)?;
|
||||
self.samples += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flush the last sample and finalize the MP4. Returns (path, sample count).
|
||||
pub fn finish(mut self) -> Result<(PathBuf, u64)> {
|
||||
if let Some((bytes, is_sync, rel)) = self.pending.take() {
|
||||
self.write_sample(bytes, is_sync, rel, 33)?; // nominal last-frame hold
|
||||
}
|
||||
if let Some(mut w) = self.writer.take() {
|
||||
w.write_end().map_err(other)?;
|
||||
}
|
||||
Ok((self.path.clone(), self.samples))
|
||||
}
|
||||
|
||||
pub fn samples(&self) -> u64 {
|
||||
self.samples
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an MP4 back and return (video track count, total video samples, width,
|
||||
/// height). Used to verify a recording is a valid, parseable, playable file.
|
||||
pub fn probe_mp4(path: impl AsRef<Path>) -> Result<(u32, u64, u16, u16)> {
|
||||
let f = File::open(path.as_ref()).map_err(other)?;
|
||||
let size = f.metadata().map_err(other)?.len();
|
||||
let reader = mp4::Mp4Reader::read_header(BufReader::new(f), size).map_err(other)?;
|
||||
let (mut vtracks, mut samples, mut w, mut h) = (0u32, 0u64, 0u16, 0u16);
|
||||
for track in reader.tracks().values() {
|
||||
if matches!(track.track_type(), Ok(mp4::TrackType::Video)) {
|
||||
vtracks += 1;
|
||||
samples += track.sample_count() as u64;
|
||||
w = track.width();
|
||||
h = track.height();
|
||||
}
|
||||
}
|
||||
Ok((vtracks, samples, w, h))
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
//! WebRTC peer-connection helpers for the native P2P fast path (webrtc 0.17.2,
|
||||
//! the classic tokio-callback API). This module builds the `API` + a
|
||||
//! `RTCPeerConnection` from the ICE servers the relay hands us in `rtc.config`
|
||||
//! / `rtc.offer.ice`. The DataChannel transport ([`crate::transport`]) and the
|
||||
//! offer/answer signaling are layered on top of these.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::mpsc;
|
||||
use webrtc::api::interceptor_registry::register_default_interceptors;
|
||||
use webrtc::api::media_engine::MediaEngine;
|
||||
use webrtc::api::{APIBuilder, API};
|
||||
use webrtc::data_channel::data_channel_init::RTCDataChannelInit;
|
||||
use webrtc::data_channel::RTCDataChannel;
|
||||
use webrtc::ice_transport::ice_candidate_type::RTCIceCandidateType;
|
||||
use webrtc::ice_transport::ice_server::RTCIceServer;
|
||||
use webrtc::interceptor::registry::Registry;
|
||||
use webrtc::peer_connection::configuration::RTCConfiguration;
|
||||
use webrtc::peer_connection::sdp::session_description::RTCSessionDescription;
|
||||
use webrtc::peer_connection::RTCPeerConnection;
|
||||
|
||||
/// One ICE server exactly as it arrives in the relay's `rtc.config` reply and
|
||||
/// is forwarded in `rtc.offer.ice`: `{"urls":[...],"username":...,"credential":...}`.
|
||||
/// coturn REST creds (username = "<expiry>:nd", credential = base64 HMAC-SHA1)
|
||||
/// drop straight into webrtc's long-term TURN credential fields.
|
||||
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
|
||||
pub struct RtcIceServerJson {
|
||||
#[serde(default)]
|
||||
pub urls: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub credential: Option<String>,
|
||||
}
|
||||
|
||||
/// Map the relay's ICE JSON into webrtc `RTCIceServer`s (0.17.2 has only
|
||||
/// urls/username/credential).
|
||||
pub fn ice_servers_from_json(ice: &[RtcIceServerJson]) -> Vec<RTCIceServer> {
|
||||
ice.iter()
|
||||
.filter(|s| !s.urls.is_empty())
|
||||
.map(|s| RTCIceServer {
|
||||
urls: s.urls.clone(),
|
||||
username: s.username.clone().unwrap_or_default(),
|
||||
credential: s.credential.clone().unwrap_or_default(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build a webrtc `API` with the default media engine + interceptors. One per
|
||||
/// peer connection is fine (cheap).
|
||||
///
|
||||
/// Raises the SCTP max send message size to 256 KiB (webrtc-rs defaults to
|
||||
/// 64 KiB), matching the web client's advertised `a=max-message-size:262144`.
|
||||
/// Without this a whole ~200 KiB video keyframe is rejected with
|
||||
/// `ErrOutboundPacketTooLarge`; with it the native peer can send whole E2E
|
||||
/// frames exactly like the browser / aiortc, which is what web/Python interop
|
||||
/// on the `media` channel requires. (Harmless for the native single channel,
|
||||
/// which fragments at 16 KiB.)
|
||||
pub fn build_api() -> Result<API> {
|
||||
use webrtc::api::setting_engine::{SctpMaxMessageSize, SettingEngine};
|
||||
let mut media = MediaEngine::default();
|
||||
let mut registry = Registry::new();
|
||||
registry = register_default_interceptors(registry, &mut media)?;
|
||||
let mut se = SettingEngine::default();
|
||||
se.set_sctp_max_message_size_can_send(SctpMaxMessageSize::Bounded(262144));
|
||||
Ok(APIBuilder::new()
|
||||
.with_media_engine(media)
|
||||
.with_interceptor_registry(registry)
|
||||
.with_setting_engine(se)
|
||||
.build())
|
||||
}
|
||||
|
||||
/// A peer-connection configuration from a list of ICE servers.
|
||||
pub fn rtc_config(ice_servers: Vec<RTCIceServer>) -> RTCConfiguration {
|
||||
RTCConfiguration {
|
||||
ice_servers,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new peer connection on `api` with `cfg`.
|
||||
pub async fn new_pc(api: &API, cfg: RTCConfiguration) -> Result<Arc<RTCPeerConnection>> {
|
||||
Ok(Arc::new(api.new_peer_connection(cfg).await?))
|
||||
}
|
||||
|
||||
/// Cap on how long we wait for ICE gathering to finish before sending the SDP.
|
||||
/// The web/Python peers are NON-TRICKLE (candidates must be embedded in the
|
||||
/// offer/answer), so we gather to completion — but never hang on a stuck
|
||||
/// network interface: 2.5 s matches the reference signaling timeout.
|
||||
pub const GATHER_CAP: Duration = Duration::from_millis(2500);
|
||||
|
||||
/// The max DataChannel message size we advertise (bytes) — matches the web
|
||||
/// client's `a=max-message-size:262144`.
|
||||
pub const MAX_MESSAGE_SIZE: u32 = 262144;
|
||||
|
||||
/// Ensure the SDP's `m=application` section advertises our max-message-size.
|
||||
/// webrtc-rs never emits `a=max-message-size`, so a peer defaults our receive
|
||||
/// cap to 64 KiB and refuses to send us a whole ~200 KiB keyframe. Injecting it
|
||||
/// (right after `a=sctp-port:`) raises the PEER's send-to-us cap to 256 KiB —
|
||||
/// and is exactly what aiortc / the browser advertise, so it is also how we
|
||||
/// receive whole video frames from the Python host over the `media` channel.
|
||||
pub fn advertise_max_message_size(sdp: String) -> String {
|
||||
if sdp.contains("a=max-message-size:") {
|
||||
return sdp; // already present (e.g. echoed from a web peer)
|
||||
}
|
||||
let nl = if sdp.contains("\r\n") { "\r\n" } else { "\n" };
|
||||
let needle = "a=sctp-port:";
|
||||
if let Some(pos) = sdp.find(needle) {
|
||||
// insert the attribute on its own line after the sctp-port line
|
||||
if let Some(eol) = sdp[pos..].find('\n') {
|
||||
let insert_at = pos + eol + 1;
|
||||
let mut out = String::with_capacity(sdp.len() + 32);
|
||||
out.push_str(&sdp[..insert_at]);
|
||||
out.push_str(&format!("a=max-message-size:{MAX_MESSAGE_SIZE}{nl}"));
|
||||
out.push_str(&sdp[insert_at..]);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
sdp
|
||||
}
|
||||
|
||||
/// Offerer (viewer): create the offer, then gather ICE **to completion** and
|
||||
/// return the full local SDP for `rtc.offer.sdp`. Non-trickle: every candidate
|
||||
/// is embedded in the returned SDP, because the Python host has no trickle
|
||||
/// candidate handler. The caller must create its DataChannel(s) on `pc` BEFORE
|
||||
/// calling (they shape the offer). `gathering_complete_promise` is taken before
|
||||
/// `set_local_description`, which is what actually starts gathering.
|
||||
pub async fn offer_nontrickle(pc: &Arc<RTCPeerConnection>, gather_cap: Duration) -> Result<String> {
|
||||
let offer = pc.create_offer(None).await?;
|
||||
let mut gather = pc.gathering_complete_promise().await;
|
||||
pc.set_local_description(offer).await?;
|
||||
let _ = tokio::time::timeout(gather_cap, gather.recv()).await;
|
||||
let local = pc
|
||||
.local_description()
|
||||
.await
|
||||
.ok_or_else(|| anyhow!("no local description after ICE gathering"))?;
|
||||
Ok(advertise_max_message_size(local.sdp))
|
||||
}
|
||||
|
||||
/// Answerer (host): apply the remote offer SDP, create the answer, gather ICE
|
||||
/// to completion (non-trickle) and return the full local SDP for
|
||||
/// `rtc.answer.sdp`. Register `on_data_channel` on `pc` BEFORE calling so the
|
||||
/// viewer-created channel is captured.
|
||||
pub async fn answer_nontrickle(
|
||||
pc: &Arc<RTCPeerConnection>,
|
||||
offer_sdp: &str,
|
||||
gather_cap: Duration,
|
||||
) -> Result<String> {
|
||||
let offer = RTCSessionDescription::offer(offer_sdp.to_string())?;
|
||||
pc.set_remote_description(offer).await?;
|
||||
let answer = pc.create_answer(None).await?;
|
||||
let mut gather = pc.gathering_complete_promise().await;
|
||||
pc.set_local_description(answer).await?;
|
||||
let _ = tokio::time::timeout(gather_cap, gather.recv()).await;
|
||||
let local = pc
|
||||
.local_description()
|
||||
.await
|
||||
.ok_or_else(|| anyhow!("no local description after ICE gathering"))?;
|
||||
Ok(advertise_max_message_size(local.sdp))
|
||||
}
|
||||
|
||||
/// Offerer (viewer): apply the remote answer SDP received in `rtc.answer`.
|
||||
pub async fn apply_answer(pc: &Arc<RTCPeerConnection>, answer_sdp: &str) -> Result<()> {
|
||||
let answer = RTCSessionDescription::answer(answer_sdp.to_string())?;
|
||||
pc.set_remote_description(answer).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait for the server to return rtc.config before falling back to a default
|
||||
/// STUN server. 2.5 s matches the reference signaling timeout.
|
||||
pub const CONFIG_CAP: Duration = Duration::from_millis(2500);
|
||||
/// How long to wait for the peer's rtc.answer.
|
||||
pub const ANSWER_CAP: Duration = Duration::from_secs(10);
|
||||
/// How long to wait for the pc to connect + the DataChannel to open.
|
||||
pub const CONNECT_CAP: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Default ICE if the server does not answer rtc.config in time. A public STUN
|
||||
/// is enough to still find a server-reflexive path; host/LAN pairs connect
|
||||
/// without it (and loopback needs no ICE server at all).
|
||||
pub fn default_ice() -> Vec<RtcIceServerJson> {
|
||||
vec![RtcIceServerJson {
|
||||
urls: vec!["stun:stun.l.google.com:19302".into()],
|
||||
username: None,
|
||||
credential: None,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Pull rtc.* Values from `rtc_in` until one matches `want` (dropping any other
|
||||
/// rtc.* that arrives first), capped by `cap`. rtc.unavailable short-circuits to
|
||||
/// an error so the caller abandons the upgrade and stays on the relay.
|
||||
async fn wait_rtc(
|
||||
rtc_in: &mut mpsc::UnboundedReceiver<Value>,
|
||||
want: &str,
|
||||
cap: Duration,
|
||||
) -> Result<Value> {
|
||||
let deadline = tokio::time::Instant::now() + cap;
|
||||
loop {
|
||||
match tokio::time::timeout_at(deadline, rtc_in.recv()).await {
|
||||
Ok(Some(v)) => {
|
||||
let t = v.get("t").and_then(|t| t.as_str()).unwrap_or("");
|
||||
if t == want {
|
||||
return Ok(v);
|
||||
}
|
||||
if t == "rtc.unavailable" {
|
||||
return Err(anyhow!("peer declined P2P (rtc.unavailable)"));
|
||||
}
|
||||
// ignore any other rtc.* and keep waiting
|
||||
}
|
||||
Ok(None) => return Err(anyhow!("rtc signaling channel closed")),
|
||||
Err(_) => return Err(anyhow!("timed out waiting for {want}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Viewer offerer: run the relay-brokered P2P upgrade handshake and return the
|
||||
/// connected pc + its DataChannel once the channel opens. `send_text` enqueues
|
||||
/// an rtc.* text message onto the relay (the caller supplies one that carries
|
||||
/// `sid`); `rtc_in` yields inbound rtc.config / rtc.answer / rtc.unavailable
|
||||
/// Values the relay loop forwards. When `ice_override` is `None` the offerer
|
||||
/// asks the server for ICE via rtc.config.req (default STUN on timeout); tests
|
||||
/// pass `Some(vec![])` for a loopback host-candidate-only pc.
|
||||
pub async fn upgrade_offerer(
|
||||
api: &API,
|
||||
sid: &str,
|
||||
ice_override: Option<Vec<RtcIceServerJson>>,
|
||||
channel_label: &str,
|
||||
send_text: &(dyn Fn(String) + Send + Sync),
|
||||
rtc_in: &mut mpsc::UnboundedReceiver<Value>,
|
||||
) -> Result<(Arc<RTCPeerConnection>, Arc<RTCDataChannel>)> {
|
||||
let ice = match ice_override {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
send_text(json!({ "t": "rtc.config.req", "sid": sid }).to_string());
|
||||
match wait_rtc(rtc_in, "rtc.config", CONFIG_CAP).await {
|
||||
Ok(v) => v
|
||||
.get("iceServers")
|
||||
.and_then(|x| serde_json::from_value::<Vec<RtcIceServerJson>>(x.clone()).ok())
|
||||
.unwrap_or_else(default_ice),
|
||||
Err(_) => default_ice(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let pc = new_pc(api, rtc_config(ice_servers_from_json(&ice))).await?;
|
||||
let (open_tx, mut open_rx) = mpsc::channel::<()>(1);
|
||||
let dc = pc
|
||||
.create_data_channel(
|
||||
channel_label,
|
||||
Some(RTCDataChannelInit {
|
||||
ordered: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
{
|
||||
let tx = open_tx.clone();
|
||||
dc.on_open(Box::new(move || {
|
||||
let tx = tx.clone();
|
||||
Box::pin(async move {
|
||||
let _ = tx.send(()).await;
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
// Non-trickle: gather fully, then send the offer with the ICE config echoed
|
||||
// through so a TURN-only carrier-NAT host can reach us.
|
||||
let offer_sdp = offer_nontrickle(&pc, GATHER_CAP).await?;
|
||||
// `native:true` marks a native single-channel offerer: a native host only
|
||||
// answers these, and declines (rtc.unavailable) a web/3-channel offerer so
|
||||
// that pairing safely stays on the relay.
|
||||
send_text(
|
||||
json!({ "t": "rtc.offer", "sid": sid, "sdp": offer_sdp, "ice": ice, "native": true })
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let answer = wait_rtc(rtc_in, "rtc.answer", ANSWER_CAP).await?;
|
||||
let answer_sdp = answer
|
||||
.get("sdp")
|
||||
.and_then(|s| s.as_str())
|
||||
.ok_or_else(|| anyhow!("rtc.answer missing sdp"))?;
|
||||
apply_answer(&pc, answer_sdp).await?;
|
||||
|
||||
tokio::time::timeout(CONNECT_CAP, open_rx.recv())
|
||||
.await
|
||||
.map_err(|_| anyhow!("P2P DataChannel did not open"))?
|
||||
.ok_or_else(|| anyhow!("P2P open signal dropped"))?;
|
||||
Ok((pc, dc))
|
||||
}
|
||||
|
||||
/// Host answerer: handle one inbound rtc.offer. Build the answerer pc from the
|
||||
/// offer plus the `ice` the offerer echoed, capture the viewer-created channel
|
||||
/// via on_data_channel, create+send the answer via `send_text` (carrying
|
||||
/// `sid`), and return the connected pc + channel once it opens.
|
||||
pub async fn answer_offer(
|
||||
api: &API,
|
||||
sid: &str,
|
||||
offer_sdp: &str,
|
||||
ice: Vec<RtcIceServerJson>,
|
||||
send_text: &(dyn Fn(String) + Send + Sync),
|
||||
) -> Result<(Arc<RTCPeerConnection>, Arc<RTCDataChannel>)> {
|
||||
let pc = new_pc(api, rtc_config(ice_servers_from_json(&ice))).await?;
|
||||
let (dc_tx, mut dc_rx) = mpsc::channel::<Arc<RTCDataChannel>>(1);
|
||||
let (open_tx, mut open_rx) = mpsc::channel::<()>(1);
|
||||
{
|
||||
let dc_tx = dc_tx.clone();
|
||||
let open_tx = open_tx.clone();
|
||||
pc.on_data_channel(Box::new(move |d: Arc<RTCDataChannel>| {
|
||||
let dc_tx = dc_tx.clone();
|
||||
let open_tx = open_tx.clone();
|
||||
Box::pin(async move {
|
||||
let ot = open_tx.clone();
|
||||
d.on_open(Box::new(move || {
|
||||
let ot = ot.clone();
|
||||
Box::pin(async move {
|
||||
let _ = ot.send(()).await;
|
||||
})
|
||||
}));
|
||||
let _ = dc_tx.send(d).await;
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
let answer_sdp = answer_nontrickle(&pc, offer_sdp, GATHER_CAP).await?;
|
||||
send_text(json!({ "t": "rtc.answer", "sid": sid, "sdp": answer_sdp }).to_string());
|
||||
|
||||
let dc = tokio::time::timeout(CONNECT_CAP, dc_rx.recv())
|
||||
.await
|
||||
.map_err(|_| anyhow!("no DataChannel from the offerer"))?
|
||||
.ok_or_else(|| anyhow!("DataChannel signal dropped"))?;
|
||||
tokio::time::timeout(CONNECT_CAP, open_rx.recv())
|
||||
.await
|
||||
.map_err(|_| anyhow!("P2P DataChannel did not open (host)"))?
|
||||
.ok_or_else(|| anyhow!("host open signal dropped"))?;
|
||||
Ok((pc, dc))
|
||||
}
|
||||
|
||||
/// True when the pc's nominated ICE pair is a DIRECT path (host / server-
|
||||
/// reflexive on both ends) rather than a TURN relay. The relay-first state
|
||||
/// machine only reports "Direct P2P" and moves media off the WSS relay for a
|
||||
/// direct pair: a TURN-relayed hop is no faster than our own relay, and a
|
||||
/// not-yet-selected pair is unknown, so both keep media on the relay.
|
||||
pub async fn selected_pair_is_direct(pc: &Arc<RTCPeerConnection>) -> bool {
|
||||
let dtls = pc.sctp().transport();
|
||||
let ice = dtls.ice_transport();
|
||||
match ice.get_selected_candidate_pair().await {
|
||||
Some(pair) => {
|
||||
pair.local.typ != RTCIceCandidateType::Relay
|
||||
&& pair.remote.typ != RTCIceCandidateType::Relay
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The web/Python 3-channel DataChannel topology. Labels + options match
|
||||
/// `app.js` exactly so a native peer interops with the browser and the Python
|
||||
/// host: `media` carries video (unreliable, 150 ms lifetime — drop beats late),
|
||||
/// `ctl` carries input/clipboard/file/keyframe (reliable, ordered), `move`
|
||||
/// carries pointer motion (unreliable, no retransmit).
|
||||
pub struct WebChannels {
|
||||
pub media: Arc<RTCDataChannel>,
|
||||
pub ctl: Arc<RTCDataChannel>,
|
||||
pub mov: Arc<RTCDataChannel>,
|
||||
}
|
||||
|
||||
/// Offerer: create the three web channels on `pc`. Call BEFORE `offer_nontrickle`
|
||||
/// so they are announced in the SDP. The option set is byte-for-byte the web
|
||||
/// client's (`media`{ordered:false,maxPacketLifeTime:150},
|
||||
/// `ctl`{ordered:true}, `move`{ordered:false,maxRetransmits:0}).
|
||||
pub async fn create_web_channels(pc: &Arc<RTCPeerConnection>) -> Result<WebChannels> {
|
||||
let media = pc
|
||||
.create_data_channel(
|
||||
"media",
|
||||
Some(RTCDataChannelInit {
|
||||
ordered: Some(false),
|
||||
max_packet_life_time: Some(150),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
let ctl = pc
|
||||
.create_data_channel(
|
||||
"ctl",
|
||||
Some(RTCDataChannelInit {
|
||||
ordered: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
let mov = pc
|
||||
.create_data_channel(
|
||||
"move",
|
||||
Some(RTCDataChannelInit {
|
||||
ordered: Some(false),
|
||||
max_retransmits: Some(0),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
Ok(WebChannels { media, ctl, mov })
|
||||
}
|
||||
|
||||
/// Like [`build_api`] but with DETACHED DataChannels enabled. In detach mode we
|
||||
/// read each channel with our OWN buffer instead of webrtc-rs's fixed 64 KiB
|
||||
/// `read_loop` buffer — which is the ONLY way to receive a whole >64 KiB
|
||||
/// DataChannel message on webrtc-rs 0.17.2. This is required for the web/Python
|
||||
/// `media` channel, where the browser / aiortc send whole ~100-200 KiB video
|
||||
/// frames. Use this API for the web 3-channel topology; [`build_api`] (on_message,
|
||||
/// 64 KiB) stays the default for the native single channel, which fragments at
|
||||
/// 16 KiB and never needs a large receive.
|
||||
pub fn build_api_web() -> Result<API> {
|
||||
use webrtc::api::setting_engine::{SctpMaxMessageSize, SettingEngine};
|
||||
let mut media = MediaEngine::default();
|
||||
let mut registry = Registry::new();
|
||||
registry = register_default_interceptors(registry, &mut media)?;
|
||||
let mut se = SettingEngine::default();
|
||||
se.set_sctp_max_message_size_can_send(SctpMaxMessageSize::Bounded(MAX_MESSAGE_SIZE));
|
||||
se.detach_data_channels();
|
||||
Ok(APIBuilder::new()
|
||||
.with_media_engine(media)
|
||||
.with_interceptor_registry(registry)
|
||||
.with_setting_engine(se)
|
||||
.build())
|
||||
}
|
||||
|
||||
/// The web DataChannels the viewer actually uses, after detaching (raw
|
||||
/// read/write handles with our own buffer — the only way to receive whole
|
||||
/// >64KiB frames on webrtc-rs 0.17.2). `move` is created for topology/interop
|
||||
/// but the viewer never reads/writes it, so it is not detached (fewer detaches
|
||||
/// = more reliable).
|
||||
pub struct WebDetached {
|
||||
pub media: Arc<webrtc::data::data_channel::DataChannel>,
|
||||
pub ctl: Arc<webrtc::data::data_channel::DataChannel>,
|
||||
}
|
||||
|
||||
/// Viewer offerer, WEB topology (interop with the browser / Python host). Builds
|
||||
/// the 3 channels (media/ctl/move, labels+options per app.js), runs the
|
||||
/// non-trickle rtc.offer/answer handshake over `send_text`+`rtc_in` (offer does
|
||||
/// NOT carry `native` — it must look like a standard web offer so the Python
|
||||
/// host accepts it), waits for all 3 to open, then detaches them SEQUENTIALLY
|
||||
/// (concurrent multi-channel detach deadlocks) and returns the raw handles. The
|
||||
/// pc must be built from [`build_api_web`] (detach enabled).
|
||||
pub async fn upgrade_offerer_web(
|
||||
api: &API,
|
||||
sid: &str,
|
||||
ice_override: Option<Vec<RtcIceServerJson>>,
|
||||
send_text: &(dyn Fn(String) + Send + Sync),
|
||||
rtc_in: &mut mpsc::UnboundedReceiver<Value>,
|
||||
) -> Result<(Arc<RTCPeerConnection>, WebDetached)> {
|
||||
let ice = match ice_override {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
send_text(json!({ "t": "rtc.config.req", "sid": sid }).to_string());
|
||||
match wait_rtc(rtc_in, "rtc.config", CONFIG_CAP).await {
|
||||
Ok(v) => v
|
||||
.get("iceServers")
|
||||
.and_then(|x| serde_json::from_value::<Vec<RtcIceServerJson>>(x.clone()).ok())
|
||||
.unwrap_or_else(default_ice),
|
||||
Err(_) => default_ice(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let pc = new_pc(api, rtc_config(ice_servers_from_json(&ice))).await?;
|
||||
let web = create_web_channels(&pc).await?;
|
||||
|
||||
// open signals for the three channels
|
||||
let (open_tx, mut open_rx) = mpsc::unbounded_channel::<&'static str>();
|
||||
for (lbl, ch) in [
|
||||
("media", &web.media),
|
||||
("ctl", &web.ctl),
|
||||
("move", &web.mov),
|
||||
] {
|
||||
let tx = open_tx.clone();
|
||||
ch.on_open(Box::new(move || {
|
||||
let tx = tx.clone();
|
||||
Box::pin(async move {
|
||||
let _ = tx.send(lbl);
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
let offer_sdp = offer_nontrickle(&pc, GATHER_CAP).await?;
|
||||
// NOTE: no `native` marker — this is a standard web offer.
|
||||
send_text(json!({ "t": "rtc.offer", "sid": sid, "sdp": offer_sdp, "ice": ice }).to_string());
|
||||
let answer = wait_rtc(rtc_in, "rtc.answer", ANSWER_CAP).await?;
|
||||
let answer_sdp = answer
|
||||
.get("sdp")
|
||||
.and_then(|s| s.as_str())
|
||||
.ok_or_else(|| anyhow!("rtc.answer missing sdp"))?;
|
||||
apply_answer(&pc, answer_sdp).await?;
|
||||
|
||||
// wait for the two channels we use (media+ctl) to open, capped.
|
||||
let mut opened = std::collections::HashSet::new();
|
||||
let deadline = tokio::time::Instant::now() + CONNECT_CAP;
|
||||
while !(opened.contains("media") && opened.contains("ctl")) {
|
||||
match tokio::time::timeout_at(deadline, open_rx.recv()).await {
|
||||
Ok(Some(l)) => {
|
||||
opened.insert(l);
|
||||
}
|
||||
_ => return Err(anyhow!("web channels did not open")),
|
||||
}
|
||||
}
|
||||
|
||||
// detach SEQUENTIALLY (concurrent detach deadlocks) — only media + ctl.
|
||||
let media = web.media.detach().await?;
|
||||
let ctl = web.ctl.detach().await?;
|
||||
Ok((pc, WebDetached { media, ctl }))
|
||||
}
|
||||
|
||||
/// Host answerer, WEB topology (a browser / web viewer connects to a native
|
||||
/// host). Builds the answerer pc, binds the viewer-created media/ctl/move
|
||||
/// channels by label, runs the non-trickle answer, waits for media+ctl to open,
|
||||
/// then detaches them SEQUENTIALLY. The pc must be built from [`build_api_web`].
|
||||
/// The host then SENDS video on media (whole frames, cap 256KiB) and READS
|
||||
/// input on ctl.
|
||||
pub async fn answer_offer_web(
|
||||
api: &API,
|
||||
sid: &str,
|
||||
offer_sdp: &str,
|
||||
ice: Vec<RtcIceServerJson>,
|
||||
send_text: &(dyn Fn(String) + Send + Sync),
|
||||
) -> Result<(Arc<RTCPeerConnection>, WebDetached)> {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
let pc = new_pc(api, rtc_config(ice_servers_from_json(&ice))).await?;
|
||||
|
||||
// bind channels by label + signal opens
|
||||
let chans: Arc<StdMutex<HashMap<String, Arc<RTCDataChannel>>>> =
|
||||
Arc::new(StdMutex::new(HashMap::new()));
|
||||
let (open_tx, mut open_rx) = mpsc::unbounded_channel::<String>();
|
||||
{
|
||||
let chans = chans.clone();
|
||||
let open_tx = open_tx.clone();
|
||||
pc.on_data_channel(Box::new(move |d: Arc<RTCDataChannel>| {
|
||||
let chans = chans.clone();
|
||||
let open_tx = open_tx.clone();
|
||||
let label = d.label().to_string();
|
||||
Box::pin(async move {
|
||||
chans.lock().unwrap().insert(label.clone(), d.clone());
|
||||
let tx = open_tx.clone();
|
||||
let l = label.clone();
|
||||
d.on_open(Box::new(move || {
|
||||
let tx = tx.clone();
|
||||
let l = l.clone();
|
||||
Box::pin(async move {
|
||||
let _ = tx.send(l);
|
||||
})
|
||||
}));
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
let answer_sdp = answer_nontrickle(&pc, offer_sdp, GATHER_CAP).await?;
|
||||
send_text(json!({ "t": "rtc.answer", "sid": sid, "sdp": answer_sdp }).to_string());
|
||||
|
||||
// wait for media + ctl to open, capped
|
||||
let mut opened = std::collections::HashSet::new();
|
||||
let deadline = tokio::time::Instant::now() + CONNECT_CAP;
|
||||
while !(opened.contains("media") && opened.contains("ctl")) {
|
||||
match tokio::time::timeout_at(deadline, open_rx.recv()).await {
|
||||
Ok(Some(l)) => {
|
||||
opened.insert(l);
|
||||
}
|
||||
_ => return Err(anyhow!("web channels (media/ctl) did not open")),
|
||||
}
|
||||
}
|
||||
|
||||
// grab wrappers, then detach SEQUENTIALLY (concurrent detach deadlocks).
|
||||
let (media_w, ctl_w) = {
|
||||
let m = chans.lock().unwrap();
|
||||
(
|
||||
m.get("media").cloned().ok_or_else(|| anyhow!("no media channel"))?,
|
||||
m.get("ctl").cloned().ok_or_else(|| anyhow!("no ctl channel"))?,
|
||||
)
|
||||
};
|
||||
let media = media_w.detach().await?;
|
||||
let ctl = ctl_w.detach().await?;
|
||||
Ok((pc, WebDetached { media, ctl }))
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,471 @@
|
||||
//! Pluggable transport for the session protocol. The E2E AES-GCM framing sits
|
||||
//! ABOVE this layer on both the viewer and host, so a transport only ever moves
|
||||
//! opaque frames (binary media/file chunks, or text control JSON). The current
|
||||
//! WSS relay ([`WsRelay`]) and the P2P WebRTC DataChannel (added later) both
|
||||
//! implement [`Transport`], so `connect_with` / `run_host_with` carry either and
|
||||
//! the relay→P2P upgrade / downgrade can swap transports without touching the
|
||||
//! protocol, crypto, or framing.
|
||||
|
||||
use crate::error::{NdError, Result};
|
||||
use async_trait::async_trait;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
||||
|
||||
use bytes::Bytes;
|
||||
use webrtc::data_channel::data_channel_message::DataChannelMessage;
|
||||
use webrtc::data_channel::RTCDataChannel;
|
||||
|
||||
/// One message crossing a transport. Opaque here; the caller encrypts/decrypts
|
||||
/// and dispatches by channel byte above this layer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TransportMsg {
|
||||
Binary(Vec<u8>),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
/// Which logical stream a binary frame belongs to. The caller knows this from
|
||||
/// the (pre-encryption) channel byte; the transport cannot see it because the
|
||||
/// byte is inside the E2E ciphertext. It decides per-lane delivery: `Media` is
|
||||
/// drop-tolerant (video — drop a fresh frame rather than queue it late), `Ctl`
|
||||
/// must be reliable (file bytes, control), `Move` is drop-tolerant pointer
|
||||
/// motion. Maps 1:1 to the web/Python `media` / `ctl` / `move` DataChannels;
|
||||
/// the relay and the native single channel use it only for back-pressure.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Lane {
|
||||
Media,
|
||||
Ctl,
|
||||
Move,
|
||||
}
|
||||
|
||||
/// A bidirectional message transport. Used from a single task (the viewer's
|
||||
/// socket task / the host's `run_host` loop): `recv` is awaited in one
|
||||
/// `tokio::select!` arm and `send_*` in other arms' bodies, so `&mut self` for
|
||||
/// all three is sound (select drops the non-chosen futures first).
|
||||
#[async_trait]
|
||||
pub trait Transport: Send + 'static {
|
||||
async fn send_binary(&mut self, data: Vec<u8>) -> Result<()>;
|
||||
async fn send_text(&mut self, text: String) -> Result<()>;
|
||||
/// Next inbound message, or `None` when the transport has closed.
|
||||
async fn recv(&mut self) -> Option<Result<TransportMsg>>;
|
||||
|
||||
/// Send a binary frame tagged with its [`Lane`]. The default ignores the
|
||||
/// lane (relay, native single channel today), so existing transports need
|
||||
/// no change; the P2P multiplexer overrides it to pick the channel /
|
||||
/// back-pressure policy. Callers that know the channel (video vs file)
|
||||
/// should prefer this over [`Transport::send_binary`].
|
||||
async fn send_binary_lane(&mut self, data: Vec<u8>, lane: Lane) -> Result<()> {
|
||||
let _ = lane;
|
||||
self.send_binary(data).await
|
||||
}
|
||||
}
|
||||
|
||||
/// The WSS relay transport — the current path, behaviourally identical to the
|
||||
/// pre-seam code. Wraps the tungstenite WebSocket (TLS to the relay); the server
|
||||
/// relays frames opaquely between the two session peers.
|
||||
pub struct WsRelay {
|
||||
ws: WebSocketStream<MaybeTlsStream<TcpStream>>,
|
||||
}
|
||||
|
||||
impl WsRelay {
|
||||
/// Dial the relay (TLS). Mirrors the old `connect_async` + error mapping.
|
||||
pub async fn connect(ws_url: &str) -> Result<Self> {
|
||||
let (ws, _resp) = connect_async(ws_url)
|
||||
.await
|
||||
.map_err(|e| NdError::Connect(e.to_string()))?;
|
||||
Ok(Self { ws })
|
||||
}
|
||||
|
||||
/// Wrap an already-connected WebSocket (e.g. one opened during a handshake
|
||||
/// elsewhere).
|
||||
pub fn from_ws(ws: WebSocketStream<MaybeTlsStream<TcpStream>>) -> Self {
|
||||
Self { ws }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Transport for WsRelay {
|
||||
async fn send_binary(&mut self, data: Vec<u8>) -> Result<()> {
|
||||
self.ws
|
||||
.send(Message::Binary(data))
|
||||
.await
|
||||
.map_err(|e| NdError::Connect(e.to_string()))
|
||||
}
|
||||
|
||||
async fn send_text(&mut self, text: String) -> Result<()> {
|
||||
self.ws
|
||||
.send(Message::Text(text))
|
||||
.await
|
||||
.map_err(|e| NdError::Connect(e.to_string()))
|
||||
}
|
||||
|
||||
async fn recv(&mut self) -> Option<Result<TransportMsg>> {
|
||||
loop {
|
||||
match self.ws.next().await {
|
||||
Some(Ok(Message::Binary(b))) => return Some(Ok(TransportMsg::Binary(b))),
|
||||
Some(Ok(Message::Text(t))) => return Some(Ok(TransportMsg::Text(t))),
|
||||
Some(Ok(Message::Close(_))) | None => return None,
|
||||
// Ping/Pong/Frame are handled by tungstenite; skip at this layer.
|
||||
Some(Ok(_)) => continue,
|
||||
Some(Err(e)) => return Some(Err(NdError::Connect(e.to_string()))),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ P2P WebRTC DataChannel ============================
|
||||
|
||||
/// 9-byte fragment header prepended to every binary chunk:
|
||||
/// `[frame_id:u32 BE][idx:u16 BE][count:u16 BE][flags:u8]`. `flags` bit0 is
|
||||
/// reserved for a keyframe hint (set by a higher layer in a later phase); the
|
||||
/// reassembler ignores it.
|
||||
const FRAG_HEADER: usize = 9;
|
||||
/// Each SCTP message stays at or below 16 KiB (header + payload) — safely under
|
||||
/// the negotiated max-message-size, and small enough that one slow frame never
|
||||
/// head-of-line-blocks the queue for long.
|
||||
const MAX_CHUNK_PAYLOAD: usize = 16 * 1024 - FRAG_HEADER;
|
||||
/// Pause sending once this much is queued in the SCTP send buffer...
|
||||
const HIGH_WATER: usize = 1024 * 1024;
|
||||
/// ...and resume when it drains to here (`on_buffered_amount_low` fires).
|
||||
const LOW_WATER: usize = 512 * 1024;
|
||||
|
||||
/// Reassembly accumulator for one multi-chunk binary frame.
|
||||
struct FrameAsm {
|
||||
count: u16,
|
||||
got: u16,
|
||||
parts: Vec<Option<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl FrameAsm {
|
||||
fn new(count: u16) -> Self {
|
||||
Self {
|
||||
count,
|
||||
got: 0,
|
||||
parts: vec![None; count as usize],
|
||||
}
|
||||
}
|
||||
fn put(&mut self, idx: u16, payload: &[u8]) {
|
||||
let i = idx as usize;
|
||||
if i < self.parts.len() && self.parts[i].is_none() {
|
||||
self.parts[i] = Some(payload.to_vec());
|
||||
self.got += 1;
|
||||
}
|
||||
}
|
||||
fn complete(&self) -> bool {
|
||||
self.got == self.count
|
||||
}
|
||||
/// Concatenate the chunks in index order. Because the chunks are contiguous
|
||||
/// slices of the original nonce||ciphertext||tag blob, the rejoined bytes
|
||||
/// are byte-identical — a mis-ordered or missing chunk would corrupt the
|
||||
/// blob and the viewer AES-GCM decrypt_frame would fail the auth tag, which
|
||||
/// is exactly the integrity guarantee we want.
|
||||
fn concat(self) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
for p in self.parts.into_iter().flatten() {
|
||||
out.extend_from_slice(&p);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the inbound reassembly handler on `dc`: SCTP string messages become
|
||||
/// `TransportMsg::Text`; fragmented binary chunks are rejoined by `frame_id`
|
||||
/// and pushed as exactly one contiguous `TransportMsg::Binary` (so the E2E
|
||||
/// `nonce||ct||tag` blob stays intact for AES-GCM). Shared by `WebRtcChannel`
|
||||
/// (direct P2P) and the relay/P2P multiplexer so both frame the wire identically.
|
||||
pub fn install_reassembler(dc: &Arc<RTCDataChannel>, out: mpsc::UnboundedSender<TransportMsg>) {
|
||||
let reasm: Arc<Mutex<HashMap<u32, FrameAsm>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
dc.on_message(Box::new(move |msg: DataChannelMessage| {
|
||||
let out = out.clone();
|
||||
let reasm = reasm.clone();
|
||||
Box::pin(async move {
|
||||
if msg.is_string {
|
||||
if let Ok(s) = String::from_utf8(msg.data.to_vec()) {
|
||||
let _ = out.send(TransportMsg::Text(s));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let data = &msg.data;
|
||||
if data.len() < FRAG_HEADER {
|
||||
return; // malformed; drop
|
||||
}
|
||||
let frame_id = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
|
||||
let idx = u16::from_be_bytes([data[4], data[5]]);
|
||||
let count = u16::from_be_bytes([data[6], data[7]]);
|
||||
// data[8] = flags (bit0 = keyframe), reserved; unused for reassembly.
|
||||
let payload = &data[FRAG_HEADER..];
|
||||
match count {
|
||||
0 => {}
|
||||
1 => {
|
||||
let _ = out.send(TransportMsg::Binary(payload.to_vec()));
|
||||
}
|
||||
_ => {
|
||||
let mut map = reasm.lock().unwrap();
|
||||
let entry = map.entry(frame_id).or_insert_with(|| FrameAsm::new(count));
|
||||
entry.put(idx, payload);
|
||||
if entry.complete() {
|
||||
if let Some(asm) = map.remove(&frame_id) {
|
||||
drop(map);
|
||||
let _ = out.send(TransportMsg::Binary(asm.concat()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
/// Fragment `data` into `<=16 KiB` SCTP messages (9-byte header each) under one
|
||||
/// `frame_id` and send them in order on `dc`. No back-pressure here — the caller
|
||||
/// applies its own policy (wait vs drop-oldest) around this. Returns the error
|
||||
/// from the first failed `dc.send`.
|
||||
pub async fn send_fragmented(dc: &Arc<RTCDataChannel>, frame_id: u32, data: &[u8]) -> Result<()> {
|
||||
let total = data.len();
|
||||
let count = ((total + MAX_CHUNK_PAYLOAD - 1) / MAX_CHUNK_PAYLOAD).max(1);
|
||||
if count > u16::MAX as usize {
|
||||
return Err(NdError::Connect(format!(
|
||||
"frame too large to fragment: {total} bytes"
|
||||
)));
|
||||
}
|
||||
for idx in 0..count {
|
||||
let start = idx * MAX_CHUNK_PAYLOAD;
|
||||
let end = (start + MAX_CHUNK_PAYLOAD).min(total);
|
||||
let chunk = &data[start..end];
|
||||
let mut framed = Vec::with_capacity(FRAG_HEADER + chunk.len());
|
||||
framed.extend_from_slice(&frame_id.to_be_bytes());
|
||||
framed.extend_from_slice(&(idx as u16).to_be_bytes());
|
||||
framed.extend_from_slice(&(count as u16).to_be_bytes());
|
||||
framed.push(0u8); // flags (bit0 = keyframe) reserved
|
||||
framed.extend_from_slice(chunk);
|
||||
dc.send(&Bytes::from(framed))
|
||||
.await
|
||||
.map_err(|e| NdError::Connect(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A [`Transport`] over a single reliable+ordered WebRTC DataChannel — the
|
||||
/// native-to-native direct P2P fast path. The E2E AES-GCM framing is unchanged
|
||||
/// above this layer; this channel only moves opaque binary frames (fragmented
|
||||
/// to fit SCTP, reassembled whole so the GCM blob stays contiguous) and opaque
|
||||
/// text control JSON (sent as SCTP string messages, so the peer tells the two
|
||||
/// apart with `DataChannelMessage::is_string`).
|
||||
pub struct WebRtcChannel {
|
||||
dc: Arc<RTCDataChannel>,
|
||||
inbound: mpsc::UnboundedReceiver<TransportMsg>,
|
||||
/// Drains one token whenever the SCTP send buffer falls below `LOW_WATER`.
|
||||
low_water_rx: mpsc::Receiver<()>,
|
||||
/// Set true by `on_close`; makes `recv` return `None` once inbound is empty.
|
||||
closed_flag: Arc<AtomicBool>,
|
||||
closed_rx: mpsc::Receiver<()>,
|
||||
next_frame_id: u32,
|
||||
}
|
||||
|
||||
impl WebRtcChannel {
|
||||
/// Wrap an open DataChannel. Registers the inbound reassembly handler, the
|
||||
/// back-pressure low-water signal, and the close signal. Call once the
|
||||
/// channel is open (sends before open error at the SCTP layer).
|
||||
pub async fn new(dc: Arc<RTCDataChannel>) -> Self {
|
||||
let (in_tx, in_rx) = mpsc::unbounded_channel::<TransportMsg>();
|
||||
|
||||
// --- inbound: reassemble binary frames, pass text straight through.
|
||||
install_reassembler(&dc, in_tx);
|
||||
|
||||
// --- back-pressure: a size-1 mpsc is race-free vs Notify here. The
|
||||
// low-water event only fires when the buffer crosses DOWN through the
|
||||
// threshold, so a token can be pending at most once; try_send drops
|
||||
// extras.
|
||||
let (low_tx, low_rx) = mpsc::channel::<()>(1);
|
||||
dc.set_buffered_amount_low_threshold(LOW_WATER).await;
|
||||
dc.on_buffered_amount_low(Box::new(move || {
|
||||
let low_tx = low_tx.clone();
|
||||
Box::pin(async move {
|
||||
let _ = low_tx.try_send(());
|
||||
})
|
||||
}))
|
||||
.await;
|
||||
|
||||
// --- close signal so recv() can return None on a dropped channel.
|
||||
let closed_flag = Arc::new(AtomicBool::new(false));
|
||||
let (closed_tx, closed_rx) = mpsc::channel::<()>(1);
|
||||
let cf = closed_flag.clone();
|
||||
dc.on_close(Box::new(move || {
|
||||
let cf = cf.clone();
|
||||
let closed_tx = closed_tx.clone();
|
||||
Box::pin(async move {
|
||||
cf.store(true, Ordering::SeqCst);
|
||||
let _ = closed_tx.try_send(());
|
||||
})
|
||||
}));
|
||||
|
||||
Self {
|
||||
dc,
|
||||
inbound: in_rx,
|
||||
low_water_rx: low_rx,
|
||||
closed_flag,
|
||||
closed_rx,
|
||||
next_frame_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The underlying channel, for stats / state inspection (upgrade logic).
|
||||
pub fn data_channel(&self) -> &Arc<RTCDataChannel> {
|
||||
&self.dc
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Transport for WebRtcChannel {
|
||||
async fn send_binary(&mut self, data: Vec<u8>) -> Result<()> {
|
||||
// Pause if the SCTP send queue is already deep: wait for it to drain
|
||||
// below LOW_WATER (fresh frames never pile up unbounded in RAM).
|
||||
while self.dc.buffered_amount().await + FRAG_HEADER + data.len() > HIGH_WATER {
|
||||
if self.low_water_rx.recv().await.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let frame_id = self.next_frame_id;
|
||||
self.next_frame_id = self.next_frame_id.wrapping_add(1);
|
||||
send_fragmented(&self.dc, frame_id, &data).await
|
||||
}
|
||||
|
||||
async fn send_text(&mut self, text: String) -> Result<()> {
|
||||
self.dc
|
||||
.send_text(text)
|
||||
.await
|
||||
.map_err(|e| NdError::Connect(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recv(&mut self) -> Option<Result<TransportMsg>> {
|
||||
if self.closed_flag.load(Ordering::SeqCst) {
|
||||
// Drain anything already reassembled, then report closed.
|
||||
return self.inbound.try_recv().ok().map(Ok);
|
||||
}
|
||||
tokio::select! {
|
||||
biased;
|
||||
msg = self.inbound.recv() => msg.map(Ok),
|
||||
_ = self.closed_rx.recv() => self.inbound.try_recv().ok().map(Ok),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct-IP transport — a raw TCP socket instead of the relay. The viewer dials
|
||||
/// the host's `IP:port` and the session runs the SAME challenge/auth + E2E
|
||||
/// AES-GCM above this layer (so direct-IP is encrypted, unlike RustDesk's).
|
||||
/// Framing is `[type:u8][len:u32 BE][payload]` (type 0 = binary, 1 = text). A
|
||||
/// dedicated reader task parses frames into a channel, so `recv` is
|
||||
/// cancellation-safe in the caller's `select!` (a bare `read_exact` is not).
|
||||
pub struct DirectTcp {
|
||||
writer: tokio::net::tcp::OwnedWriteHalf,
|
||||
rx: mpsc::UnboundedReceiver<Result<TransportMsg>>,
|
||||
}
|
||||
|
||||
/// Cap on a single framed message (generous for a 4K keyframe).
|
||||
const DIRECT_MAX_FRAME: usize = 64 * 1024 * 1024;
|
||||
|
||||
impl DirectTcp {
|
||||
/// Wrap an accepted / dialed TCP stream.
|
||||
pub fn new(stream: TcpStream) -> Self {
|
||||
let _ = stream.set_nodelay(true);
|
||||
let (mut read, write) = stream.into_split();
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut hdr = [0u8; 5];
|
||||
loop {
|
||||
if read.read_exact(&mut hdr).await.is_err() {
|
||||
break;
|
||||
}
|
||||
let ty = hdr[0];
|
||||
let len = u32::from_be_bytes([hdr[1], hdr[2], hdr[3], hdr[4]]) as usize;
|
||||
if len > DIRECT_MAX_FRAME {
|
||||
break;
|
||||
}
|
||||
let mut buf = vec![0u8; len];
|
||||
if read.read_exact(&mut buf).await.is_err() {
|
||||
break;
|
||||
}
|
||||
let msg = if ty == 1 {
|
||||
TransportMsg::Text(String::from_utf8_lossy(&buf).into_owned())
|
||||
} else {
|
||||
TransportMsg::Binary(buf)
|
||||
};
|
||||
if tx.send(Ok(msg)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
DirectTcp { writer: write, rx }
|
||||
}
|
||||
|
||||
async fn write_frame(&mut self, ty: u8, payload: &[u8]) -> Result<()> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut hdr = [0u8; 5];
|
||||
hdr[0] = ty;
|
||||
hdr[1..5].copy_from_slice(&(payload.len() as u32).to_be_bytes());
|
||||
self.writer
|
||||
.write_all(&hdr)
|
||||
.await
|
||||
.map_err(|e| NdError::Connect(e.to_string()))?;
|
||||
self.writer
|
||||
.write_all(payload)
|
||||
.await
|
||||
.map_err(|e| NdError::Connect(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Transport for DirectTcp {
|
||||
async fn send_binary(&mut self, data: Vec<u8>) -> Result<()> {
|
||||
self.write_frame(0, &data).await
|
||||
}
|
||||
async fn send_text(&mut self, text: String) -> Result<()> {
|
||||
self.write_frame(1, text.as_bytes()).await
|
||||
}
|
||||
async fn recv(&mut self) -> Option<Result<TransportMsg>> {
|
||||
self.rx.recv().await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod direct_tests {
|
||||
use super::*;
|
||||
|
||||
// DirectTcp framing must round-trip text + binary, in order, over a real
|
||||
// loopback socket (the foundation of encrypted direct-IP access).
|
||||
#[tokio::test]
|
||||
async fn direct_tcp_roundtrips_text_and_binary_in_order() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let srv = tokio::spawn(async move {
|
||||
let (s, _) = listener.accept().await.unwrap();
|
||||
let mut t = DirectTcp::new(s);
|
||||
for _ in 0..3 {
|
||||
match t.recv().await.unwrap().unwrap() {
|
||||
TransportMsg::Text(s) => t.send_text(format!("echo:{s}")).await.unwrap(),
|
||||
TransportMsg::Binary(b) => {
|
||||
let mut r = b.clone();
|
||||
r.reverse();
|
||||
t.send_binary(r).await.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let cs = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||
let mut c = DirectTcp::new(cs);
|
||||
c.send_text("hello".into()).await.unwrap();
|
||||
c.send_binary(vec![1, 2, 3, 4]).await.unwrap();
|
||||
c.send_text("world".into()).await.unwrap();
|
||||
assert!(matches!(c.recv().await.unwrap().unwrap(), TransportMsg::Text(s) if s == "echo:hello"));
|
||||
assert!(matches!(c.recv().await.unwrap().unwrap(), TransportMsg::Binary(b) if b == vec![4, 3, 2, 1]));
|
||||
assert!(matches!(c.recv().await.unwrap().unwrap(), TransportMsg::Text(s) if s == "echo:world"));
|
||||
srv.await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Self-update support: a tiny release manifest the client fetches to learn the
|
||||
//! latest version, where to download it, and its SHA-256 — so an update is only
|
||||
//! ever applied after the bytes are verified. RustDesk-style sha256-checked
|
||||
//! auto-update, distilled to pure, testable helpers.
|
||||
//!
|
||||
//! The server publishes `version.json` alongside the installer (served by the
|
||||
//! existing `/download/<name>` static route — no server code change needed):
|
||||
//! ```json
|
||||
//! { "version": "0.2.0",
|
||||
//! "url": "https://netdesk.space/download/NetDesk-Setup.exe",
|
||||
//! "sha256": "<hex>", "notes": "what changed" }
|
||||
//! ```
|
||||
|
||||
use crate::error::{NdError, Result};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// A parsed release manifest.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Manifest {
|
||||
pub version: String,
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub sha256: String,
|
||||
#[serde(default)]
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
/// Parse the `version.json` manifest.
|
||||
pub fn parse_manifest(json: &str) -> Result<Manifest> {
|
||||
serde_json::from_str(json).map_err(|e| NdError::Other(anyhow::anyhow!("manifest: {e}")))
|
||||
}
|
||||
|
||||
/// Parse a dotted `x.y.z` version into a comparable tuple. Extra components are
|
||||
/// ignored; a missing/garbage component is treated as 0 (so a malformed version
|
||||
/// never reads as "newer" and triggers a spurious update).
|
||||
fn parse_ver(v: &str) -> (u64, u64, u64) {
|
||||
let mut it = v
|
||||
.trim()
|
||||
.trim_start_matches('v')
|
||||
.split(|c| c == '.' || c == '-' || c == '+')
|
||||
.map(|p| p.parse::<u64>().unwrap_or(0));
|
||||
(
|
||||
it.next().unwrap_or(0),
|
||||
it.next().unwrap_or(0),
|
||||
it.next().unwrap_or(0),
|
||||
)
|
||||
}
|
||||
|
||||
/// True when `latest` is strictly newer than `current` (semantic x.y.z compare).
|
||||
/// Equal or older → false, so re-checking never re-triggers.
|
||||
pub fn is_newer(current: &str, latest: &str) -> bool {
|
||||
parse_ver(latest) > parse_ver(current)
|
||||
}
|
||||
|
||||
/// Verify a downloaded artifact against the manifest's SHA-256 (hex, any case).
|
||||
/// An empty expected hash fails closed (we never install unverified bytes).
|
||||
pub fn verify_sha256(bytes: &[u8], expected_hex: &str) -> bool {
|
||||
if expected_hex.trim().is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut h = Sha256::new();
|
||||
h.update(bytes);
|
||||
let got = hex::encode(h.finalize());
|
||||
// length-independent constant-time-ish compare on the lowercased hex
|
||||
let exp = expected_hex.trim().to_lowercase();
|
||||
got.len() == exp.len()
|
||||
&& got
|
||||
.bytes()
|
||||
.zip(exp.bytes())
|
||||
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
|
||||
== 0
|
||||
}
|
||||
|
||||
/// Decide whether to update given the running version + a fetched manifest.
|
||||
/// Returns Some(manifest) only when the manifest names a strictly newer version
|
||||
/// AND carries a sha256 (we refuse to "update" to an unverifiable artifact).
|
||||
pub fn update_available(current: &str, manifest: &Manifest) -> bool {
|
||||
!manifest.sha256.trim().is_empty() && is_newer(current, &manifest.version)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn version_compare() {
|
||||
assert!(is_newer("0.1.0", "0.2.0"));
|
||||
assert!(is_newer("0.1.0", "0.1.1"));
|
||||
assert!(is_newer("1.9.9", "2.0.0"));
|
||||
assert!(!is_newer("0.2.0", "0.2.0")); // equal
|
||||
assert!(!is_newer("0.2.0", "0.1.9")); // older
|
||||
assert!(!is_newer("0.2.0", "garbage")); // malformed -> not newer
|
||||
assert!(is_newer("v0.1.0", "v0.1.1")); // tolerate a 'v' prefix
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_verify_roundtrip() {
|
||||
let data = b"NetDesk-Setup.exe bytes";
|
||||
let mut h = Sha256::new();
|
||||
h.update(data);
|
||||
let good = hex::encode(h.finalize());
|
||||
assert!(verify_sha256(data, &good));
|
||||
assert!(verify_sha256(data, &good.to_uppercase())); // case-insensitive
|
||||
assert!(!verify_sha256(data, "")); // empty fails closed
|
||||
assert!(!verify_sha256(data, &"0".repeat(64))); // wrong hash
|
||||
assert!(!verify_sha256(b"tampered", &good)); // tampered bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_available_needs_newer_and_hash() {
|
||||
let m = |ver: &str, sha: &str| Manifest {
|
||||
version: ver.into(),
|
||||
url: "https://x/NetDesk-Setup.exe".into(),
|
||||
sha256: sha.into(),
|
||||
notes: String::new(),
|
||||
};
|
||||
assert!(update_available("0.1.0", &m("0.2.0", "abcd")));
|
||||
assert!(!update_available("0.1.0", &m("0.2.0", "")), "no hash -> refuse");
|
||||
assert!(!update_available("0.2.0", &m("0.2.0", "abcd")), "not newer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_parses() {
|
||||
let m = parse_manifest(
|
||||
r#"{"version":"0.3.0","url":"https://netdesk.space/download/NetDesk-Setup.exe","sha256":"deadbeef","notes":"x"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(m.version, "0.3.0");
|
||||
assert!(m.url.ends_with("NetDesk-Setup.exe"));
|
||||
assert_eq!(m.sha256, "deadbeef");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
//! Pure-Rust WebM (Matroska/EBML) muxer for session recording — remuxes the
|
||||
//! received VP9 video frames + Opus audio packets into a playable `.webm` with
|
||||
//! **no re-encode** (just like `recorder::Mp4Recorder` does for H.264 → MP4).
|
||||
//!
|
||||
//! Why WebM and not MP4: our audio is Opus and the `mp4` crate is AAC-only (and
|
||||
//! we have no AAC encoder), so Opus can't ride an MP4 track. VP9 + Opus are both
|
||||
//! WebM-native, so a WebM container tees both received streams with zero
|
||||
//! transcoding. This is a hand-rolled EBML writer (no C++ libwebm / no mingw
|
||||
//! dependency): an EBML header, a streaming Segment (unknown size), Info,
|
||||
//! Tracks (V_VP9 + optional A_OPUS), then live Clusters of SimpleBlocks.
|
||||
//!
|
||||
//! Developed by Akronlogic.
|
||||
#![allow(clippy::unusual_byte_groupings)]
|
||||
|
||||
use crate::error::{NdError, Result};
|
||||
use std::fs::File;
|
||||
use std::io::{BufWriter, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn other(msg: impl std::fmt::Display) -> NdError {
|
||||
NdError::Other(anyhow::anyhow!("webm: {msg}"))
|
||||
}
|
||||
|
||||
// ---- EBML element IDs (written as their literal bytes; the ID already encodes
|
||||
// its own length in the leading bits) ----
|
||||
const ID_EBML: &[u8] = &[0x1A, 0x45, 0xDF, 0xA3];
|
||||
const ID_EBMLVERSION: &[u8] = &[0x42, 0x86];
|
||||
const ID_EBMLREADVERSION: &[u8] = &[0x42, 0xF7];
|
||||
const ID_EBMLMAXIDLENGTH: &[u8] = &[0x42, 0xF2];
|
||||
const ID_EBMLMAXSIZELENGTH: &[u8] = &[0x42, 0xF3];
|
||||
const ID_DOCTYPE: &[u8] = &[0x42, 0x82];
|
||||
const ID_DOCTYPEVERSION: &[u8] = &[0x42, 0x87];
|
||||
const ID_DOCTYPEREADVERSION: &[u8] = &[0x42, 0x85];
|
||||
const ID_SEGMENT: &[u8] = &[0x18, 0x53, 0x80, 0x67];
|
||||
const ID_INFO: &[u8] = &[0x15, 0x49, 0xA9, 0x66];
|
||||
const ID_TIMECODESCALE: &[u8] = &[0x2A, 0xD7, 0xB1];
|
||||
const ID_MUXINGAPP: &[u8] = &[0x4D, 0x80];
|
||||
const ID_WRITINGAPP: &[u8] = &[0x57, 0x41];
|
||||
const ID_TRACKS: &[u8] = &[0x16, 0x54, 0xAE, 0x6B];
|
||||
const ID_TRACKENTRY: &[u8] = &[0xAE];
|
||||
const ID_TRACKNUMBER: &[u8] = &[0xD7];
|
||||
const ID_TRACKUID: &[u8] = &[0x73, 0xC5];
|
||||
const ID_TRACKTYPE: &[u8] = &[0x83];
|
||||
const ID_CODECID: &[u8] = &[0x86];
|
||||
const ID_CODECPRIVATE: &[u8] = &[0x63, 0xA2];
|
||||
const ID_VIDEO: &[u8] = &[0xE0];
|
||||
const ID_PIXELWIDTH: &[u8] = &[0xB0];
|
||||
const ID_PIXELHEIGHT: &[u8] = &[0xBA];
|
||||
const ID_AUDIO: &[u8] = &[0xE1];
|
||||
const ID_SAMPLINGFREQ: &[u8] = &[0xB5];
|
||||
const ID_CHANNELS: &[u8] = &[0x9F];
|
||||
const ID_CLUSTER: &[u8] = &[0x1F, 0x43, 0xB6, 0x75];
|
||||
const ID_TIMECODE: &[u8] = &[0xE7];
|
||||
const ID_SIMPLEBLOCK: &[u8] = &[0xA3];
|
||||
|
||||
/// EBML variable-length integer encoding `value` as a size/number field.
|
||||
fn vint(value: u64) -> Vec<u8> {
|
||||
for n in 1..=8u32 {
|
||||
if value < (1u64 << (7 * n)) - 1 {
|
||||
let v = value | (1u64 << (7 * n));
|
||||
return v.to_be_bytes()[(8 - n as usize)..].to_vec();
|
||||
}
|
||||
}
|
||||
let v = (value & ((1u64 << 56) - 1)) | (1u64 << 56);
|
||||
v.to_be_bytes()[1..].to_vec()
|
||||
}
|
||||
|
||||
/// Minimal big-endian unsigned (EBML uint element body).
|
||||
fn uint_be(v: u64) -> Vec<u8> {
|
||||
if v == 0 {
|
||||
return vec![0u8];
|
||||
}
|
||||
let b = v.to_be_bytes();
|
||||
let first = b.iter().position(|&x| x != 0).unwrap();
|
||||
b[first..].to_vec()
|
||||
}
|
||||
|
||||
fn elem(id: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(id.len() + 8 + data.len());
|
||||
out.extend_from_slice(id);
|
||||
out.extend_from_slice(&vint(data.len() as u64));
|
||||
out.extend_from_slice(data);
|
||||
out
|
||||
}
|
||||
|
||||
fn elem_uint(id: &[u8], v: u64) -> Vec<u8> {
|
||||
elem(id, &uint_be(v))
|
||||
}
|
||||
|
||||
fn ebml_header() -> Vec<u8> {
|
||||
let mut h = Vec::new();
|
||||
h.extend(elem_uint(ID_EBMLVERSION, 1));
|
||||
h.extend(elem_uint(ID_EBMLREADVERSION, 1));
|
||||
h.extend(elem_uint(ID_EBMLMAXIDLENGTH, 4));
|
||||
h.extend(elem_uint(ID_EBMLMAXSIZELENGTH, 8));
|
||||
h.extend(elem(ID_DOCTYPE, b"webm"));
|
||||
h.extend(elem_uint(ID_DOCTYPEVERSION, 2));
|
||||
h.extend(elem_uint(ID_DOCTYPEREADVERSION, 2));
|
||||
elem(ID_EBML, &h)
|
||||
}
|
||||
|
||||
fn info() -> Vec<u8> {
|
||||
let mut i = Vec::new();
|
||||
i.extend(elem_uint(ID_TIMECODESCALE, 1_000_000)); // 1 tick = 1 ms
|
||||
i.extend(elem(ID_MUXINGAPP, b"NetDesk"));
|
||||
i.extend(elem(ID_WRITINGAPP, b"NetDesk"));
|
||||
elem(ID_INFO, &i)
|
||||
}
|
||||
|
||||
fn opus_head(channels: u8) -> Vec<u8> {
|
||||
let mut h = Vec::new();
|
||||
h.extend_from_slice(b"OpusHead");
|
||||
h.push(1); // version
|
||||
h.push(channels.max(1));
|
||||
h.extend_from_slice(&3840u16.to_le_bytes()); // pre-skip
|
||||
h.extend_from_slice(&48000u32.to_le_bytes()); // input sample rate
|
||||
h.extend_from_slice(&0i16.to_le_bytes()); // output gain
|
||||
h.push(0); // channel-mapping family 0
|
||||
h
|
||||
}
|
||||
|
||||
fn tracks(width: u16, height: u16, channels: u8) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
v.extend(elem_uint(ID_TRACKNUMBER, 1));
|
||||
v.extend(elem_uint(ID_TRACKUID, 1));
|
||||
v.extend(elem_uint(ID_TRACKTYPE, 1)); // video
|
||||
v.extend(elem(ID_CODECID, b"V_VP9"));
|
||||
let mut vid = Vec::new();
|
||||
vid.extend(elem_uint(ID_PIXELWIDTH, width as u64));
|
||||
vid.extend(elem_uint(ID_PIXELHEIGHT, height as u64));
|
||||
v.extend(elem(ID_VIDEO, &vid));
|
||||
let mut entries = elem(ID_TRACKENTRY, &v);
|
||||
|
||||
if channels > 0 {
|
||||
let mut a = Vec::new();
|
||||
a.extend(elem_uint(ID_TRACKNUMBER, 2));
|
||||
a.extend(elem_uint(ID_TRACKUID, 2));
|
||||
a.extend(elem_uint(ID_TRACKTYPE, 2)); // audio
|
||||
a.extend(elem(ID_CODECID, b"A_OPUS"));
|
||||
a.extend(elem(ID_CODECPRIVATE, &opus_head(channels)));
|
||||
let mut aud = Vec::new();
|
||||
aud.extend(elem(ID_SAMPLINGFREQ, &48000.0f64.to_be_bytes()));
|
||||
aud.extend(elem_uint(ID_CHANNELS, channels as u64));
|
||||
a.extend(elem(ID_AUDIO, &aud));
|
||||
entries.extend(elem(ID_TRACKENTRY, &a));
|
||||
}
|
||||
elem(ID_TRACKS, &entries)
|
||||
}
|
||||
|
||||
fn simpleblock(track: u64, rel_tc: i16, keyframe: bool, frame: &[u8]) -> Vec<u8> {
|
||||
let mut d = Vec::with_capacity(4 + frame.len());
|
||||
d.extend_from_slice(&vint(track)); // track number (EBML vint, 1-based)
|
||||
d.extend_from_slice(&rel_tc.to_be_bytes()); // signed 16-bit relative timecode
|
||||
d.push(if keyframe { 0x80 } else { 0x00 }); // flags: 0x80 = keyframe
|
||||
d.extend_from_slice(frame);
|
||||
elem(ID_SIMPLEBLOCK, &d)
|
||||
}
|
||||
|
||||
/// Muxes received VP9 video (+ optional Opus audio) into a `.webm`. No decode /
|
||||
/// re-encode: the frames are stored exactly as they arrive from the session.
|
||||
pub struct WebmRecorder {
|
||||
path: PathBuf,
|
||||
width: u16,
|
||||
height: u16,
|
||||
channels: u8, // 0 = video-only (no audio track)
|
||||
writer: Option<BufWriter<File>>,
|
||||
start_ms: Option<u64>,
|
||||
cluster_base: u64,
|
||||
cluster: Vec<u8>,
|
||||
open_cluster: bool,
|
||||
frames: u64,
|
||||
}
|
||||
|
||||
impl WebmRecorder {
|
||||
/// `channels` 0 records video only; >0 adds an Opus audio track.
|
||||
pub fn new(path: impl AsRef<Path>, width: u16, height: u16, channels: u8) -> Self {
|
||||
Self {
|
||||
path: path.as_ref().to_path_buf(),
|
||||
width: width.max(2),
|
||||
height: height.max(2),
|
||||
channels,
|
||||
writer: None,
|
||||
start_ms: None,
|
||||
cluster_base: 0,
|
||||
cluster: Vec::new(),
|
||||
open_cluster: false,
|
||||
frames: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_open(&mut self) -> Result<()> {
|
||||
if self.writer.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut w = BufWriter::new(File::create(&self.path).map_err(other)?);
|
||||
w.write_all(&ebml_header()).map_err(other)?;
|
||||
// Streaming Segment: unknown size (all data bits set in an 8-octet vint).
|
||||
w.write_all(ID_SEGMENT).map_err(other)?;
|
||||
w.write_all(&[0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]).map_err(other)?;
|
||||
w.write_all(&info()).map_err(other)?;
|
||||
w.write_all(&tracks(self.width, self.height, self.channels)).map_err(other)?;
|
||||
self.writer = Some(w);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flush_cluster(&mut self) -> Result<()> {
|
||||
if self.open_cluster && !self.cluster.is_empty() {
|
||||
if let Some(w) = self.writer.as_mut() {
|
||||
let mut body = Vec::with_capacity(self.cluster.len() + 8);
|
||||
body.extend(elem_uint(ID_TIMECODE, self.cluster_base));
|
||||
body.extend_from_slice(&self.cluster);
|
||||
w.write_all(ID_CLUSTER).map_err(other)?;
|
||||
w.write_all(&vint(body.len() as u64)).map_err(other)?;
|
||||
w.write_all(&body).map_err(other)?;
|
||||
}
|
||||
}
|
||||
self.cluster.clear();
|
||||
self.open_cluster = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_block(&mut self, track: u64, data: &[u8], keyframe: bool, ts_ms: u64) -> Result<()> {
|
||||
let start = *self.start_ms.get_or_insert(ts_ms);
|
||||
let rel_total = ts_ms.saturating_sub(start);
|
||||
// New cluster on a video keyframe (so seeking works) or before the
|
||||
// relative timecode could overflow the signed-16-bit SimpleBlock field.
|
||||
if !self.open_cluster
|
||||
|| (track == 1 && keyframe)
|
||||
|| rel_total.saturating_sub(self.cluster_base) > 30_000
|
||||
{
|
||||
self.flush_cluster()?;
|
||||
self.cluster_base = rel_total;
|
||||
self.open_cluster = true;
|
||||
}
|
||||
let rel = rel_total.saturating_sub(self.cluster_base).min(32_767) as i16;
|
||||
self.cluster.extend(simpleblock(track, rel, keyframe, data));
|
||||
self.frames += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Feed one VP9 frame. The file opens on the first keyframe.
|
||||
pub fn push_video(&mut self, data: &[u8], keyframe: bool, ts_ms: u64) -> Result<()> {
|
||||
if self.writer.is_none() {
|
||||
if !keyframe {
|
||||
return Ok(()); // wait for the first keyframe to start the file
|
||||
}
|
||||
self.ensure_open()?;
|
||||
}
|
||||
if data.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
self.add_block(1, data, keyframe, ts_ms)
|
||||
}
|
||||
|
||||
/// Feed one Opus packet (ignored until the video track + file exist, and
|
||||
/// when the recorder was created video-only).
|
||||
pub fn push_audio(&mut self, data: &[u8], ts_ms: u64) -> Result<()> {
|
||||
if self.writer.is_none() || self.channels == 0 || data.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
self.add_block(2, data, true, ts_ms) // every Opus frame is independent
|
||||
}
|
||||
|
||||
/// Flush the final cluster and close the file. Returns (path, block count).
|
||||
pub fn finish(mut self) -> Result<(PathBuf, u64)> {
|
||||
self.flush_cluster()?;
|
||||
if let Some(mut w) = self.writer.take() {
|
||||
w.flush().map_err(other)?;
|
||||
}
|
||||
Ok((self.path.clone(), self.frames))
|
||||
}
|
||||
|
||||
pub fn frames(&self) -> u64 {
|
||||
self.frames
|
||||
}
|
||||
}
|
||||
|
||||
// ---- verification: a minimal EBML walk that re-reads a muxed file ----
|
||||
fn find_sub(hay: &[u8], needle: &[u8]) -> bool {
|
||||
hay.windows(needle.len()).any(|w| w == needle)
|
||||
}
|
||||
|
||||
fn read_id(d: &[u8], p: &mut usize) -> Option<Vec<u8>> {
|
||||
let b0 = *d.get(*p)?;
|
||||
let n = (0..4).find(|i| b0 & (0x80 >> i) != 0).map(|i| i + 1)?;
|
||||
if *p + n > d.len() {
|
||||
return None;
|
||||
}
|
||||
let id = d[*p..*p + n].to_vec();
|
||||
*p += n;
|
||||
Some(id)
|
||||
}
|
||||
|
||||
fn read_size(d: &[u8], p: &mut usize) -> Option<u64> {
|
||||
let b0 = *d.get(*p)?;
|
||||
let n = (0..8).find(|i| b0 & (0x80 >> i) != 0).map(|i| i + 1)?;
|
||||
if *p + n > d.len() {
|
||||
return None;
|
||||
}
|
||||
let mut v = (b0 & (0xFF >> n)) as u64;
|
||||
for i in 1..n {
|
||||
v = (v << 8) | d[*p + i] as u64;
|
||||
}
|
||||
*p += n;
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// Re-read a `.webm` and confirm its structure: returns
|
||||
/// (is_webm_doctype, has_vp9, has_opus, simpleblock_count). Walks the top-level
|
||||
/// Segment children and descends into each Cluster to count SimpleBlocks for
|
||||
/// real (no byte-scan false positives for the block count).
|
||||
pub fn probe_webm(path: impl AsRef<Path>) -> Result<(bool, bool, bool, u64)> {
|
||||
let data = std::fs::read(path.as_ref()).map_err(other)?;
|
||||
if data.len() < 4 || &data[0..4] != ID_EBML {
|
||||
return Err(other("missing EBML magic"));
|
||||
}
|
||||
let webm = find_sub(&data, b"webm");
|
||||
let mut p = 0usize;
|
||||
// EBML header element (skip).
|
||||
let _ = read_id(&data, &mut p).ok_or_else(|| other("no EBML id"))?;
|
||||
let hsz = read_size(&data, &mut p).ok_or_else(|| other("no EBML size"))? as usize;
|
||||
p += hsz;
|
||||
// Segment (unknown/huge size) — walk its children to EOF.
|
||||
let _seg = read_id(&data, &mut p).ok_or_else(|| other("no Segment id"))?;
|
||||
let _ = read_size(&data, &mut p).ok_or_else(|| other("no Segment size"))?;
|
||||
let (mut vp9, mut opus, mut blocks) = (false, false, 0u64);
|
||||
while p < data.len() {
|
||||
let id = match read_id(&data, &mut p) {
|
||||
Some(i) => i,
|
||||
None => break,
|
||||
};
|
||||
let sz = match read_size(&data, &mut p) {
|
||||
Some(s) => s as usize,
|
||||
None => break,
|
||||
};
|
||||
let end = (p + sz).min(data.len());
|
||||
if id == ID_CLUSTER {
|
||||
let mut q = p;
|
||||
while q < end {
|
||||
let cid = match read_id(&data, &mut q) {
|
||||
Some(i) => i,
|
||||
None => break,
|
||||
};
|
||||
let csz = match read_size(&data, &mut q) {
|
||||
Some(s) => s as usize,
|
||||
None => break,
|
||||
};
|
||||
if cid == ID_SIMPLEBLOCK {
|
||||
blocks += 1;
|
||||
}
|
||||
q += csz;
|
||||
}
|
||||
} else if id == ID_TRACKS {
|
||||
let seg = &data[p..end];
|
||||
vp9 = find_sub(seg, b"V_VP9");
|
||||
opus = find_sub(seg, b"A_OPUS");
|
||||
}
|
||||
p = end;
|
||||
}
|
||||
Ok((webm, vp9, opus, blocks))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mux_vp9_opus_webm_roundtrips() {
|
||||
// Codec-agnostic remux: the payload bytes are arbitrary (the muxer does
|
||||
// not parse VP9/Opus — the live session supplies real frames). We prove
|
||||
// the CONTAINER: a valid WebM with a V_VP9 + A_OPUS track and the fed
|
||||
// frames stored as SimpleBlocks, re-read by probe_webm.
|
||||
let dir = std::env::temp_dir();
|
||||
let path = dir.join("netdesk_webm_mux_test.webm");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let mut rec = WebmRecorder::new(&path, 320, 240, 2);
|
||||
// audio before the first keyframe is dropped (file not open yet)
|
||||
rec.push_audio(&[0xAA; 40], 0).unwrap();
|
||||
// first keyframe opens the file
|
||||
rec.push_video(&[0x11; 300], true, 0).unwrap();
|
||||
rec.push_audio(&[0xBB; 40], 0).unwrap();
|
||||
rec.push_video(&[0x22; 120], false, 33).unwrap();
|
||||
rec.push_audio(&[0xCC; 40], 20).unwrap();
|
||||
rec.push_video(&[0x33; 110], false, 66).unwrap();
|
||||
rec.push_video(&[0x44; 280], true, 100).unwrap(); // 2nd keyframe => new cluster
|
||||
rec.push_audio(&[0xDD; 40], 100).unwrap();
|
||||
let (out, frames) = rec.finish().unwrap();
|
||||
|
||||
assert!(out.exists(), "webm file not written");
|
||||
let sz = std::fs::metadata(&out).unwrap().len();
|
||||
let (is_webm, has_vp9, has_opus, blocks) = probe_webm(&out).unwrap();
|
||||
println!(
|
||||
"WEBM_OK file={} bytes={} frames={} vp9={} opus={} simpleblocks={}",
|
||||
out.display(), sz, frames, has_vp9, has_opus, blocks
|
||||
);
|
||||
assert!(is_webm, "DocType is not webm");
|
||||
assert!(has_vp9, "no V_VP9 track");
|
||||
assert!(has_opus, "no A_OPUS track");
|
||||
// 4 video + 4 audio pushed, but the 1st audio (pre-keyframe) was dropped.
|
||||
assert_eq!(blocks, 7, "expected 7 SimpleBlocks, got {blocks}");
|
||||
assert_eq!(frames, 7, "recorder frame count");
|
||||
let _ = std::fs::remove_file(&out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_only_webm_has_no_audio_track() {
|
||||
let path = std::env::temp_dir().join("netdesk_webm_vid_only.webm");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut rec = WebmRecorder::new(&path, 64, 64, 0);
|
||||
rec.push_audio(&[0x01; 20], 0).unwrap(); // ignored (video-only)
|
||||
rec.push_video(&[0x11; 50], true, 0).unwrap();
|
||||
rec.push_video(&[0x22; 40], false, 33).unwrap();
|
||||
let (out, frames) = rec.finish().unwrap();
|
||||
let (is_webm, has_vp9, has_opus, blocks) = probe_webm(&out).unwrap();
|
||||
assert!(is_webm && has_vp9 && !has_opus, "video-only must have VP9 and no Opus");
|
||||
assert_eq!((frames, blocks), (2, 2));
|
||||
let _ = std::fs::remove_file(&out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! End-to-end: the core HOST (host.rs) serves a session that the core VIEWER
|
||||
//! (signaling.rs connect) authenticates to, over the LIVE server. Proves the
|
||||
//! Android host's protocol (register -> challenge -> auth/E2E -> accept) and
|
||||
//! input routing interoperate before any Kotlin is wired.
|
||||
//!
|
||||
//! Network test -> #[ignore] by default. Run explicitly:
|
||||
//! cargo test -p netdesk-core --target x86_64-pc-windows-gnu --test host_viewer -- --ignored --nocapture
|
||||
|
||||
use netdesk_core::crypto::{gen_host_id, new_host_credentials};
|
||||
use netdesk_core::host::{run_host, HostConfig, HostEvent, HostFrame};
|
||||
use netdesk_core::{connect, ConnectConfig, SessionEvent};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::timeout;
|
||||
|
||||
async fn wait_host<F: Fn(&HostEvent) -> bool>(
|
||||
rx: &mut mpsc::UnboundedReceiver<HostEvent>,
|
||||
pred: F,
|
||||
secs: u64,
|
||||
) -> Option<HostEvent> {
|
||||
let deadline = Duration::from_secs(secs);
|
||||
loop {
|
||||
match timeout(deadline, rx.recv()).await {
|
||||
Ok(Some(ev)) => {
|
||||
if pred(&ev) {
|
||||
return Some(ev);
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_view<F: Fn(&SessionEvent) -> bool>(
|
||||
rx: &mut mpsc::UnboundedReceiver<SessionEvent>,
|
||||
pred: F,
|
||||
secs: u64,
|
||||
) -> Option<SessionEvent> {
|
||||
let deadline = Duration::from_secs(secs);
|
||||
loop {
|
||||
match timeout(deadline, rx.recv()).await {
|
||||
Ok(Some(ev)) => {
|
||||
if pred(&ev) {
|
||||
return Some(ev);
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn host_viewer_handshake_and_input() {
|
||||
let ws = "wss://netdesk.space/ws";
|
||||
let id = gen_host_id();
|
||||
let password = "e2e-host-test-pw";
|
||||
let (salt_b64, iters, verifier_hex) = new_host_credentials(password);
|
||||
|
||||
let (frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let hcfg = HostConfig {
|
||||
ws_url: ws.into(),
|
||||
host_id: id.clone(),
|
||||
name: "TestAndroidHost".into(),
|
||||
salt_b64,
|
||||
iters,
|
||||
verifier_hex,
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
audio: false,
|
||||
monitors: Vec::new(),
|
||||
monitor: 0,
|
||||
perms: Default::default(),
|
||||
approve: false,
|
||||
totp_secret: String::new(),
|
||||
};
|
||||
// Outbound control channel (unused by this test; drop the sender).
|
||||
let (_hctl_tx, hctl_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let host_task =
|
||||
tokio::spawn(async move { run_host(hcfg, frame_rx, hev_tx, hctl_rx).await });
|
||||
|
||||
assert!(
|
||||
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Registered), 10)
|
||||
.await
|
||||
.is_some(),
|
||||
"host did not register"
|
||||
);
|
||||
// let the registration settle on the server before the viewer dials in
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
|
||||
// viewer connects to our host id
|
||||
let (vtx, mut vrx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
let vcfg = ConnectConfig {
|
||||
ws_url: ws.into(),
|
||||
host_id: id.clone(),
|
||||
password: password.into(),
|
||||
totp: String::new(),
|
||||
takeover: false,
|
||||
};
|
||||
let session = connect(vcfg, vtx).await.expect("viewer connect+handshake");
|
||||
|
||||
// host sees the viewer; viewer sees Connected with our advertised size
|
||||
assert!(
|
||||
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ViewerConnected { .. }), 10)
|
||||
.await
|
||||
.is_some(),
|
||||
"host never saw ViewerConnected"
|
||||
);
|
||||
let connected = wait_view(&mut vrx, |e| matches!(e, SessionEvent::Connected { .. }), 10).await;
|
||||
match connected {
|
||||
Some(SessionEvent::Connected { w, h, e2e, .. }) => {
|
||||
assert_eq!((w, h), (1080, 1920), "advertised resolution");
|
||||
assert!(e2e, "E2E negotiated");
|
||||
}
|
||||
_ => panic!("viewer never got Connected"),
|
||||
}
|
||||
|
||||
// viewer input must surface on the host side with matching coordinates
|
||||
session.mouse(123, 456, 1, 0.0, 0.0);
|
||||
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Mouse { .. }), 10).await {
|
||||
Some(HostEvent::Mouse { x, y, buttons, .. }) => {
|
||||
assert_eq!((x, y, buttons), (123, 456, 1), "mouse routed to host");
|
||||
}
|
||||
_ => panic!("host never received the viewer's mouse"),
|
||||
}
|
||||
|
||||
session.key("KeyA", true);
|
||||
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Key { .. }), 10).await {
|
||||
Some(HostEvent::Key { code, down }) => {
|
||||
assert_eq!((code.as_str(), down), ("KeyA", true), "key routed to host");
|
||||
}
|
||||
_ => panic!("host never received the viewer's key"),
|
||||
}
|
||||
|
||||
// a frame fed to the host reaches the viewer as a binary message (we only
|
||||
// assert the host accepts + seals it; decode is covered elsewhere).
|
||||
let _ = frame_tx.send(HostFrame {
|
||||
data: vec![0, 0, 0, 1, 0x67, 0x42, 0, 0x28],
|
||||
keyframe: true,
|
||||
codec: 0,
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
|
||||
session.close();
|
||||
drop(frame_tx);
|
||||
let _ = timeout(Duration::from_secs(3), host_task).await;
|
||||
println!("HOST_VIEWER_E2E_OK id={id}");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,739 @@
|
||||
//! Host-enforced permission ACL + view-only + approve-before-connect — protocol
|
||||
//! tests over the in-memory relay (same harness as p2p_loopback.rs): the FULL
|
||||
//! `connect_with` / `run_host_with` stacks run server-free, and the assertions
|
||||
//! are on what the ENGINE emits/sends — i.e. what a (possibly modified) viewer
|
||||
//! could actually make the host do, not what a well-behaved UI chooses to send.
|
||||
//!
|
||||
//! Proves the Tier-0 security fix: with a permission disabled, the engine never
|
||||
//! emits the corresponding platform event (input/clipboard/file/audio), replies
|
||||
//! `file.err` to file requests, advertises the REAL perms in `session.accept`,
|
||||
//! enforces viewer-requested view-only host-side, and holds a password-passed
|
||||
//! session for Accept/Reject when approve mode is on.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use netdesk_core::crypto::{
|
||||
gen_host_id, new_host_credentials, password_proof, password_proof_bound,
|
||||
};
|
||||
use netdesk_core::host::{run_host_with, HostConfig, HostControl, HostEvent, HostFrame, HostPerms};
|
||||
use netdesk_core::signaling::connect_with;
|
||||
use netdesk_core::{ConnectConfig, NdError, SessionEvent, Transport, TransportMsg};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory relay pair (the p2p_loopback.rs harness, minus the drop toggles).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct MemTransport {
|
||||
tx: mpsc::UnboundedSender<TransportMsg>,
|
||||
rx: mpsc::UnboundedReceiver<TransportMsg>,
|
||||
}
|
||||
|
||||
fn mem_relay_pair() -> (MemTransport, MemTransport) {
|
||||
let (a_tx, a_rx) = mpsc::unbounded_channel::<TransportMsg>();
|
||||
let (b_tx, b_rx) = mpsc::unbounded_channel::<TransportMsg>();
|
||||
(MemTransport { tx: b_tx, rx: a_rx }, MemTransport { tx: a_tx, rx: b_rx })
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Transport for MemTransport {
|
||||
async fn send_binary(&mut self, data: Vec<u8>) -> netdesk_core::Result<()> {
|
||||
self.tx
|
||||
.send(TransportMsg::Binary(data))
|
||||
.map_err(|_| NdError::Connect("mem relay peer gone".into()))
|
||||
}
|
||||
async fn send_text(&mut self, text: String) -> netdesk_core::Result<()> {
|
||||
self.tx
|
||||
.send(TransportMsg::Text(text))
|
||||
.map_err(|_| NdError::Connect("mem relay peer gone".into()))
|
||||
}
|
||||
async fn recv(&mut self) -> Option<netdesk_core::Result<TransportMsg>> {
|
||||
self.rx.recv().await.map(Ok)
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_host<F: Fn(&HostEvent) -> bool>(
|
||||
rx: &mut mpsc::UnboundedReceiver<HostEvent>,
|
||||
pred: F,
|
||||
secs: u64,
|
||||
) -> Option<HostEvent> {
|
||||
let deadline = Duration::from_secs(secs);
|
||||
loop {
|
||||
match timeout(deadline, rx.recv()).await {
|
||||
Ok(Some(ev)) => {
|
||||
if pred(&ev) {
|
||||
return Some(ev);
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_view<F: Fn(&SessionEvent) -> bool>(
|
||||
rx: &mut mpsc::UnboundedReceiver<SessionEvent>,
|
||||
pred: F,
|
||||
secs: u64,
|
||||
) -> Option<SessionEvent> {
|
||||
let deadline = Duration::from_secs(secs);
|
||||
loop {
|
||||
match timeout(deadline, rx.recv()).await {
|
||||
Ok(Some(ev)) => {
|
||||
if pred(&ev) {
|
||||
return Some(ev);
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn host_cfg(perms: HostPerms, approve: bool) -> (HostConfig, String) {
|
||||
host_cfg_totp(perms, approve, "")
|
||||
}
|
||||
|
||||
fn host_cfg_totp(perms: HostPerms, approve: bool, totp_secret: &str) -> (HostConfig, String) {
|
||||
let password = "perms-test-pw".to_string();
|
||||
let (salt_b64, iters, verifier_hex) = new_host_credentials(&password);
|
||||
let cfg = HostConfig {
|
||||
ws_url: "mem://relay".into(),
|
||||
host_id: gen_host_id(),
|
||||
name: "PermsHost".into(),
|
||||
salt_b64,
|
||||
iters,
|
||||
verifier_hex,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
audio: true,
|
||||
monitors: Vec::new(),
|
||||
monitor: 0,
|
||||
perms,
|
||||
approve,
|
||||
totp_secret: totp_secret.to_string(),
|
||||
};
|
||||
(cfg, password)
|
||||
}
|
||||
|
||||
/// Collect host events until `marker` matches (inclusive), with a timeout.
|
||||
/// Returns everything seen before the marker.
|
||||
async fn collect_until_marker<F: Fn(&HostEvent) -> bool>(
|
||||
rx: &mut mpsc::UnboundedReceiver<HostEvent>,
|
||||
marker: F,
|
||||
secs: u64,
|
||||
) -> Vec<HostEvent> {
|
||||
let mut seen = Vec::new();
|
||||
let deadline = Duration::from_secs(secs);
|
||||
loop {
|
||||
match timeout(deadline, rx.recv()).await {
|
||||
Ok(Some(ev)) => {
|
||||
if marker(&ev) {
|
||||
return seen;
|
||||
}
|
||||
seen.push(ev);
|
||||
}
|
||||
_ => panic!("marker event never arrived; saw {seen:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1) All perms DISABLED: the engine must not emit input/clipboard/file/audio
|
||||
// events no matter what the viewer sends, and file requests get file.err.
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn perms_disabled_block_all_channels() {
|
||||
let (viewer_relay, host_relay) = mem_relay_pair();
|
||||
let perms = HostPerms { input: false, clipboard: false, files: false, audio: false };
|
||||
let (hcfg, password) = host_cfg(perms, false);
|
||||
let host_id = hcfg.host_id.clone();
|
||||
|
||||
let (frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
|
||||
let host_task = tokio::spawn(async move {
|
||||
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
|
||||
});
|
||||
|
||||
let (vtx, mut vrx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
let vcfg = ConnectConfig {
|
||||
ws_url: "mem://relay".into(),
|
||||
host_id,
|
||||
password,
|
||||
totp: String::new(),
|
||||
takeover: false,
|
||||
};
|
||||
let session = connect_with(vcfg, vtx, Box::new(viewer_relay))
|
||||
.await
|
||||
.expect("viewer connects (password auth still works with perms off)");
|
||||
assert!(
|
||||
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ViewerConnected { .. }), 10)
|
||||
.await
|
||||
.is_some(),
|
||||
"host saw the viewer"
|
||||
);
|
||||
|
||||
// Fire one of EVERYTHING a hostile viewer could send on disabled channels…
|
||||
session.mouse(100, 100, 1, 0.0, 0.0);
|
||||
session.key("F15", true);
|
||||
session.key("F15", false);
|
||||
session.clipboard("must-not-reach-the-platform");
|
||||
session.audio_set(1);
|
||||
session.list_roots();
|
||||
// …then flip view-only as an ORDERED MARKER (its handler is independent of
|
||||
// the four perms, so it must still emit — everything sent before it was
|
||||
// processed first on the same ordered channel).
|
||||
session.view_only(true);
|
||||
|
||||
let before = collect_until_marker(
|
||||
&mut hev_rx,
|
||||
|e| matches!(e, HostEvent::ViewOnly { on: true }),
|
||||
10,
|
||||
)
|
||||
.await;
|
||||
for ev in &before {
|
||||
assert!(
|
||||
!matches!(
|
||||
ev,
|
||||
HostEvent::Mouse { .. }
|
||||
| HostEvent::Key { .. }
|
||||
| HostEvent::Clipboard { .. }
|
||||
| HostEvent::File { .. }
|
||||
| HostEvent::FileChunk { .. }
|
||||
| HostEvent::AudioSource { .. }
|
||||
| HostEvent::Voice { .. }
|
||||
),
|
||||
"engine leaked a blocked event to the platform: {ev:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// The denied file request must answer with a clear file.err.
|
||||
match wait_view(&mut vrx, |e| matches!(e, SessionEvent::FileError { .. }), 10).await {
|
||||
Some(SessionEvent::FileError { msg }) => {
|
||||
assert!(
|
||||
msg.contains("disabled"),
|
||||
"file.err should say the channel is disabled, got: {msg}"
|
||||
);
|
||||
}
|
||||
_ => panic!("viewer never got file.err for the denied file.roots"),
|
||||
}
|
||||
|
||||
session.close();
|
||||
drop(frame_tx);
|
||||
let _ = timeout(Duration::from_secs(5), host_task).await;
|
||||
println!("PERMS_DENY_OK all four channels blocked at the engine + file.err reply");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2) Default perms: every channel flows (no regression from the gates).
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn perms_default_channels_flow() {
|
||||
let (viewer_relay, host_relay) = mem_relay_pair();
|
||||
let (hcfg, password) = host_cfg(HostPerms::default(), false);
|
||||
let host_id = hcfg.host_id.clone();
|
||||
|
||||
let (frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
|
||||
let host_task = tokio::spawn(async move {
|
||||
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
|
||||
});
|
||||
|
||||
let (vtx, _vrx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
let vcfg = ConnectConfig {
|
||||
ws_url: "mem://relay".into(),
|
||||
host_id,
|
||||
password,
|
||||
totp: String::new(),
|
||||
takeover: false,
|
||||
};
|
||||
let session = connect_with(vcfg, vtx, Box::new(viewer_relay)).await.expect("connect");
|
||||
|
||||
session.mouse(321, 654, 2, 0.0, 0.0);
|
||||
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Mouse { .. }), 10).await {
|
||||
Some(HostEvent::Mouse { x, y, buttons, .. }) => assert_eq!((x, y, buttons), (321, 654, 2)),
|
||||
_ => panic!("mouse blocked despite perms on"),
|
||||
}
|
||||
// Relative (game-mouse) motion flows through the same input gate.
|
||||
session.mouse_rel(17, -9, 1, 0.0);
|
||||
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::MouseRel { .. }), 10).await {
|
||||
Some(HostEvent::MouseRel { dx, dy, buttons, .. }) => {
|
||||
assert_eq!((dx, dy, buttons), (17, -9, 1))
|
||||
}
|
||||
_ => panic!("relative mouse blocked despite perms on"),
|
||||
}
|
||||
// Translate-keyboard Unicode text flows through the same input gate.
|
||||
session.text("h\u{00e9}llo");
|
||||
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Text { .. }), 10).await {
|
||||
Some(HostEvent::Text { text }) => assert_eq!(text, "h\u{00e9}llo"),
|
||||
_ => panic!("text input blocked despite perms on"),
|
||||
}
|
||||
// Annotation strokes flow through the same input gate and reach the host.
|
||||
session.annotate_stroke(0xFF_0000, 3, &[(10, 20), (30, 40)]);
|
||||
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Annot { .. }), 10).await {
|
||||
Some(HostEvent::Annot { json }) => assert!(json.contains("stroke"), "annot json: {json}"),
|
||||
_ => panic!("annotation blocked despite perms on"),
|
||||
}
|
||||
session.key("KeyA", true);
|
||||
assert!(
|
||||
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Key { down: true, .. }), 10)
|
||||
.await
|
||||
.is_some(),
|
||||
"key blocked despite perms on"
|
||||
);
|
||||
session.clipboard("hello");
|
||||
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Clipboard { .. }), 10).await {
|
||||
Some(HostEvent::Clipboard { text }) => assert_eq!(text, "hello"),
|
||||
_ => panic!("clipboard blocked despite perms on"),
|
||||
}
|
||||
session.audio_set(1);
|
||||
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::AudioSource { .. }), 10).await {
|
||||
Some(HostEvent::AudioSource { src }) => assert_eq!(src, 1),
|
||||
_ => panic!("audio.set blocked despite perms on"),
|
||||
}
|
||||
session.list_roots();
|
||||
assert!(
|
||||
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::File { .. }), 10)
|
||||
.await
|
||||
.is_some(),
|
||||
"file.roots blocked despite perms on"
|
||||
);
|
||||
|
||||
session.close();
|
||||
drop(frame_tx);
|
||||
let _ = timeout(Duration::from_secs(5), host_task).await;
|
||||
println!("PERMS_ALLOW_OK all channels flow with default perms");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3) View-only: input gated OFF and back ON mid-session, enforced host-side.
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn view_only_gates_input_live() {
|
||||
let (viewer_relay, host_relay) = mem_relay_pair();
|
||||
let (hcfg, password) = host_cfg(HostPerms::default(), false);
|
||||
let host_id = hcfg.host_id.clone();
|
||||
|
||||
let (frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
|
||||
let host_task = tokio::spawn(async move {
|
||||
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
|
||||
});
|
||||
|
||||
let (vtx, _vrx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
let vcfg = ConnectConfig {
|
||||
ws_url: "mem://relay".into(),
|
||||
host_id,
|
||||
password,
|
||||
totp: String::new(),
|
||||
takeover: false,
|
||||
};
|
||||
let session = connect_with(vcfg, vtx, Box::new(viewer_relay)).await.expect("connect");
|
||||
|
||||
// Input flows before view-only…
|
||||
session.mouse(10, 10, 0, 0.0, 0.0);
|
||||
assert!(
|
||||
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Mouse { .. }), 10).await.is_some(),
|
||||
"input should flow before view-only"
|
||||
);
|
||||
|
||||
// …is dropped while it is on…
|
||||
session.view_only(true);
|
||||
assert!(
|
||||
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ViewOnly { on: true }), 10)
|
||||
.await
|
||||
.is_some()
|
||||
);
|
||||
session.mouse(20, 20, 1, 0.0, 0.0);
|
||||
session.key("KeyB", true);
|
||||
session.view_only(false); // ordered marker: everything above processed first
|
||||
let between = collect_until_marker(
|
||||
&mut hev_rx,
|
||||
|e| matches!(e, HostEvent::ViewOnly { on: false }),
|
||||
10,
|
||||
)
|
||||
.await;
|
||||
for ev in &between {
|
||||
assert!(
|
||||
!matches!(ev, HostEvent::Mouse { .. } | HostEvent::Key { .. }),
|
||||
"input leaked through host-enforced view-only: {ev:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// …and flows again after it is off.
|
||||
session.mouse(30, 30, 0, 0.0, 0.0);
|
||||
assert!(
|
||||
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Mouse { .. }), 10).await.is_some(),
|
||||
"input should flow again after view-only off"
|
||||
);
|
||||
|
||||
session.close();
|
||||
drop(frame_tx);
|
||||
let _ = timeout(Duration::from_secs(5), host_task).await;
|
||||
println!("VIEW_ONLY_OK host-enforced input gate on/off mid-session");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4) Wire-level: session.accept advertises the REAL perms; approve mode holds
|
||||
// the session and Accept/Reject finalizes/denies it. Drives the raw
|
||||
// protocol by hand so the actual JSON on the wire is asserted.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A parsed session.challenge from the host.
|
||||
struct Challenge {
|
||||
sid: String,
|
||||
salt: String,
|
||||
iters: u32,
|
||||
nonce: String,
|
||||
pub_b64: String,
|
||||
totp: bool,
|
||||
}
|
||||
|
||||
/// Drive begin → challenge on a raw viewer-end transport; return the challenge.
|
||||
async fn raw_challenge(viewer: &mut MemTransport, host_id: &str) -> Challenge {
|
||||
viewer
|
||||
.send_text(format!(r#"{{"t":"session.begin","id":"{host_id}","peer":"203.0.113.7","user":"tester@example.com"}}"#))
|
||||
.await
|
||||
.unwrap();
|
||||
loop {
|
||||
match timeout(Duration::from_secs(10), viewer.recv()).await {
|
||||
Ok(Some(Ok(TransportMsg::Text(t)))) => {
|
||||
let v: Value = serde_json::from_str(&t).unwrap();
|
||||
if v["t"] == "session.challenge" {
|
||||
return Challenge {
|
||||
sid: v["sid"].as_str().unwrap().to_string(),
|
||||
salt: v["salt"].as_str().unwrap().to_string(),
|
||||
iters: v["iters"].as_u64().unwrap() as u32,
|
||||
nonce: v["nonce"].as_str().unwrap().to_string(),
|
||||
pub_b64: v["pub"].as_str().unwrap_or("").to_string(),
|
||||
totp: v["totp"].as_bool().unwrap_or(false),
|
||||
};
|
||||
}
|
||||
}
|
||||
_ => panic!("no session.challenge"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive begin → challenge → auth (plain proof only, legacy viewer). Returns sid.
|
||||
async fn raw_auth(viewer: &mut MemTransport, host_id: &str, password: &str) -> String {
|
||||
let ch = raw_challenge(viewer, host_id).await;
|
||||
let proof = password_proof(password, &ch.salt, &ch.nonce, ch.iters).unwrap();
|
||||
viewer
|
||||
.send_text(format!(r#"{{"t":"session.auth","sid":"{}","proof":"{proof}"}}"#, ch.sid))
|
||||
.await
|
||||
.unwrap();
|
||||
ch.sid
|
||||
}
|
||||
|
||||
async fn next_text(viewer: &mut MemTransport, secs: u64) -> Option<Value> {
|
||||
loop {
|
||||
match timeout(Duration::from_secs(secs), viewer.recv()).await {
|
||||
Ok(Some(Ok(TransportMsg::Text(t)))) => {
|
||||
return serde_json::from_str(&t).ok();
|
||||
}
|
||||
Ok(Some(Ok(TransportMsg::Binary(_)))) => continue,
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn accept_advertises_real_perms() {
|
||||
let (mut viewer, host_relay) = mem_relay_pair();
|
||||
let perms = HostPerms { input: true, clipboard: false, files: true, audio: false };
|
||||
let (hcfg, password) = host_cfg(perms, false);
|
||||
let host_id = hcfg.host_id.clone();
|
||||
let (_frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, _hev_rx) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
|
||||
let _host = tokio::spawn(async move {
|
||||
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
|
||||
});
|
||||
|
||||
raw_auth(&mut viewer, &host_id, &password).await;
|
||||
let accept = next_text(&mut viewer, 10).await.expect("session.accept");
|
||||
assert_eq!(accept["t"], "session.accept");
|
||||
let p = &accept["info"]["perms"];
|
||||
assert_eq!(p["input"], true, "perms.input must be advertised truthfully");
|
||||
assert_eq!(p["clipboard"], false, "perms.clipboard must be advertised truthfully");
|
||||
assert_eq!(p["files"], true, "perms.files must be advertised truthfully");
|
||||
assert_eq!(p["audio"], false, "perms.audio must be advertised truthfully");
|
||||
// Disabled audio also clears the audio capability flag.
|
||||
assert_eq!(accept["info"]["audio"], false);
|
||||
println!("PERMS_ADVERTISE_OK session.accept carries the enforced perms");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn approve_holds_then_rejects() {
|
||||
let (mut viewer, host_relay) = mem_relay_pair();
|
||||
let (hcfg, password) = host_cfg(HostPerms::default(), true);
|
||||
let host_id = hcfg.host_id.clone();
|
||||
let (_frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let (hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
|
||||
let _host = tokio::spawn(async move {
|
||||
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
|
||||
});
|
||||
|
||||
let sid = raw_auth(&mut viewer, &host_id, &password).await;
|
||||
|
||||
// The platform gets the approve request with the relay-reported identity…
|
||||
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ApproveRequest { .. }), 10).await {
|
||||
Some(HostEvent::ApproveRequest { sid: s, peer, user }) => {
|
||||
assert_eq!(s, sid);
|
||||
assert_eq!(peer, "203.0.113.7");
|
||||
assert_eq!(user, "tester@example.com");
|
||||
}
|
||||
_ => panic!("no ApproveRequest emitted in approve mode"),
|
||||
}
|
||||
// …and the wire stays SILENT (no session.accept while held).
|
||||
assert!(
|
||||
timeout(Duration::from_millis(900), viewer.recv()).await.is_err(),
|
||||
"session must be held (no accept) until the platform answers"
|
||||
);
|
||||
|
||||
// Reject → session.deny with a clear reason.
|
||||
hctl_tx
|
||||
.send(HostControl::ApproveReply { sid: sid.clone(), accept: false })
|
||||
.unwrap();
|
||||
let deny = next_text(&mut viewer, 10).await.expect("session.deny");
|
||||
assert_eq!(deny["t"], "session.deny");
|
||||
assert_eq!(deny["sid"], sid.as_str());
|
||||
assert_eq!(deny["reason"], "not accepted by host");
|
||||
println!("APPROVE_REJECT_OK held session denied with 'not accepted by host'");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn approve_accept_finalizes_session() {
|
||||
let (mut viewer, host_relay) = mem_relay_pair();
|
||||
let (hcfg, password) = host_cfg(HostPerms::default(), true);
|
||||
let host_id = hcfg.host_id.clone();
|
||||
let (_frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let (hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
|
||||
let _host = tokio::spawn(async move {
|
||||
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
|
||||
});
|
||||
|
||||
let sid = raw_auth(&mut viewer, &host_id, &password).await;
|
||||
assert!(
|
||||
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ApproveRequest { .. }), 10)
|
||||
.await
|
||||
.is_some()
|
||||
);
|
||||
hctl_tx
|
||||
.send(HostControl::ApproveReply { sid: sid.clone(), accept: true })
|
||||
.unwrap();
|
||||
let accept = next_text(&mut viewer, 10).await.expect("session.accept");
|
||||
assert_eq!(accept["t"], "session.accept");
|
||||
assert_eq!(accept["sid"], sid.as_str());
|
||||
assert_eq!(accept["info"]["perms"]["input"], true);
|
||||
assert!(
|
||||
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ViewerConnected { .. }), 10)
|
||||
.await
|
||||
.is_some(),
|
||||
"accepting must finalize the session (ViewerConnected)"
|
||||
);
|
||||
println!("APPROVE_ACCEPT_OK held session finalized on Accept");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5) Device-key binding (MITM hardening): a viewer that binds the host's REAL
|
||||
// ephemeral pub is accepted; one that binds a SWAPPED pub (what a malicious
|
||||
// relay would do) is denied "key verification failed". Legacy viewers that
|
||||
// send no proof_bind still connect (covered by the approve_* tests above).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn bound_proof_accepts_matching_key() {
|
||||
// Rebuild host_cfg inline so we keep both host_id and password.
|
||||
let (mut viewer, host_relay) = mem_relay_pair();
|
||||
let (hcfg, password) = host_cfg(HostPerms::default(), false);
|
||||
let host_id = hcfg.host_id.clone();
|
||||
let (_frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, _hev_rx) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
|
||||
tokio::spawn(async move {
|
||||
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
|
||||
});
|
||||
|
||||
let ch = raw_challenge(&mut viewer, &host_id).await;
|
||||
assert!(!ch.pub_b64.is_empty(), "host must offer an ECDH pub (E2E)");
|
||||
let proof = password_proof(&password, &ch.salt, &ch.nonce, ch.iters).unwrap();
|
||||
// Bind the host's REAL pub (what an honest, un-swapped path yields).
|
||||
let bind = password_proof_bound(&password, &ch.salt, &ch.nonce, ch.iters, &ch.pub_b64).unwrap();
|
||||
viewer
|
||||
.send_text(format!(
|
||||
r#"{{"t":"session.auth","sid":"{}","proof":"{proof}","proof_bind":"{bind}","pub":"{}"}}"#,
|
||||
ch.sid, ch.pub_b64
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let accept = next_text(&mut viewer, 10).await.expect("reply");
|
||||
assert_eq!(accept["t"], "session.accept", "matching bound proof must be accepted");
|
||||
println!("BIND_OK matching channel-bound proof accepted");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn bound_proof_rejects_swapped_key() {
|
||||
let (mut viewer, host_relay) = mem_relay_pair();
|
||||
let (hcfg, password) = host_cfg(HostPerms::default(), false);
|
||||
let host_id = hcfg.host_id.clone();
|
||||
let (_frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, _hev_rx) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
|
||||
tokio::spawn(async move {
|
||||
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
|
||||
});
|
||||
|
||||
let ch = raw_challenge(&mut viewer, &host_id).await;
|
||||
let proof = password_proof(&password, &ch.salt, &ch.nonce, ch.iters).unwrap();
|
||||
// A malicious relay swapped the host's pub: the viewer binds the ATTACKER's
|
||||
// pub, but the host verifies against its own real pub → mismatch → deny.
|
||||
// (A valid-looking but different P-256 point, base64 of 65 bytes 0x04||…)
|
||||
let swapped = {
|
||||
use netdesk_core::crypto::HostE2e;
|
||||
HostE2e::new().our_pub_b64 // a different, real ECDH pub
|
||||
};
|
||||
assert_ne!(swapped, ch.pub_b64, "the swapped pub must differ from the host's");
|
||||
let bind = password_proof_bound(&password, &ch.salt, &ch.nonce, ch.iters, &swapped).unwrap();
|
||||
viewer
|
||||
.send_text(format!(
|
||||
r#"{{"t":"session.auth","sid":"{}","proof":"{proof}","proof_bind":"{bind}","pub":"{}"}}"#,
|
||||
ch.sid, swapped
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let deny = next_text(&mut viewer, 10).await.expect("reply");
|
||||
assert_eq!(deny["t"], "session.deny", "a swapped-key bound proof must be denied");
|
||||
assert_eq!(deny["reason"], "key verification failed");
|
||||
println!("BIND_SWAP_OK relay key-swap detected + denied ('key verification failed')");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6) TOTP 2FA on the native host: the challenge advertises totp:true, a wrong/
|
||||
// missing code is denied "bad 2FA code", and a valid code is accepted.
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn totp_required_rejects_missing_and_accepts_valid() {
|
||||
use netdesk_core::crypto::{password_proof, totp_at};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; // RFC 6238 test secret
|
||||
|
||||
// --- wrong/missing code is denied ---
|
||||
{
|
||||
let (mut viewer, host_relay) = mem_relay_pair();
|
||||
let (hcfg, password) = host_cfg_totp(HostPerms::default(), false, secret);
|
||||
let host_id = hcfg.host_id.clone();
|
||||
let (_ft, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, _hev) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let (_hc, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
|
||||
tokio::spawn(async move {
|
||||
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
|
||||
});
|
||||
let ch = raw_challenge(&mut viewer, &host_id).await;
|
||||
assert!(ch.totp, "challenge must advertise totp:true when a secret is set");
|
||||
let proof = password_proof(&password, &ch.salt, &ch.nonce, ch.iters).unwrap();
|
||||
// Correct password, NO totp code → bad 2FA.
|
||||
viewer
|
||||
.send_text(format!(r#"{{"t":"session.auth","sid":"{}","proof":"{proof}"}}"#, ch.sid))
|
||||
.await
|
||||
.unwrap();
|
||||
let deny = next_text(&mut viewer, 10).await.expect("reply");
|
||||
assert_eq!(deny["t"], "session.deny");
|
||||
assert_eq!(deny["reason"], "bad 2FA code");
|
||||
}
|
||||
|
||||
// --- a valid, current code is accepted ---
|
||||
{
|
||||
let (mut viewer, host_relay) = mem_relay_pair();
|
||||
let (hcfg, password) = host_cfg_totp(HostPerms::default(), false, secret);
|
||||
let host_id = hcfg.host_id.clone();
|
||||
let (_ft, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, _hev) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let (_hc, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
|
||||
tokio::spawn(async move {
|
||||
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
|
||||
});
|
||||
let ch = raw_challenge(&mut viewer, &host_id).await;
|
||||
let proof = password_proof(&password, &ch.salt, &ch.nonce, ch.iters).unwrap();
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
|
||||
let code = totp_at(secret, now, 30, 6).unwrap();
|
||||
viewer
|
||||
.send_text(format!(
|
||||
r#"{{"t":"session.auth","sid":"{}","proof":"{proof}","totp":"{code}"}}"#,
|
||||
ch.sid
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let accept = next_text(&mut viewer, 10).await.expect("reply");
|
||||
assert_eq!(accept["t"], "session.accept", "a valid TOTP code must be accepted");
|
||||
}
|
||||
println!("TOTP_OK native host advertises + enforces TOTP (deny bad, accept valid)");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7) TCP tunnel routing (core): tunnel.open -> HostEvent::TunnelOpen; a raw
|
||||
// [0xF3][tid][payload] frame (NOT E2E) -> HostEvent::TunnelData ONLY when
|
||||
// that tid is open; HostControl::TunnelData -> a [0xF3] frame on the wire.
|
||||
// Proves the server-relayed tunnel plumbing + the 0xF3 disambiguation.
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn tunnel_routing_core() {
|
||||
let (mut viewer, host_relay) = mem_relay_pair();
|
||||
let (hcfg, _password) = host_cfg(HostPerms::default(), false);
|
||||
let (_ft, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let (hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
|
||||
tokio::spawn(async move {
|
||||
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
|
||||
});
|
||||
|
||||
// The server asks the host to open tunnel tid=7 (no video session needed).
|
||||
viewer
|
||||
.send_text(r#"{"t":"tunnel.open","tid":7,"thost":"127.0.0.1","port":5555}"#.into())
|
||||
.await
|
||||
.unwrap();
|
||||
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::TunnelOpen { .. }), 10).await {
|
||||
Some(HostEvent::TunnelOpen { tid, thost, port }) => {
|
||||
assert_eq!((tid, thost.as_str(), port), (7, "127.0.0.1", 5555));
|
||||
}
|
||||
_ => panic!("no TunnelOpen"),
|
||||
}
|
||||
|
||||
// Raw [0xF3][tid=7 LE][payload] -> TunnelData for an OPEN tid.
|
||||
let mut frame = vec![0xF3u8];
|
||||
frame.extend_from_slice(&7u32.to_le_bytes());
|
||||
frame.extend_from_slice(b"hello-tcp");
|
||||
viewer.send_binary(frame).await.unwrap();
|
||||
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::TunnelData { .. }), 10).await {
|
||||
Some(HostEvent::TunnelData { tid, data }) => {
|
||||
assert_eq!(tid, 7);
|
||||
assert_eq!(&data, b"hello-tcp");
|
||||
}
|
||||
_ => panic!("no TunnelData for an open tid"),
|
||||
}
|
||||
|
||||
// Outbound: HostControl::TunnelData -> a raw [0xF3][tid][data] on the wire.
|
||||
hctl_tx
|
||||
.send(HostControl::TunnelData { tid: 7, data: b"reply42".to_vec() })
|
||||
.unwrap();
|
||||
loop {
|
||||
match timeout(Duration::from_secs(10), viewer.recv()).await {
|
||||
Ok(Some(Ok(TransportMsg::Binary(b)))) => {
|
||||
assert_eq!(b[0], 0xF3);
|
||||
assert_eq!(u32::from_le_bytes([b[1], b[2], b[3], b[4]]), 7);
|
||||
assert_eq!(&b[5..], b"reply42");
|
||||
break;
|
||||
}
|
||||
Ok(Some(Ok(TransportMsg::Text(_)))) => continue,
|
||||
_ => panic!("no outbound tunnel frame"),
|
||||
}
|
||||
}
|
||||
println!("TUNNEL_CORE_OK tunnel.open + [0xF3] route in/out, tid-gated");
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
# Building NetDesk for Android
|
||||
|
||||
NetDesk's Android app is a Flutter UI (`ui/`) bound, through
|
||||
[flutter_rust_bridge](https://cjycode.com/flutter_rust_bridge/) (FRB v2), to a
|
||||
compiled Rust library. The Rust FFI layer is the **`netdesk-frb`** crate
|
||||
(`frb/`), a `cdylib` that statically links **`netdesk-core`** (signaling, E2E
|
||||
crypto, transport, H.264 decode). On Android the library ships inside the
|
||||
APK/AAB as `lib/<abi>/libnetdesk_frb.so` and the Dart side `dlopen`s it (the FRB
|
||||
loader looks for the stem `netdesk_frb`).
|
||||
|
||||
```
|
||||
netdesk-frb (+ netdesk-core) --cargo-ndk--> ui/android/app/src/main/jniLibs/<abi>/libnetdesk_frb.so
|
||||
Flutter (ui/) --gradle-----> release .apk / split .apk / .aab (signed)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The one command
|
||||
|
||||
From the repo root (`netdesk-native/`), in a shell whose `PATH` contains the
|
||||
MinGW tools, cargo, and Flutter:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File packaging\build-android.ps1
|
||||
```
|
||||
|
||||
That single command:
|
||||
|
||||
1. verifies / auto-provisions the toolchain (cargo-ndk, the rustup Android
|
||||
targets),
|
||||
2. detects the Android NDK at `C:\dev\android-ndk`,
|
||||
3. compiles `netdesk-frb` for **arm64-v8a, armeabi-v7a, x86_64** with cargo-ndk
|
||||
and drops `libnetdesk_frb.so` straight into
|
||||
`ui/android/app/src/main/jniLibs/<abi>/`,
|
||||
4. builds the release **fat APK**, the **per-ABI split APKs**, and the Play
|
||||
**App Bundle (.aab)**.
|
||||
|
||||
Useful switches:
|
||||
|
||||
| Switch | Effect |
|
||||
| --- | --- |
|
||||
| `-Target apk\|split\|aab\|rust\|all` | Build only that deliverable (default `all`; `rust` = just the `.so`). |
|
||||
| `-SkipRust` | Reuse the `.so` already in `jniLibs`, build Flutter only. |
|
||||
| `-Abis arm64-v8a` | Restrict the ABIs (default all three). |
|
||||
| `-GenerateKeystore` | Create the release keystore + `key.properties` first (see below). |
|
||||
| `-MinSdk 21` | Android API the `.so` links against (must be `<=` Flutter `minSdk`). |
|
||||
| `-NdkHome <path>` | Override the NDK location (default `C:\dev\android-ndk`). |
|
||||
|
||||
---
|
||||
|
||||
## Toolchain
|
||||
|
||||
| Component | Version / location used here |
|
||||
| --- | --- |
|
||||
| Rust | stable (cargo 1.99) on `PATH` |
|
||||
| rustup targets | `aarch64-linux-android`, `armv7-linux-androideabi`, `x86_64-linux-android` |
|
||||
| cargo-ndk | 4.1.2 (`cargo install cargo-ndk`) |
|
||||
| Android NDK | r26d (`26.3.11579264`) at `C:\dev\android-ndk` (`ANDROID_NDK_HOME`) |
|
||||
| Android SDK | `%LOCALAPPDATA%\Android\Sdk` |
|
||||
| Flutter | `C:\dev\flutter\bin\flutter.bat` |
|
||||
| JDK (keytool + Gradle) | Android Studio JBR (auto-detected) |
|
||||
| nasm | on `PATH` (needed by `openh264` for the x86_64 ABI) |
|
||||
|
||||
One-time setup (the build script also does the first three automatically):
|
||||
|
||||
```powershell
|
||||
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
|
||||
cargo install cargo-ndk
|
||||
# NDK: unzip the Android NDK r26+ to C:\dev\android-ndk (set ANDROID_NDK_HOME)
|
||||
```
|
||||
|
||||
Recommended `PATH` (Git Bash form):
|
||||
|
||||
```bash
|
||||
export PATH="/c/mingw-netdesk/mingw64/bin:$HOME/.cargo/bin:/c/dev/flutter/bin:$PATH"
|
||||
```
|
||||
|
||||
### What cargo-ndk runs
|
||||
|
||||
The Rust step is exactly:
|
||||
|
||||
```
|
||||
cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 \
|
||||
-o ui/android/app/src/main/jniLibs --platform 21 \
|
||||
build --release --package netdesk-frb --lib
|
||||
```
|
||||
|
||||
cargo-ndk sets the per-ABI clang/linker from the NDK, and the `-o` flag copies
|
||||
each produced `libnetdesk_frb.so` into `jniLibs/<abi>/`. The `.so` are build
|
||||
artifacts (git-ignored) and are regenerated on every run.
|
||||
|
||||
---
|
||||
|
||||
## Release signing
|
||||
|
||||
Signing credentials live in `ui/android/key.properties`, which is **git-ignored**
|
||||
(so are `*.keystore` / `*.jks`). A committable template is
|
||||
`ui/android/key.properties.example`.
|
||||
|
||||
`app/build.gradle.kts` loads `key.properties` when present and signs the release
|
||||
build with it; when it is absent the release build falls back to **debug**
|
||||
signing so `flutter run --release` still works during development.
|
||||
|
||||
Create the upload keystore once (the password is read from the environment and
|
||||
is **never** written into a committed file):
|
||||
|
||||
```powershell
|
||||
$env:NETDESK_KEYSTORE_PASSWORD = '<your-strong-password>'
|
||||
powershell -ExecutionPolicy Bypass -File packaging\build-android.ps1 -GenerateKeystore
|
||||
```
|
||||
|
||||
This writes:
|
||||
|
||||
* `ui/android/app/netdesk-release.keystore` - RSA 4096, alias `netdesk`, 10000-day validity,
|
||||
* `ui/android/key.properties` - with the alias, store file, and the password you
|
||||
supplied (local only; keep it out of version control).
|
||||
|
||||
> Back up the keystore and remember the password. If you lose either you can no
|
||||
> longer push updates to an app already published under that key (unless it uses
|
||||
> Play App Signing with a separate upload key you can reset).
|
||||
|
||||
---
|
||||
|
||||
## Outputs
|
||||
|
||||
After a full build:
|
||||
|
||||
| Deliverable | Path (under `ui/`) |
|
||||
| --- | --- |
|
||||
| Fat APK (all ABIs) | `build/app/outputs/flutter-apk/app-release.apk` |
|
||||
| Split APKs (per ABI) | `build/app/outputs/flutter-apk/app-arm64-v8a-release.apk`, `-armeabi-v7a-`, `-x86_64-` |
|
||||
| Play App Bundle | `build/app/outputs/bundle/release/app-release.aab` |
|
||||
|
||||
The fat APK is convenient for side-loading / direct download; the split APKs are
|
||||
smaller per device; the `.aab` is what Google Play requires.
|
||||
|
||||
---
|
||||
|
||||
## Publishing to Google Play (AAB)
|
||||
|
||||
1. Produce the signed bundle:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File packaging\build-android.ps1 -Target aab
|
||||
```
|
||||
|
||||
(or run the default `all` target). The bundle is
|
||||
`ui/build/app/outputs/bundle/release/app-release.aab`.
|
||||
|
||||
2. In the [Play Console](https://play.google.com/console), create the app, then
|
||||
upload the `.aab` under a release track (internal testing first).
|
||||
|
||||
3. Enrol in **Play App Signing** (recommended). Google then holds the *app
|
||||
signing key*; the keystore above becomes your *upload key*. If the upload key
|
||||
is ever compromised you can ask Google to reset it - which is why App Signing
|
||||
is preferred over shipping the raw signing key.
|
||||
|
||||
4. `versionCode` / `versionName` come from `ui/pubspec.yaml`
|
||||
(`version: x.y.z+build`). Bump the `+build` number for every Play upload - the
|
||||
Console rejects a duplicate `versionCode`.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
* **"Android NDK not found"** - the script could not find a complete NDK at
|
||||
`C:\dev\android-ndk` (it checks for `source.properties` +
|
||||
`toolchains\llvm\prebuilt`). Finish extracting the NDK, set `ANDROID_NDK_HOME`,
|
||||
or pass `-NdkHome <path>`. Use `-SkipRust` to build only the Flutter layer
|
||||
against an already-built `.so`.
|
||||
* **x86_64 ABI fails in `openh264`** - that ABI needs `nasm` on `PATH`. arm64-v8a
|
||||
and armeabi-v7a do not. Drop x86_64 with `-Abis arm64-v8a,armeabi-v7a` if you
|
||||
only ship to real devices.
|
||||
* **Gradle wants to download an NDK** - `app/build.gradle.kts` points AGP at
|
||||
`ANDROID_NDK_HOME` when it is set, so the prebuilt (already stripped) `.so` are
|
||||
packaged without a second NDK download. Make sure the env var is set (the build
|
||||
script sets it).
|
||||
* **`UnsatisfiedLinkError: libnetdesk_frb.so`** at runtime - the `.so` for the
|
||||
device's ABI is missing from `jniLibs`. Re-run the Rust step (`-Target rust`)
|
||||
for that ABI, or confirm the FRB loader stem is still `netdesk_frb`.
|
||||
@@ -0,0 +1,288 @@
|
||||
# NetDesk — iOS build (`.ipa`)
|
||||
|
||||
How to build the signed NetDesk iOS app — the Flutter UI on top of the compiled
|
||||
Rust `netdesk-core` engine — and ship it to TestFlight / the App Store.
|
||||
|
||||
> **This only builds on a Mac.** iOS toolchains (Xcode, the iOS SDKs,
|
||||
> `xcodebuild`, code signing) exist on macOS only. Nothing here can run on the
|
||||
> Windows dev box — Windows is used to *author* these scripts, the Mac runs
|
||||
> them. Everything below assumes macOS + Xcode.
|
||||
|
||||
---
|
||||
|
||||
## 1. What gets built
|
||||
|
||||
```
|
||||
core crate (netdesk-core, lib netdesk_core)
|
||||
│ cargo build --release --target aarch64-apple-ios → libnetdesk_core.a (device)
|
||||
│ cargo build --release --target aarch64-apple-ios-sim → libnetdesk_core.a (simulator)
|
||||
▼
|
||||
NetDeskCore.xcframework (xcodebuild -create-xcframework, both slices)
|
||||
│ vendored via ui/ios/netdesk_core.podspec + pod 'netdesk_core' in the Podfile
|
||||
▼
|
||||
Flutter iOS Runner ── flutter build ipa ──► ui/build/ios/ipa/netdesk.ipa
|
||||
```
|
||||
|
||||
The Rust static library is the FFI surface the Flutter UI binds to. Today that
|
||||
is the workspace **`core`** crate (`crate-type = ["lib","staticlib","cdylib"]`,
|
||||
lib name `netdesk_core` → `libnetdesk_core.a`). When the Phase-3
|
||||
`flutter_rust_bridge` wrapper crate lands under a different package name (e.g.
|
||||
`netdesk-frb`), build it with the same script by overriding two variables:
|
||||
|
||||
```bash
|
||||
CORE_CRATE=netdesk-frb LIB_NAME=netdesk_frb packaging/build-ios.sh --team <TEAM_ID>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Prerequisites
|
||||
|
||||
### 2.1 Apple Developer Program — **$99/year**
|
||||
|
||||
A paid **Apple Developer Program** membership (<https://developer.apple.com/programs/>,
|
||||
US$99/yr) is required to:
|
||||
|
||||
- create a **distribution signing certificate**,
|
||||
- register an **App ID** (bundle identifier) and a **provisioning profile**,
|
||||
- upload to **App Store Connect / TestFlight**.
|
||||
|
||||
The free personal team can only sideload to your own device for 7 days — not
|
||||
enough to ship. Enroll first; enrollment can take a day or two to clear.
|
||||
|
||||
### 2.2 Signing certificate + provisioning profile
|
||||
|
||||
You need, inside your developer account:
|
||||
|
||||
| Thing | Where | Notes |
|
||||
|-------|-------|-------|
|
||||
| **Team ID** | developer.apple.com → Membership | 10 chars, e.g. `ABCDE12345`. You pass this to the script. |
|
||||
| **App ID / bundle id** | Certificates, IDs & Profiles → Identifiers | Must match the app's `PRODUCT_BUNDLE_IDENTIFIER` (default `space.netdesk.netdesk`, set via `--org`). |
|
||||
| **Distribution certificate** | Certificates, IDs & Profiles → Certificates → *Apple Distribution* | One per team; Xcode can create it for you. |
|
||||
| **Provisioning profile** | Certificates, IDs & Profiles → Profiles → *App Store* (or Ad Hoc) | Ties the App ID + certificate + devices. |
|
||||
|
||||
**Easiest path — automatic signing.** Open the project once in Xcode, sign in
|
||||
with your Apple ID, tick *Automatically manage signing*, pick your Team. Xcode
|
||||
creates the certificate + profile for you and the command-line build reuses
|
||||
them. `build-ios.sh` writes `DEVELOPMENT_TEAM` + `CODE_SIGN_STYLE = Automatic`
|
||||
into `ui/ios/Flutter/{Release,Debug}.xcconfig`, so after that one-time Xcode
|
||||
sign-in the whole build is non-interactive.
|
||||
|
||||
**Manual signing** (CI, locked-down teams): download the distribution
|
||||
certificate (`.p12`) and the provisioning profile (`.mobileprovision`), install
|
||||
both into the login keychain / `~/Library/MobileDevice/Provisioning Profiles/`,
|
||||
then set `signingStyle` to `manual` and add a `provisioningProfiles` dict in
|
||||
`ui/ios/ExportOptions.plist`.
|
||||
|
||||
### 2.3 Toolchains on the Mac
|
||||
|
||||
```bash
|
||||
# Xcode + command line tools
|
||||
xcode-select --install # or install full Xcode from the App Store
|
||||
sudo xcode-select -s /Applications/Xcode.app
|
||||
|
||||
# Rust
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source "$HOME/.cargo/env"
|
||||
|
||||
# Flutter SDK (>= 3.27, per ui/pubspec.yaml) — add its bin/ to PATH
|
||||
# https://docs.flutter.dev/get-started/install/macos
|
||||
flutter doctor
|
||||
|
||||
# CocoaPods (Flutter will also install pods during the build)
|
||||
sudo gem install cocoapods
|
||||
```
|
||||
|
||||
> **PATH note.** On Windows the project builds with
|
||||
> `export PATH="/c/mingw-netdesk/mingw64/bin:$HOME/.cargo/bin:/c/dev/flutter/bin:$PATH"`.
|
||||
> On the **Mac** that line does not apply — just make sure `cargo`, `rustup`,
|
||||
> `flutter` and `xcrun` are on PATH (`source $HOME/.cargo/env`, Flutter's
|
||||
> `bin/` in your shell profile).
|
||||
|
||||
---
|
||||
|
||||
## 3. Generate the Flutter iOS project (one time)
|
||||
|
||||
`ui/` currently has `lib/` + `pubspec.yaml` but **no `ios/` folder** — it was
|
||||
never generated (it's platform scaffolding, not source). Create it on the Mac:
|
||||
|
||||
```bash
|
||||
cd ui
|
||||
flutter create --org space.netdesk --project-name netdesk --platforms=ios .
|
||||
```
|
||||
|
||||
This adds `ui/ios/` (the `Runner` Xcode project, `Runner.xcworkspace`,
|
||||
`Info.plist`, launch assets, the Flutter xcconfig layer) without touching your
|
||||
`lib/` Dart code. The bundle identifier becomes `<org>.netdesk`, i.e.
|
||||
`space.netdesk.netdesk` — change `--org` to use your own (e.g.
|
||||
`--org com.akronlogic` → `com.akronlogic.netdesk`), and register that exact id
|
||||
as the App ID in your developer account.
|
||||
|
||||
> On the Windows box the equivalent invocation (for reference only — it does not
|
||||
> produce a buildable iOS app on Windows) is:
|
||||
> `C:\dev\flutter\bin\flutter.bat create --platforms=ios .`
|
||||
|
||||
**`build-ios.sh` runs this step for you** if `ui/ios/` does not exist, so you
|
||||
can skip straight to §5. Run it by hand only if you want to pick the `--org`
|
||||
yourself before the first build.
|
||||
|
||||
---
|
||||
|
||||
## 4. The VideoToolbox decode path (why the frameworks matter)
|
||||
|
||||
NetDesk's iOS H.264 decode is **hardware** via **VideoToolbox**, implemented in
|
||||
[`core/src/decode_vt.rs`](../core/src/decode_vt.rs) and gated
|
||||
`#[cfg(any(target_os = "macos", target_os = "ios"))]`. It feeds Annex-B →
|
||||
AVCC-repacked access units into a `VTDecompressionSession`, builds the
|
||||
`CMVideoFormatDescription` from the first keyframe's SPS/PPS, and asks for
|
||||
32-bit BGRA output (VideoToolbox does the YUV→RGB on the GPU); openh264 is the
|
||||
software fallback if the session can't start.
|
||||
|
||||
That module links three Apple system frameworks directly with
|
||||
`#[link(kind = "framework")]`:
|
||||
|
||||
| Framework | Used for |
|
||||
|-----------|----------|
|
||||
| **VideoToolbox** | `VTDecompressionSession*` (the decoder itself) |
|
||||
| **CoreMedia** | `CMVideoFormatDescription` / `CMSampleBuffer` / `CMBlockBuffer` |
|
||||
| **CoreVideo** | `CVPixelBuffer*` (the decoded BGRA surface) |
|
||||
| **CoreFoundation** | `CFDictionary`/`CFString`/`CFRelease` (via `core-foundation-sys`, for the pixel-buffer attributes) |
|
||||
|
||||
A **static** Rust library records those `#[link]` directives in its metadata but
|
||||
does **not** pull the frameworks into the final app link. So the podspec
|
||||
(`ui/ios/netdesk_core.podspec`) names them explicitly:
|
||||
|
||||
```ruby
|
||||
s.frameworks = 'VideoToolbox', 'CoreMedia', 'CoreVideo', 'CoreFoundation'
|
||||
```
|
||||
|
||||
If you ever see link errors like `Undefined symbol: _VTDecompressionSessionCreate`
|
||||
or `_CMVideoFormatDescriptionCreateFromH264ParameterSets`, a framework is
|
||||
missing from that line. At runtime, the log line
|
||||
`decode: VideoToolbox H.264 (hardware)` confirms the hardware path was selected.
|
||||
|
||||
---
|
||||
|
||||
## 5. Build the `.ipa` — the exact commands
|
||||
|
||||
### One command (recommended)
|
||||
|
||||
```bash
|
||||
# from the repo root, on the Mac:
|
||||
packaging/build-ios.sh --team ABCDE12345
|
||||
```
|
||||
|
||||
Replace `ABCDE12345` with your 10-character Apple **Team ID**. That script does
|
||||
every step in §6 and prints the path to the finished `.ipa`. Useful variants:
|
||||
|
||||
```bash
|
||||
packaging/build-ios.sh --team ABCDE12345 --method app-store # default: App Store / TestFlight
|
||||
packaging/build-ios.sh --team ABCDE12345 --method ad-hoc # ad-hoc distribution
|
||||
packaging/build-ios.sh --team ABCDE12345 --method development # dev devices
|
||||
packaging/build-ios.sh --team ABCDE12345 --org com.akronlogic # bundle id com.akronlogic.netdesk (first run only)
|
||||
packaging/build-ios.sh --skip-ipa # build NetDeskCore.xcframework only, no signing needed
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
- `ui/build/ios/ipa/netdesk.ipa` — the signed app
|
||||
- `packaging/dist/ios/NetDeskCore.xcframework` — the Rust core (also staged into `ui/ios/`)
|
||||
|
||||
### The same steps by hand
|
||||
|
||||
If you'd rather run it step by step (this is exactly what the script does):
|
||||
|
||||
```bash
|
||||
# 0. Rust iOS targets
|
||||
rustup target add aarch64-apple-ios aarch64-apple-ios-sim
|
||||
|
||||
# 1. Build the Rust core static lib for device + simulator
|
||||
export IPHONEOS_DEPLOYMENT_TARGET=13.0
|
||||
cargo build --release -p netdesk-core --target aarch64-apple-ios
|
||||
cargo build --release -p netdesk-core --target aarch64-apple-ios-sim
|
||||
|
||||
# 2. Assemble the xcframework (delete any stale one first)
|
||||
rm -rf packaging/dist/ios/NetDeskCore.xcframework
|
||||
xcodebuild -create-xcframework \
|
||||
-library target/aarch64-apple-ios/release/libnetdesk_core.a \
|
||||
-library target/aarch64-apple-ios-sim/release/libnetdesk_core.a \
|
||||
-output packaging/dist/ios/NetDeskCore.xcframework
|
||||
|
||||
# 3. Generate ui/ios if needed, then wire the xcframework in
|
||||
cd ui
|
||||
flutter create --org space.netdesk --project-name netdesk --platforms=ios . # only if ui/ios is missing
|
||||
flutter pub get
|
||||
cp -R ../packaging/dist/ios/NetDeskCore.xcframework ios/
|
||||
cp ../packaging/ios/netdesk_core.podspec ios/netdesk_core.podspec
|
||||
# ... add `pod 'netdesk_core', :path => '.'` to ios/Podfile (Runner target),
|
||||
# ... add DEVELOPMENT_TEAM + CODE_SIGN_STYLE=Automatic to ios/Flutter/*.xcconfig,
|
||||
# ... write ios/ExportOptions.plist with your teamID + method.
|
||||
cd ios && pod install && cd ..
|
||||
|
||||
# 4. Build + sign the ipa
|
||||
flutter build ipa --release --export-options-plist=ios/ExportOptions.plist
|
||||
# → ui/build/ios/ipa/netdesk.ipa
|
||||
```
|
||||
|
||||
### Ship it
|
||||
|
||||
```bash
|
||||
# TestFlight / App Store (App Store Connect API key):
|
||||
xcrun altool --upload-app -f ui/build/ios/ipa/netdesk.ipa -t ios \
|
||||
--apiKey <KEY_ID> --apiIssuer <ISSUER_ID>
|
||||
```
|
||||
|
||||
Or open `ui/build/ios/archive/Runner.xcarchive` in **Xcode → Window →
|
||||
Organizer → Distribute App** and follow the GUI.
|
||||
|
||||
---
|
||||
|
||||
## 6. What `build-ios.sh` automates (summary)
|
||||
|
||||
1. **Preflight** — macOS, Xcode, `rustup`/`cargo`, `flutter`, and a Team ID (unless `--skip-ipa`).
|
||||
2. **`rustup target add`** `aarch64-apple-ios aarch64-apple-ios-sim`.
|
||||
3. **`cargo build --release`** the core staticlib for both targets (`IPHONEOS_DEPLOYMENT_TARGET=13.0`).
|
||||
4. **`xcodebuild -create-xcframework`** → `NetDeskCore.xcframework`.
|
||||
5. **`flutter create`** `ui/ios` if missing, copy the xcframework + podspec in, inject the pod into the Podfile, set the signing team in the Flutter xcconfigs, write `ExportOptions.plist`, `pod install`.
|
||||
6. **`flutter build ipa`** with your team and export method.
|
||||
|
||||
---
|
||||
|
||||
## 7. Gotchas
|
||||
|
||||
- **Apple-silicon vs Intel Mac.** The simulator slice defaults to
|
||||
`aarch64-apple-ios-sim` (Apple silicon). On an Intel Mac build the Intel
|
||||
simulator slice instead: `SIM_TARGET=x86_64-apple-ios packaging/build-ios.sh …`
|
||||
(and `rustup target add x86_64-apple-ios`). The device slice is always
|
||||
`aarch64-apple-ios`.
|
||||
- **First build compiles openh264 from source** (the software-decode fallback).
|
||||
That needs the Xcode clang/C++ toolchain (present with Xcode) — the first
|
||||
`cargo build` is slow; later ones are cached.
|
||||
- **Deployment target.** Keep `IPHONEOS_DEPLOYMENT_TARGET`, the podspec
|
||||
`platform :ios`, and the Runner's min iOS in sync (all `13.0` here). Mismatches
|
||||
cause CocoaPods warnings or link errors.
|
||||
- **Bundle id must match the App ID.** The provisioning profile is tied to a
|
||||
specific bundle id; if `--org` doesn't match a registered App ID, signing
|
||||
fails. Register the id in your developer account first.
|
||||
- **Phase-3 FFI.** This build links the Rust core but the Flutter UI does not
|
||||
*call* it yet (the Dart bindings are Phase 3: uncomment `flutter_rust_bridge`
|
||||
+ `ffi` in `ui/pubspec.yaml`, add `core/src/ffi.rs`, generate the header with
|
||||
`cbindgen`, add `-headers` to the `xcodebuild -create-xcframework` call, and
|
||||
run `flutter_rust_bridge_codegen generate`). Until then the archive may be
|
||||
dead-stripped out of the app — harmless; the signed `.ipa` still builds. See
|
||||
the commented `-force_load` block in `netdesk_core.podspec` to force a link
|
||||
test before Phase 3.
|
||||
- **`xcodebuild -create-xcframework` refuses to overwrite** an existing output —
|
||||
the script `rm -rf`s the old xcframework first; do the same by hand.
|
||||
|
||||
---
|
||||
|
||||
## 8. Files
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `packaging/build-ios.sh` | the end-to-end Mac build script |
|
||||
| `packaging/ios/netdesk_core.podspec` | CocoaPods spec vendoring the xcframework + system frameworks (copied to `ui/ios/`) |
|
||||
| `packaging/ios/Podfile` | Flutter iOS Podfile template with the `netdesk_core` pod (used if `ui/ios/Podfile` is absent) |
|
||||
| `packaging/dist/ios/NetDeskCore.xcframework` | build output — the compiled Rust core |
|
||||
| `core/src/decode_vt.rs` | the VideoToolbox hardware-decode implementation |
|
||||
| `docs/cross-platform-decode.md` | the per-platform decode design (VideoToolbox section) |
|
||||
@@ -0,0 +1,189 @@
|
||||
# NetDesk — Linux build & packaging
|
||||
|
||||
Build the native `netdesk-viewer` for Linux and bundle the two distributions
|
||||
**NetDesk by Akronlogic** ships on Linux:
|
||||
|
||||
| Edition | Artifact | Install? |
|
||||
|--------------|-----------------------------------|----------------------------------------|
|
||||
| **AppImage** | `NetDesk-<ver>-x86_64.AppImage` | No — `chmod +x` and run anywhere |
|
||||
| **.deb** | `netdesk_<ver>_amd64.deb` | Yes — `apt install ./…` (launcher + icon) |
|
||||
|
||||
Both are produced by [`packaging/build-linux.sh`](../packaging/build-linux.sh).
|
||||
Everything it writes lands in **`packaging/dist/linux/`** (git-ignored). This is
|
||||
the Linux counterpart of the Windows [`packaging/README.md`](../packaging/README.md).
|
||||
|
||||
> This target must be **built on Linux** (x86_64). The viewer links the system
|
||||
> FFmpeg/VAAPI libraries for the Linux hardware-decode path
|
||||
> (`core/src/decode_vaapi.rs`), which are not present when cross-building from
|
||||
> Windows or macOS.
|
||||
|
||||
---
|
||||
|
||||
## 1. Build dependencies
|
||||
|
||||
The Linux decode backend pulls in FFmpeg through the `ffmpeg-next` crate, whose
|
||||
build script (`ffmpeg-sys-next`) locates `libav*` with **pkg-config** and
|
||||
generates bindings with **libclang**. The GUI (eframe/egui on winit + glow)
|
||||
needs OpenGL at runtime. Install the toolchain + dev libraries first.
|
||||
|
||||
### Debian / Ubuntu
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y build-essential clang pkg-config nasm \
|
||||
libavcodec-dev libavformat-dev libavutil-dev libswscale-dev \
|
||||
libavfilter-dev libavdevice-dev libswresample-dev \
|
||||
libva-dev \
|
||||
libgl1-mesa-dev
|
||||
```
|
||||
|
||||
- `build-essential` → the C/C++ linker (`cc`/`g++`) Rust and `openh264-sys2` need.
|
||||
- `clang` → libclang for `ffmpeg-sys-next`'s bindgen.
|
||||
- `pkg-config` → locates the FFmpeg and VAAPI libraries.
|
||||
- `nasm` → assembler for the bundled `openh264` software-decode fallback.
|
||||
- `libav*-dev` → FFmpeg ≥ 4 development libraries (the full set matches the
|
||||
default features of `ffmpeg-next`).
|
||||
- `libva-dev` → VAAPI headers for the hardware-decode path.
|
||||
- `libgl1-mesa-dev` → OpenGL for the glow renderer.
|
||||
|
||||
### Fedora (FFmpeg dev libs come from RPM Fusion)
|
||||
|
||||
```bash
|
||||
sudo dnf install -y gcc gcc-c++ clang pkgconf-pkg-config nasm \
|
||||
ffmpeg-devel libva-devel mesa-libGL-devel
|
||||
```
|
||||
|
||||
### Optional — native file-picker
|
||||
|
||||
The viewer's **File transfer → Upload…** button uses `rfd`, which is compiled
|
||||
with **no Linux backend** in `viewer/Cargo.toml` (`default-features = false`).
|
||||
The build and the app run fine without it, but the picker is a no-op on Linux
|
||||
until `rfd` is given a backend feature:
|
||||
|
||||
- **GTK 3** backend → add `features = ["gtk3"]` to `rfd`, then install
|
||||
`libgtk-3-dev` (build) / `libgtk-3-0` (runtime).
|
||||
- **XDG portal** backend (no GTK) → add `features = ["xdg-portal", "tokio"]`;
|
||||
needs a running `xdg-desktop-portal` at runtime, nothing extra at build.
|
||||
|
||||
This is the only reason `libgtk` is ever involved on Linux — it is **not**
|
||||
required to build or run NetDesk as shipped.
|
||||
|
||||
---
|
||||
|
||||
## 2. The one command
|
||||
|
||||
```bash
|
||||
export PATH="$HOME/.cargo/bin:$PATH" # ensure cargo/rustup are reachable
|
||||
packaging/build-linux.sh
|
||||
```
|
||||
|
||||
That runs `cargo build --release -p netdesk-viewer`
|
||||
(target `x86_64-unknown-linux-gnu`) and then produces **both** the `.deb` and
|
||||
the AppImage in `packaging/dist/linux/`:
|
||||
|
||||
```
|
||||
packaging/dist/linux/
|
||||
├── netdesk_0.1.0_amd64.deb ← dpkg package (launcher + hicolor icons)
|
||||
├── NetDesk-0.1.0-x86_64.AppImage ← portable single file
|
||||
├── com.akronlogic.netdesk.desktop (generated launcher, shared by both)
|
||||
├── AppDir/ (AppImage staging)
|
||||
└── deb/ (.deb staging tree)
|
||||
```
|
||||
|
||||
> The underlying build command on its own:
|
||||
> ```bash
|
||||
> cargo build --release -p netdesk-viewer --target x86_64-unknown-linux-gnu
|
||||
> ```
|
||||
|
||||
### Flags
|
||||
|
||||
| Flag | Effect |
|
||||
|-----------------------|----------------------------------------------------|
|
||||
| `--skip-build` | reuse an existing `target/.../release/netdesk-viewer` |
|
||||
| `--no-appimage` | build the `.deb` only |
|
||||
| `--no-deb` | build the AppImage only |
|
||||
| `--target <triple>` | override the Rust target (default `x86_64-unknown-linux-gnu`) |
|
||||
| `LINUXDEPLOY=/path` | env: use a local `linuxdeploy` instead of downloading it |
|
||||
|
||||
The AppImage step downloads **linuxdeploy** once (cached in
|
||||
`packaging/dist/.tools/`) and runs it with `APPIMAGE_EXTRACT_AND_RUN=1`, so it
|
||||
works in containers/CI without FUSE. linuxdeploy `ldd`-scans the binary and
|
||||
bundles its shared libraries (`libavcodec`, `libavutil`, `libswscale`, …) into
|
||||
the AppImage; `libGL`, `libX11`/`xcb`, Wayland and `libva` are intentionally
|
||||
**not** bundled — they are `dlopen()`ed and must come from the host graphics
|
||||
stack. The `.deb` instead declares those as package `Depends:`.
|
||||
|
||||
---
|
||||
|
||||
## 3. VAAPI hardware-decode note
|
||||
|
||||
NetDesk hardware-decodes the host's H.264 stream on Linux through
|
||||
**VAAPI via FFmpeg/libavcodec** (`core/src/decode_vaapi.rs`): it opens the
|
||||
libavcodec H.264 decoder with a VAAPI `hw_device_ctx` on the default DRI render
|
||||
node (`/dev/dri/renderD128`), decodes on the GPU, pulls the surface to system
|
||||
memory (NV12), crops to the true display size, and swscales to the packed RGB8
|
||||
`Frame` the viewer paints — see [`cross-platform-decode.md`](cross-platform-decode.md)
|
||||
for the full backend matrix.
|
||||
|
||||
**Hardware decode is optional at runtime.** `best_decoder()` tries VAAPI first
|
||||
and **falls back to the openh264 software decoder** if no usable VAAPI device is
|
||||
present, so headless / GPU-less boxes still work. VAAPI is a build dependency
|
||||
(the libraries must link) but not a runtime requirement.
|
||||
|
||||
To get the hardware path at runtime on the target machine:
|
||||
|
||||
1. **Install a VAAPI driver** (the `.deb` lists these as `Recommends`):
|
||||
- Intel (Broadwell+): `intel-media-va-driver` (iHD)
|
||||
- Intel (older): `i965-va-driver`
|
||||
- AMD / others (Mesa): `mesa-va-drivers` (radeonsi)
|
||||
2. **Verify it:** `vainfo` should list an `H264` decode profile (e.g.
|
||||
`VAProfileH264High : VAEntrypointVLD`). Install `vainfo` from `libva-utils`.
|
||||
3. **Render-node access:** the user must be able to read
|
||||
`/dev/dri/renderD128` — typically by membership of the `render` (or `video`)
|
||||
group.
|
||||
4. **Driver override (if needed):** `export LIBVA_DRIVER_NAME=iHD` (or `i965`,
|
||||
`radeonsi`) when autodetection picks the wrong driver.
|
||||
|
||||
Confirm which path is live from the viewer's log:
|
||||
|
||||
```
|
||||
decode: VAAPI H.264 (FFmpeg hardware) ← hardware path selected
|
||||
VAAPI init failed (...); using openh264 ... ← fell back to software
|
||||
```
|
||||
|
||||
If it fell back, check `vainfo`, the `/dev/dri/renderD128` permissions, and
|
||||
`LIBVA_DRIVER_NAME`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Troubleshooting
|
||||
|
||||
- **`libavcodec` not found via pkg-config / bindgen errors** — the FFmpeg dev
|
||||
libraries or `clang` are missing. Re-run the install command in §1. For FFmpeg
|
||||
in a non-standard prefix, set `PKG_CONFIG_PATH`.
|
||||
- **`openh264` build fails for want of an assembler** — install `nasm`.
|
||||
- **AppImage not produced** (linuxdeploy download/FUSE error) — the script warns
|
||||
and still writes the `.deb`. Supply a local tool with `LINUXDEPLOY=/path`, or
|
||||
run with `--no-appimage`. `APPIMAGE_EXTRACT_AND_RUN=1` is already exported for
|
||||
FUSE-less hosts.
|
||||
- **`.deb` won't install — unmet FFmpeg dependency** — soname package names
|
||||
differ per release; the control file lists `libavcodec60 | 59 | 58 | ffmpeg`
|
||||
(and likewise for `libavutil`/`libswscale`). On an unusual release, installing
|
||||
the distro `ffmpeg` package satisfies the catch-all.
|
||||
- **Wrong window/taskbar icon on GNOME/Wayland** — the launcher file name and
|
||||
its `Icon=` key both use the app-id `com.akronlogic.netdesk`, matching the
|
||||
viewer's `with_app_id(...)`; for the `.deb`, `gtk-update-icon-cache` runs in
|
||||
`postinst`. Log out/in if the shell cached the old icon.
|
||||
|
||||
---
|
||||
|
||||
## 5. Test
|
||||
|
||||
```bash
|
||||
vainfo # confirm a VAAPI driver + H264 profile (optional)
|
||||
cargo test -p netdesk-core # core decode/transport tests
|
||||
./packaging/dist/linux/NetDesk-*.AppImage # smoke-test the viewer
|
||||
```
|
||||
|
||||
Smoke-test end to end against a live host; the log line
|
||||
`decode: VAAPI H.264 (FFmpeg hardware)` confirms the hardware path was selected.
|
||||
@@ -0,0 +1,293 @@
|
||||
# Building the NetDesk desktop app for macOS
|
||||
|
||||
This builds the **same** egui viewer/host-panel binary the Windows editions
|
||||
ship (crate `netdesk-viewer`) as a signed, notarized **universal** macOS app
|
||||
(`NetDesk.app`) and a distributable `.dmg`.
|
||||
|
||||
- One binary, two roles: the viewer window **and** the host panel, identical to
|
||||
the Windows build — only the packaging here is Mac-specific.
|
||||
- **Decode path:** on macOS the host H.264 (Annex-B) stream is decoded on the
|
||||
Apple video engine through **VideoToolbox** — `core/src/decode_vt.rs`
|
||||
(`VTDecompressionSession`, 32-bit BGRA output repacked to RGB8), gated
|
||||
`#[cfg(any(target_os = "macos", target_os = "ios"))]`. It falls back to the
|
||||
portable openh264 software decoder if the hardware path can't initialize. See
|
||||
[`cross-platform-decode.md`](./cross-platform-decode.md). No VideoToolbox-
|
||||
specific flags are needed at build time: the CoreMedia / CoreVideo /
|
||||
VideoToolbox framework `#[link]`s resolve against the active SDK.
|
||||
|
||||
> **Where this runs:** on the Mac (the M4), **not** the Windows dev box. Cross-
|
||||
> compiling a macOS `.app` from Windows is not supported — codesign, notarytool,
|
||||
> iconutil, lipo and hdiutil are all macOS-only tools.
|
||||
|
||||
The script is [`packaging/build-macos.sh`](../packaging/build-macos.sh). All
|
||||
outputs land in `packaging/dist/`.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — the Mac build command
|
||||
|
||||
Once the one-time Apple Developer setup below is done (Developer ID cert in the
|
||||
keychain + a stored `notarytool` profile named `netdesk`), the whole signed +
|
||||
notarized build is:
|
||||
|
||||
```bash
|
||||
# on the Mac, from the repo root:
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
bash packaging/build-macos.sh --notary-profile netdesk
|
||||
```
|
||||
|
||||
That produces, in `packaging/dist/`:
|
||||
|
||||
| Artifact | What it is |
|
||||
|----------------------------------------|---------------------------------------------|
|
||||
| `NetDesk.app` | signed + notarized + **stapled** universal app |
|
||||
| `NetDesk-macOS-universal.dmg` | signed + notarized + stapled disk image (ship this) |
|
||||
|
||||
Useful flags: `--skip-build` (reuse the per-target binaries), `--no-sign`
|
||||
(local unsigned test build), `--no-notarize` (sign but skip the Apple round-
|
||||
trip), `--identity "…"`, `--icon <png>`, `--version <x.y.z>`, `--entitlements
|
||||
<plist>`. Run `bash packaging/build-macos.sh --help` for the full list.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites (on the Mac)
|
||||
|
||||
1. **Xcode command-line tools** (gives `clang`, the SDKs, `lipo`, `iconutil`,
|
||||
`sips`, `hdiutil`, `codesign`, `xcrun`):
|
||||
|
||||
```bash
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
A full Xcode install from the App Store also works.
|
||||
|
||||
2. **Rust** via rustup. The script adds the two Apple-silicon/Intel std targets
|
||||
for you, but you can pre-add them:
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||
```
|
||||
|
||||
3. An **Apple Developer Program** membership (needed only for signing +
|
||||
notarization — a `--no-sign` build needs none). Set that up once, below.
|
||||
|
||||
---
|
||||
|
||||
## One-time Apple Developer setup
|
||||
|
||||
You need a **Developer ID Application** certificate (this is the identity that
|
||||
lets an app run on *any* Mac after notarization — distinct from the "Apple
|
||||
Development" / "Apple Distribution" / Mac App Store certs) and a way for
|
||||
`notarytool` to authenticate.
|
||||
|
||||
### 1. Enrol in the Apple Developer Program
|
||||
|
||||
<https://developer.apple.com/programs/> — \$99/yr. You must be the **Account
|
||||
Holder** (or have the Admin role) to create a Developer ID certificate.
|
||||
|
||||
### 2. Create the "Developer ID Application" certificate
|
||||
|
||||
Easiest, via Xcode:
|
||||
|
||||
1. Xcode → **Settings… → Accounts**, sign in with the Apple ID on the team.
|
||||
2. Select the team → **Manage Certificates…**
|
||||
3. Click **+** → **Developer ID Application**.
|
||||
4. Xcode creates the private key + cert and installs both into your **login
|
||||
keychain**. (Back up the private key — it cannot be re-downloaded.)
|
||||
|
||||
Or manually at <https://developer.apple.com/account/resources/certificates> →
|
||||
**+** → *Developer ID → Developer ID Application*, upload a CSR made with
|
||||
**Keychain Access → Certificate Assistant → Request a Certificate from a
|
||||
Certificate Authority**, then double-click the downloaded `.cer` to install it.
|
||||
|
||||
Verify it is present and usable for signing:
|
||||
|
||||
```bash
|
||||
security find-identity -v -p codesigning
|
||||
# look for a line like:
|
||||
# 1) ABCD… "Developer ID Application: Akronlogic LLC (TEAMID1234)"
|
||||
```
|
||||
|
||||
The script auto-detects this identity; override with `--identity "Developer ID
|
||||
Application: Akronlogic LLC (TEAMID1234)"` if you have more than one.
|
||||
|
||||
### 3. Find your Team ID
|
||||
|
||||
<https://developer.apple.com/account> → **Membership details** → *Team ID* (a
|
||||
10-character string, also shown in the parentheses of the identity name above).
|
||||
|
||||
### 4. Create credentials for notarytool
|
||||
|
||||
Two supported options — pick one:
|
||||
|
||||
**A. App-specific password (simplest).** At <https://appleid.apple.com> →
|
||||
*Sign-In and Security → App-Specific Passwords* → **+**, label it e.g.
|
||||
`netdesk-notarytool`. Then store it once in the keychain as a named profile:
|
||||
|
||||
```bash
|
||||
xcrun notarytool store-credentials netdesk \
|
||||
--apple-id "you@akronlogic.com" \
|
||||
--team-id "TEAMID1234" \
|
||||
--password "abcd-efgh-ijkl-mnop" # the app-specific password
|
||||
```
|
||||
|
||||
`netdesk` is the profile name you pass to `--notary-profile netdesk`.
|
||||
|
||||
**B. App Store Connect API key.** Create a key at *App Store Connect → Users and
|
||||
Access → Integrations → App Store Connect API* (role: *Developer*), download the
|
||||
`AuthKey_XXXX.p8`, and store it:
|
||||
|
||||
```bash
|
||||
xcrun notarytool store-credentials netdesk \
|
||||
--key "/path/AuthKey_XXXXXXXX.p8" \
|
||||
--key-id "XXXXXXXXXX" \
|
||||
--issuer "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
```
|
||||
|
||||
> Instead of a stored profile you can pass credentials inline each run:
|
||||
> `--apple-id you@akronlogic.com --team-id TEAMID1234` **plus** the app-specific
|
||||
> password in the environment: `export NETDESK_NOTARY_PASSWORD='abcd-efgh-…'`.
|
||||
|
||||
---
|
||||
|
||||
## What the script does (step by step)
|
||||
|
||||
`packaging/build-macos.sh`:
|
||||
|
||||
1. **rustup target add** `aarch64-apple-darwin` `x86_64-apple-darwin`.
|
||||
2. **cargo build --release -p netdesk-viewer** once per target — producing
|
||||
`target/aarch64-apple-darwin/release/netdesk-viewer` and the `x86_64` one.
|
||||
(Cross-building the Intel slice on an Apple-silicon Mac uses the same SDK and
|
||||
needs no extra tooling.)
|
||||
3. **lipo -create** the two into one universal Mach-O at
|
||||
`NetDesk.app/Contents/MacOS/NetDesk` (`lipo -info` prints `arm64 x86_64`).
|
||||
4. **iconutil** builds `NetDesk.icns` from the largest PNG master it finds
|
||||
(`assets/icon-1024.png` → `icon-512.png` → `icon.png`, or `--icon <png>`):
|
||||
`sips` resizes the master into a complete `.iconset` (16→1024 px, @1x/@2x),
|
||||
then `iconutil -c icns` packs it into `Contents/Resources/NetDesk.icns`.
|
||||
5. **Assembles `NetDesk.app`** — writes `Contents/Info.plist` with bundle id
|
||||
**`com.akronlogic.netdesk`**, `CFBundleVersion` / `CFBundleShortVersionString`
|
||||
= the workspace version (read from `Cargo.toml`, currently **0.1.0**),
|
||||
`CFBundleExecutable`/`CFBundleIconFile` = `NetDesk`, `LSMinimumSystemVersion`
|
||||
11.0, `NSHighResolutionCapable`, plus a `PkgInfo` (`APPL????`).
|
||||
6. **codesign** the executable and the bundle with the Developer ID Application
|
||||
identity, **`--options runtime`** (hardened runtime, required to notarize) and
|
||||
**`--timestamp`** (secure timestamp), then `codesign --verify --deep --strict`.
|
||||
7. **Notarize + staple the `.app`**: `ditto -c -k --keepParent` it into a zip,
|
||||
`xcrun notarytool submit … --wait`, then `xcrun stapler staple NetDesk.app`
|
||||
so the ticket travels with the app offline. (Skip with `--no-staple-app`.)
|
||||
8. **Build the `.dmg`** (`hdiutil create`, UDZO, with an `/Applications`
|
||||
symlink for drag-install), **sign** it, **notarize** it
|
||||
(`notarytool submit … --wait`), and **staple** it.
|
||||
|
||||
If no Developer ID identity is found (or `--no-sign`), it still produces an
|
||||
**unsigned** `.app` + `.dmg` and tells you so — handy for a local smoke test.
|
||||
If signing works but no notarization credentials are present, it signs and
|
||||
prints exactly how to add them, like the Windows installer script does when
|
||||
NSIS is missing.
|
||||
|
||||
---
|
||||
|
||||
## Exact commands (equivalent to what the script runs)
|
||||
|
||||
If you want to drive it by hand, this is the full sequence (`VERSION=0.1.0`,
|
||||
`ID="Developer ID Application: Akronlogic LLC (TEAMID1234)"`):
|
||||
|
||||
```bash
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
|
||||
# 1-2. targets + release builds
|
||||
rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||
cargo build --release -p netdesk-viewer --target aarch64-apple-darwin
|
||||
cargo build --release -p netdesk-viewer --target x86_64-apple-darwin
|
||||
|
||||
# 3. universal binary
|
||||
mkdir -p packaging/dist/NetDesk.app/Contents/MacOS packaging/dist/NetDesk.app/Contents/Resources
|
||||
lipo -create -output packaging/dist/NetDesk.app/Contents/MacOS/NetDesk \
|
||||
target/aarch64-apple-darwin/release/netdesk-viewer \
|
||||
target/x86_64-apple-darwin/release/netdesk-viewer
|
||||
|
||||
# 4. icon (ICONSET fully populated 16..1024 as in the script)
|
||||
iconutil -c icns NetDesk.iconset -o packaging/dist/NetDesk.app/Contents/Resources/NetDesk.icns
|
||||
|
||||
# 5. Info.plist (the script writes com.akronlogic.netdesk + version 0.1.0)
|
||||
|
||||
# 6. sign (hardened runtime + timestamp)
|
||||
codesign --force --timestamp --options runtime --sign "$ID" packaging/dist/NetDesk.app/Contents/MacOS/NetDesk
|
||||
codesign --force --timestamp --options runtime --sign "$ID" packaging/dist/NetDesk.app
|
||||
codesign --verify --deep --strict --verbose=2 packaging/dist/NetDesk.app
|
||||
|
||||
# 7. notarize + staple the app
|
||||
ditto -c -k --keepParent packaging/dist/NetDesk.app packaging/dist/NetDesk.zip
|
||||
xcrun notarytool submit packaging/dist/NetDesk.zip --keychain-profile netdesk --wait
|
||||
xcrun stapler staple packaging/dist/NetDesk.app
|
||||
|
||||
# 8. dmg -> sign -> notarize -> staple
|
||||
hdiutil create -volname NetDesk -srcfolder <stage-with-app+Applications-symlink> \
|
||||
-fs HFS+ -format UDZO -ov packaging/dist/NetDesk-macOS-universal.dmg
|
||||
codesign --force --timestamp --sign "$ID" packaging/dist/NetDesk-macOS-universal.dmg
|
||||
xcrun notarytool submit packaging/dist/NetDesk-macOS-universal.dmg --keychain-profile netdesk --wait
|
||||
xcrun stapler staple packaging/dist/NetDesk-macOS-universal.dmg
|
||||
```
|
||||
|
||||
The script wraps all of this (with the full `.iconset` generation, the plist,
|
||||
auto-detection, and graceful fallbacks) — prefer it over the manual steps.
|
||||
|
||||
---
|
||||
|
||||
## Verify a finished build
|
||||
|
||||
```bash
|
||||
# signature valid + hardened runtime + Developer-ID authority
|
||||
codesign --verify --deep --strict --verbose=2 packaging/dist/NetDesk.app
|
||||
codesign -dv --verbose=4 packaging/dist/NetDesk.app 2>&1 | grep -E 'Authority|Timestamp|flags|Identifier'
|
||||
|
||||
# Gatekeeper will accept it (Developer ID + notarized)
|
||||
spctl -a -vvv --type exec packaging/dist/NetDesk.app
|
||||
|
||||
# notarization ticket is stapled (works offline)
|
||||
xcrun stapler validate packaging/dist/NetDesk.app
|
||||
xcrun stapler validate packaging/dist/NetDesk-macOS-universal.dmg
|
||||
```
|
||||
|
||||
`spctl` should print `source=Notarized Developer ID` and `accepted`.
|
||||
|
||||
---
|
||||
|
||||
## Runtime permissions (host mode)
|
||||
|
||||
Signing/notarization are **not** the macOS capture permissions. When the binary
|
||||
runs as the **host** and first tries to capture the screen or inject input,
|
||||
macOS prompts the user (TCC) to grant, under **System Settings → Privacy &
|
||||
Security**:
|
||||
|
||||
- **Screen Recording** — to capture the remote screen.
|
||||
- **Accessibility** — to inject mouse/keyboard input (CGEvent).
|
||||
|
||||
These are user-granted at runtime and are **not** code-signing entitlements, so
|
||||
the default build needs no entitlements file. (If a future host feature drives
|
||||
*other* apps via Apple Events, add an entitlements plist with the matching keys
|
||||
and pass it via `--entitlements`.)
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`no identity found` / unsigned output** — the Developer ID Application cert
|
||||
(and its private key) must be in the login keychain of the user running the
|
||||
build; confirm with `security find-identity -v -p codesigning`.
|
||||
- **Notarization `Invalid` / rejected** — fetch the log:
|
||||
`xcrun notarytool log <submission-id> --keychain-profile netdesk`. Almost
|
||||
always a missing `--options runtime` (hardened runtime) or a missing secure
|
||||
`--timestamp`; the script sets both.
|
||||
- **`The binary is not signed with a valid Developer ID certificate`** — an
|
||||
*Apple Development* cert was used instead of *Developer ID Application*; only
|
||||
the latter notarizes for distribution outside the App Store.
|
||||
- **openh264 fails to cross-compile the x86_64 slice** — it builds C++ via the
|
||||
`cc` crate; the Xcode CLT clang handles `-arch x86_64` on an arm Mac, so a
|
||||
full `xcode-select --install` (or Xcode) is enough. If it still fails, build
|
||||
only the native slice with `--target aarch64-apple-darwin` and drop the lipo
|
||||
step (the app is then arm64-only).
|
||||
- **Icon looks soft** — add a 1024×1024 `assets/icon-1024.png` master (the repo
|
||||
currently ships 256 and 512); the script prefers the largest it finds.
|
||||
@@ -0,0 +1,223 @@
|
||||
# Cross-platform hardware H.264 decode
|
||||
|
||||
NetDesk's core decodes the host's H.264 (Annex-B) stream into packed-RGB8
|
||||
[`Frame`]s for the UI to paint. Every platform shares one trait and one output
|
||||
type, so the viewer never knows which backend is running:
|
||||
|
||||
```rust
|
||||
pub struct Frame { pub w: usize, pub h: usize, pub rgb: Vec<u8> } // w*h*3 bytes
|
||||
pub trait VideoDecoder { fn decode(&mut self, au: &[u8]) -> Result<Option<Frame>>; }
|
||||
pub fn best_decoder() -> Box<dyn VideoDecoder + Send>;
|
||||
```
|
||||
|
||||
`best_decoder()` selects the fastest decoder for the current OS at runtime and
|
||||
**always** falls back to the portable openh264 software decoder if the native
|
||||
hardware path cannot initialize (no GPU, missing runtime, headless box, etc.).
|
||||
|
||||
| Target | Backend | Module | API | Crate(s) |
|
||||
|---------------|----------------|---------------------------|-----------------------------|---------------------------------|
|
||||
| Windows | Media Foundation (DXVA2) | `core/src/decode_mf.rs` | `IMFTransform` (MSH264 MFT) | `windows` |
|
||||
| macOS / iOS | VideoToolbox | `core/src/decode_vt.rs` | `VTDecompressionSession` | `core-foundation(-sys)` + frameworks |
|
||||
| Android | MediaCodec | `core/src/decode_mediacodec.rs` | `AMediaCodec` (NDK) | `ndk-sys` / `libmediandk` |
|
||||
| Linux | VAAPI | `core/src/decode_vaapi.rs` | FFmpeg/libavcodec + VAAPI | `ffmpeg-next` |
|
||||
| All (fallback)| openh264 | `core/src/decode.rs` | software | `openh264` |
|
||||
|
||||
Each native module is `#[cfg]`-gated to its own OS, so building for one target
|
||||
never compiles (or even links) another target's module or its dependencies. The
|
||||
per-platform crates are declared under `[target.'cfg(...)'.dependencies]` in
|
||||
`core/Cargo.toml`; on a non-matching host they are only added to `Cargo.lock`
|
||||
(resolved), never built, and their build scripts never run.
|
||||
|
||||
All backends emit the **true display dimensions** (e.g. 1920×1080), cropping out
|
||||
any codec padding / stride / alignment, so frames are never stretched.
|
||||
|
||||
---
|
||||
|
||||
## Windows — Media Foundation (reference, builds here)
|
||||
|
||||
Already shipping. Build with the portable MinGW toolchain:
|
||||
|
||||
```bash
|
||||
export PATH="/c/mingw-netdesk/mingw64/bin:$HOME/.cargo/bin:$PATH"
|
||||
cargo build -p netdesk-core # or: --target x86_64-pc-windows-gnu
|
||||
```
|
||||
|
||||
### Dimension fix (2026-10)
|
||||
|
||||
The MS H.264 decoder MFT reports the **coded** size in `MF_MT_FRAME_SIZE`, which
|
||||
is padded up to the codec's macroblock alignment (1080p → a 1088+ coded height;
|
||||
other resolutions pad larger — this is what produced the wrong `1920x18xx` in the
|
||||
stats). The real picture rectangle is carried separately in a video-aperture
|
||||
blob. `decode_mf.rs` now:
|
||||
|
||||
1. Reads `MF_MT_FRAME_SIZE` into `coded_w`/`coded_h` — used **only** for the NV12
|
||||
plane layout (stride × plane height, and the UV plane offset).
|
||||
2. Reads the display rectangle from, in priority order,
|
||||
`MF_MT_MINIMUM_DISPLAY_APERTURE` → `MF_MT_GEOMETRIC_APERTURE` →
|
||||
`MF_MT_PAN_SCAN_APERTURE` (the last only when `MF_MT_PAN_SCAN_ENABLED`), as an
|
||||
`MFVideoArea` blob giving offset `(x, y)` and visible `width`×`height`. The
|
||||
aperture is clamped inside the coded frame so a malformed blob can't over-read.
|
||||
3. `nv12_to_rgb(..)` copies only the visible `width`×`height` region at
|
||||
`(crop_x, crop_y)` out of the padded NV12 (system-memory and DXGI/D3D11
|
||||
staging paths both), so `Frame.w`/`Frame.h` are the true `1920×1080`.
|
||||
|
||||
When no aperture is present the whole coded frame is treated as the picture
|
||||
(unchanged behaviour).
|
||||
|
||||
---
|
||||
|
||||
## macOS / iOS — VideoToolbox
|
||||
|
||||
`decode_vt.rs`, gated `#[cfg(any(target_os = "macos", target_os = "ios"))]`.
|
||||
|
||||
Feeds AUs into a `VTDecompressionSession` on the platform video engine and
|
||||
requests 32-bit BGRA output (VideoToolbox does YUV→RGB on the GPU), which is
|
||||
repacked to RGB8. The session is built lazily from the first keyframe's SPS/PPS
|
||||
(`CMVideoFormatDescriptionCreateFromH264ParameterSets`); Annex-B is repacked to
|
||||
AVCC for the `CMSampleBuffer`. `CVPixelBufferGetWidth/Height` already return the
|
||||
cropped display size, so no manual crop is needed.
|
||||
|
||||
Links the system **CoreMedia**, **CoreVideo** and **VideoToolbox** frameworks
|
||||
directly; `core-foundation` is used for the pixel-buffer attribute dictionary.
|
||||
|
||||
### Build (requires macOS + Xcode)
|
||||
|
||||
```bash
|
||||
xcode-select --install # or a full Xcode install
|
||||
rustup target add aarch64-apple-darwin x86_64-apple-darwin # macOS
|
||||
rustup target add aarch64-apple-ios aarch64-apple-ios-sim # iOS
|
||||
|
||||
cargo build -p netdesk-core --target aarch64-apple-darwin # Apple-silicon mac
|
||||
cargo build -p netdesk-core --target aarch64-apple-ios # device
|
||||
cargo build -p netdesk-core --target aarch64-apple-ios-sim # simulator
|
||||
```
|
||||
|
||||
The framework `#[link]`s resolve against the SDK the active toolchain selects
|
||||
(`xcrun --sdk macosx|iphoneos|iphonesimulator --show-sdk-path`). No extra flags.
|
||||
|
||||
### Test
|
||||
|
||||
```bash
|
||||
cargo test -p netdesk-core --target aarch64-apple-darwin
|
||||
```
|
||||
|
||||
Smoke-test end to end by running the viewer against a live host; the log line
|
||||
`decode: VideoToolbox H.264 (hardware)` confirms the path was selected.
|
||||
|
||||
---
|
||||
|
||||
## Android — MediaCodec
|
||||
|
||||
`decode_mediacodec.rs`, gated `#[cfg(target_os = "android")]`.
|
||||
|
||||
Drives a byte-buffer-mode `AMediaCodec` AVC decoder (the SoC hardware decoder via
|
||||
OMX/Codec2). The codec is configured lazily from the first keyframe's SPS/PPS as
|
||||
`csd-0`/`csd-1`; each `decode` queues the Annex-B AU as one input buffer and
|
||||
dequeues one output buffer. Output is YUV 4:2:0 — NV12 semi-planar or I420
|
||||
planar depending on the device — converted to RGB8 honouring the format's
|
||||
`stride`, `slice-height`, `color-format`, and `crop-left/top/right/bottom` keys,
|
||||
so the emitted frame is the true display size.
|
||||
|
||||
Talks to the stable NDK Media C ABI in **`libmediandk`** directly (the same ABI
|
||||
the `ndk-sys` crate binds), so it is version-independent.
|
||||
|
||||
### Build (requires the Android NDK)
|
||||
|
||||
Install the NDK (via Android Studio's SDK Manager or `sdkmanager "ndk;27.0.x"`),
|
||||
then use [`cargo-ndk`]:
|
||||
|
||||
```bash
|
||||
rustup target add aarch64-linux-android armv7-linux-androideabi \
|
||||
x86_64-linux-android i686-linux-android
|
||||
cargo install cargo-ndk
|
||||
export ANDROID_NDK_HOME=$HOME/Android/Sdk/ndk/27.0.12077973
|
||||
|
||||
# API 24+; links libmediandk / liblog automatically via the NDK sysroot.
|
||||
cargo ndk -t arm64-v8a -t armeabi-v7a -p 24 build -p netdesk-core --release
|
||||
```
|
||||
|
||||
Or without cargo-ndk, point Cargo at the NDK clang linker for each target, e.g.:
|
||||
|
||||
```bash
|
||||
export CC_aarch64_linux_android=$ANDROID_NDK_HOME/.../aarch64-linux-android24-clang
|
||||
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=$CC_aarch64_linux_android
|
||||
cargo build -p netdesk-core --target aarch64-linux-android --release
|
||||
```
|
||||
|
||||
### Test
|
||||
|
||||
Run on a device/emulator (the `mediandk` APIs are not present on the host):
|
||||
|
||||
```bash
|
||||
cargo ndk -t arm64-v8a build -p netdesk-core --tests
|
||||
# push the test binary with `adb push` and run it on-device, or exercise it
|
||||
# through the app's instrumented tests.
|
||||
```
|
||||
|
||||
The log line `decode: MediaCodec H.264 (hardware)` confirms selection.
|
||||
|
||||
---
|
||||
|
||||
## Linux — VAAPI (via FFmpeg)
|
||||
|
||||
`decode_vaapi.rs`, gated `#[cfg(target_os = "linux")]`.
|
||||
|
||||
Opens the libavcodec H.264 decoder with a VAAPI `hw_device_ctx` (default DRI
|
||||
render node). libavcodec does all bitstream parsing; decode runs on the GPU, the
|
||||
surface is pulled to system memory with `av_hwframe_transfer_data` (NV12),
|
||||
cropped to the display rectangle with `av_frame_apply_cropping`, and converted to
|
||||
RGB8 with swscale. If no VAAPI device is usable, `new()` errors and the core
|
||||
falls back to openh264, so GPU-less / headless boxes still work.
|
||||
|
||||
Pulls libav* in through the **`ffmpeg-next`** crate and needs the system FFmpeg
|
||||
(≥ 4) development libraries and a VAAPI driver at build/run time.
|
||||
|
||||
### Build (requires system FFmpeg + VAAPI dev packages)
|
||||
|
||||
Debian/Ubuntu:
|
||||
|
||||
```bash
|
||||
sudo apt install -y clang pkg-config \
|
||||
libavcodec-dev libavutil-dev libswscale-dev libavformat-dev \
|
||||
libva-dev vainfo intel-media-va-driver # or mesa-va-drivers for AMD
|
||||
|
||||
cargo build -p netdesk-core # host target
|
||||
```
|
||||
|
||||
Fedora:
|
||||
|
||||
```bash
|
||||
sudo dnf install clang pkgconf-pkg-config \
|
||||
ffmpeg-devel libva-devel libva-utils mesa-va-drivers
|
||||
cargo build -p netdesk-core
|
||||
```
|
||||
|
||||
`ffmpeg-sys-next` locates the libraries with `pkg-config`; set
|
||||
`PKG_CONFIG_PATH` if FFmpeg is in a non-standard prefix. `bindgen` needs
|
||||
`clang`/`libclang` (the `clang` package above).
|
||||
|
||||
### Test
|
||||
|
||||
```bash
|
||||
vainfo # confirm a VAAPI driver + H264 profile
|
||||
cargo test -p netdesk-core
|
||||
```
|
||||
|
||||
At runtime the log line `decode: VAAPI H.264 (FFmpeg hardware)` confirms the
|
||||
hardware path; `VAAPI init failed (...); using openh264 software decode` means it
|
||||
fell back (check `vainfo`, `/dev/dri/renderD128` permissions, and the
|
||||
`LIBVA_DRIVER_NAME` driver).
|
||||
|
||||
---
|
||||
|
||||
## Adding another backend
|
||||
|
||||
1. Add `core/src/decode_<name>.rs` implementing `VideoDecoder` + `unsafe impl
|
||||
Send`, with a `new() -> Result<Self>` that errors when the platform can't
|
||||
support it.
|
||||
2. Declare the module in `decode.rs` behind the right `#[cfg(...)]`.
|
||||
3. Add a `#[cfg(...)]` branch in `best_decoder()` before the openh264 fallback.
|
||||
4. Add the deps under a matching `[target.'cfg(...)'.dependencies]` in
|
||||
`core/Cargo.toml`.
|
||||
5. Emit the **true display size** (crop any codec padding) and BT.601 limited-
|
||||
range RGB to match the other backends.
|
||||
@@ -0,0 +1,32 @@
|
||||
# NetDesk flutter_rust_bridge (FRB v2) FFI layer. This crate is the Dart-callable
|
||||
# surface over `netdesk-core`: the Flutter UI (ui/) calls `connect(...)` plus the
|
||||
# input/control methods here, and receives a typed event stream (video frames,
|
||||
# chat, clipboard, lifecycle) over a flutter_rust_bridge StreamSink.
|
||||
# Developed by Akronlogic (https://akronlogic.com).
|
||||
[package]
|
||||
name = "netdesk-frb"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
description = "NetDesk flutter_rust_bridge FFI layer — Dart-callable API over netdesk-core for the Flutter UI. Developed by Akronlogic."
|
||||
|
||||
[lib]
|
||||
name = "netdesk_frb"
|
||||
# cdylib = the .so/.dll/.dylib the Flutter engine dlopen's on Android/Windows/Linux;
|
||||
# staticlib = the .a linked into the iOS/macOS app; lib = so the codegen + tests
|
||||
# can use the crate as a normal Rust library.
|
||||
crate-type = ["cdylib", "staticlib", "lib"]
|
||||
|
||||
# Silence the cosmetic `unexpected_cfgs` warning the `#[frb(sync)]` attribute
|
||||
# macro emits (it references an internal `frb_expand` cfg); this is the fix
|
||||
# flutter_rust_bridge documents.
|
||||
[lints.rust]
|
||||
unexpected_cfgs = { level = "allow", check-cfg = ['cfg(frb_expand)'] }
|
||||
|
||||
[dependencies]
|
||||
netdesk-core = { path = "../core" }
|
||||
# Pinned to the installed codegen (flutter_rust_bridge_codegen 2.13.0); FRB
|
||||
# requires the runtime crate and the codegen to be the same version.
|
||||
flutter_rust_bridge = "=2.13.0"
|
||||
tokio = { workspace = true }
|
||||
@@ -0,0 +1,6 @@
|
||||
# flutter_rust_bridge v2 codegen config for the NetDesk FFI layer.
|
||||
# Run from this directory: flutter_rust_bridge_codegen generate
|
||||
rust_input: crate::api
|
||||
rust_root: .
|
||||
dart_output: ../ui/lib/rust
|
||||
dart_root: ../ui
|
||||
@@ -0,0 +1,632 @@
|
||||
//! Dart-callable API (flutter_rust_bridge v2) over `netdesk-core`.
|
||||
//!
|
||||
//! Lifecycle from Dart:
|
||||
//! ```dart
|
||||
//! final handle = await connect(wsUrl: ..., hostId: ..., password: ..., totp: ...);
|
||||
//! sessionEvents(handle: handle).listen((ev) { /* NdEvent */ });
|
||||
//! sendMouse(handle: handle, x: .., y: .., buttons: .., wheel: .., hwheel: ..);
|
||||
//! disconnect(handle: handle);
|
||||
//! ```
|
||||
//!
|
||||
//! [`connect`] returns a `u64` **session handle** and starts the Tokio +
|
||||
//! `netdesk_core::connect` work on a background runtime; it is split from
|
||||
//! [`session_events`] because a flutter_rust_bridge function that takes a
|
||||
//! `StreamSink` returns a Dart `Stream` (so it cannot also return the handle).
|
||||
//! `connect` therefore buffers [`NdEvent`]s into a per-handle channel, and
|
||||
//! `session_events` taps that channel as the Dart-facing stream. All input /
|
||||
//! control calls ([`send_mouse`], [`send_key`], …) take the handle so the right
|
||||
//! [`Session`] is found in the global session table.
|
||||
//!
|
||||
//! Threading: a single process-wide multi-threaded Tokio runtime owns every
|
||||
//! session's network + decode task. The live [`Session`] is parked in a global
|
||||
//! `Mutex<HashMap<u64, Session>>` so the (cheap, synchronous) input/control
|
||||
//! functions can reach it from the Dart isolate thread without touching async.
|
||||
|
||||
use crate::frb_generated::StreamSink;
|
||||
use flutter_rust_bridge::frb;
|
||||
use netdesk_core::decode::{best_decoder, best_decoder_for};
|
||||
use netdesk_core::host::{run_host, HostConfig, HostEvent, HostFrame};
|
||||
use netdesk_core::{connect as core_connect, crypto, ConnectConfig, NdError, Session, SessionEvent};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Events streamed from the Rust core to the Flutter UI (the Dart-facing
|
||||
/// projection of `netdesk_core`'s richer `SessionEvent` — video access units
|
||||
/// are decoded to paint-ready RGBA frames here).
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum NdEvent {
|
||||
/// The WebSocket is dialing / the handshake is in flight.
|
||||
Connecting,
|
||||
/// Session accepted; the remote screen is about to stream.
|
||||
Connected {
|
||||
w: u32,
|
||||
h: u32,
|
||||
e2e: bool,
|
||||
host_name: String,
|
||||
},
|
||||
/// A decoded, paint-ready frame: tightly-packed RGBA8 (`width*height*4`).
|
||||
Frame {
|
||||
width: u32,
|
||||
height: u32,
|
||||
rgba: Vec<u8>,
|
||||
},
|
||||
/// The session ended (socket closed, host ended it, connect failed, …).
|
||||
Ended { reason: String },
|
||||
/// The host refused the session (bad password/TOTP, busy, blocked, …).
|
||||
Denied { reason: String },
|
||||
/// A chat message from the host.
|
||||
ChatMessage { from: String, text: String },
|
||||
/// Remote clipboard text pushed by the host (RustDesk-style sync).
|
||||
Clipboard { text: String },
|
||||
}
|
||||
|
||||
/// Process-wide Tokio runtime that owns every session's network + decode task.
|
||||
fn runtime() -> &'static tokio::runtime::Runtime {
|
||||
static RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
|
||||
RT.get_or_init(|| {
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("netdesk-frb: build tokio runtime")
|
||||
})
|
||||
}
|
||||
|
||||
/// Live sessions, keyed by the `u64` handle [`connect`] returns.
|
||||
fn sessions() -> &'static Mutex<HashMap<u64, Session>> {
|
||||
static SESSIONS: OnceLock<Mutex<HashMap<u64, Session>>> = OnceLock::new();
|
||||
SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Event receivers parked between [`connect`] (the producer) and
|
||||
/// [`session_events`] (the Dart-facing stream), keyed by the same handle. The
|
||||
/// channel is unbounded so events emitted before Dart subscribes are buffered,
|
||||
/// not dropped.
|
||||
fn pending() -> &'static Mutex<HashMap<u64, mpsc::UnboundedReceiver<NdEvent>>> {
|
||||
static PENDING: OnceLock<Mutex<HashMap<u64, mpsc::UnboundedReceiver<NdEvent>>>> =
|
||||
OnceLock::new();
|
||||
PENDING.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Per-handle "the user asked to stop" flags. The reconnect loop checks this to
|
||||
/// tell a deliberate [`disconnect`] (leave the session) from a network drop
|
||||
/// (auto-reconnect). Shared as an `Arc` so the loop keeps its own reference even
|
||||
/// after `disconnect` removes the map entry.
|
||||
fn cancels() -> &'static Mutex<HashMap<u64, Arc<AtomicBool>>> {
|
||||
static CANCELS: OnceLock<Mutex<HashMap<u64, Arc<AtomicBool>>>> = OnceLock::new();
|
||||
CANCELS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Monotonic source of session handles. Starts at 1 so `0` can mean "none".
|
||||
static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// Expand tightly-packed RGB8 (what the decoders emit) to RGBA8 (what Flutter's
|
||||
/// `ui.decodeImageFromPixels` / a texture wants), forcing full alpha.
|
||||
fn rgb_to_rgba(rgb: &[u8], w: usize, h: usize) -> Vec<u8> {
|
||||
let mut out = vec![0u8; w * h * 4];
|
||||
for (src, dst) in rgb.chunks_exact(3).zip(out.chunks_exact_mut(4)) {
|
||||
dst[0] = src[0];
|
||||
dst[1] = src[1];
|
||||
dst[2] = src[2];
|
||||
dst[3] = 0xff;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Start a session against `ws_url` / `host_id` with `password` + `totp`, and
|
||||
/// return its **handle**. The actual connect + handshake + H.264 decode run on
|
||||
/// the background Tokio runtime; subscribe to the resulting [`NdEvent`]s with
|
||||
/// [`session_events`] (passing this handle), and drive input / control with the
|
||||
/// other functions here. A failed connect still returns a valid handle and
|
||||
/// reports [`NdEvent::Ended`] / [`NdEvent::Denied`] on the stream.
|
||||
pub fn connect(ws_url: String, host_id: String, password: String, totp: String) -> u64 {
|
||||
let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed);
|
||||
let cfg = ConnectConfig {
|
||||
ws_url,
|
||||
host_id,
|
||||
password,
|
||||
totp,
|
||||
takeover: false,
|
||||
};
|
||||
// UI-facing event channel; `session_events` drains the receiver into a sink.
|
||||
let (ev_tx, ev_rx) = mpsc::unbounded_channel::<NdEvent>();
|
||||
pending().lock().unwrap().insert(handle, ev_rx);
|
||||
|
||||
// Cancel flag: `disconnect` sets it so the reconnect loop stops instead of
|
||||
// re-dialing. Kept as an Arc so the loop holds its own reference even after
|
||||
// `disconnect` removes the map entry.
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
cancels().lock().unwrap().insert(handle, cancel.clone());
|
||||
|
||||
runtime().spawn(async move {
|
||||
// Resilient session: keep the remote alive across network drops, host
|
||||
// restarts and roaming. On an unexpected disconnect we re-dial the SAME
|
||||
// host under the SAME handle (so send_mouse/key keep routing) with a
|
||||
// short backoff, surfacing a `Connecting` blip instead of ending. We
|
||||
// stop only on a deliberate `disconnect` (cancel) or an auth `Denied`.
|
||||
let mut backoff: u64 = 1;
|
||||
let mut first = true;
|
||||
let mut was_denied = false;
|
||||
loop {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
if !first {
|
||||
// reconnecting: tell the UI the link is being re-established.
|
||||
let _ = ev_tx.send(NdEvent::Connecting);
|
||||
}
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
let session = match core_connect(cfg.clone(), tx).await {
|
||||
Ok(s) => s,
|
||||
Err(NdError::Denied(reason)) => {
|
||||
// Bad password / TOTP / blocked / host offline: never
|
||||
// succeeds on retry. Surface the real reason and stop.
|
||||
let _ = ev_tx.send(NdEvent::Denied { reason });
|
||||
was_denied = true;
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
// Transient (server/host not reachable yet): back off + retry.
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(backoff)).await;
|
||||
backoff = (backoff * 2).min(5);
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Fresh connection: immediate keyframe + park under the handle. A new
|
||||
// decoder per connection avoids carrying stale reference frames over
|
||||
// the gap.
|
||||
session.request_keyframe();
|
||||
sessions().lock().unwrap().insert(handle, session);
|
||||
backoff = 1;
|
||||
first = false;
|
||||
|
||||
let mut decoder = best_decoder();
|
||||
let mut cur_codec: u8 = 0; // 0 = H.264 (proto::VideoCodec)
|
||||
while let Some(ev) = rx.recv().await {
|
||||
match ev {
|
||||
SessionEvent::Connecting => {
|
||||
let _ = ev_tx.send(NdEvent::Connecting);
|
||||
}
|
||||
SessionEvent::Connected {
|
||||
host_name,
|
||||
e2e,
|
||||
w,
|
||||
h,
|
||||
} => {
|
||||
let _ = ev_tx.send(NdEvent::Connected { w, h, e2e, host_name });
|
||||
}
|
||||
SessionEvent::Video { au, keyframe, codec, .. } => {
|
||||
// Follow the host's per-frame codec tag (H.264 / VP9),
|
||||
// swapping the decoder only on a keyframe.
|
||||
if codec != cur_codec {
|
||||
if keyframe {
|
||||
decoder = best_decoder_for(codec);
|
||||
cur_codec = codec;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Feed the access unit to the active decoder; emit a frame
|
||||
// only when it produces a picture.
|
||||
if let Ok(Some(frame)) = decoder.decode(&au) {
|
||||
let rgba = rgb_to_rgba(&frame.rgb, frame.w, frame.h);
|
||||
let _ = ev_tx.send(NdEvent::Frame {
|
||||
width: frame.w as u32,
|
||||
height: frame.h as u32,
|
||||
rgba,
|
||||
});
|
||||
}
|
||||
}
|
||||
SessionEvent::ChatMessage { from, text } => {
|
||||
let _ = ev_tx.send(NdEvent::ChatMessage { from, text });
|
||||
}
|
||||
SessionEvent::Clipboard { text } => {
|
||||
let _ = ev_tx.send(NdEvent::Clipboard { text });
|
||||
}
|
||||
SessionEvent::Denied { reason } => {
|
||||
let _ = ev_tx.send(NdEvent::Denied { reason });
|
||||
was_denied = true;
|
||||
break;
|
||||
}
|
||||
SessionEvent::Ended { .. } => {
|
||||
// Connection dropped: leave the inner loop and reconnect
|
||||
// (unless the user asked to stop, checked below).
|
||||
break;
|
||||
}
|
||||
// Challenged / Audio / Info / SysResult / file-transfer events
|
||||
// are not surfaced to the mobile UI in this phase.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
sessions().lock().unwrap().remove(&handle);
|
||||
if was_denied || cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
// Unexpected drop: brief pause, then reconnect (top emits Connecting).
|
||||
tokio::time::sleep(Duration::from_secs(backoff)).await;
|
||||
backoff = (backoff * 2).min(5);
|
||||
}
|
||||
// Finished for good (user left or auth denied): clean up + close the UI
|
||||
// stream so Dart's `.listen` onDone fires.
|
||||
sessions().lock().unwrap().remove(&handle);
|
||||
cancels().lock().unwrap().remove(&handle);
|
||||
// Only emit a generic Ended when the user left (cancel). A Denied already
|
||||
// carried its real reason (wrong password / host offline / busy) - don't
|
||||
// overwrite it with "disconnected".
|
||||
if !was_denied {
|
||||
let _ = ev_tx.send(NdEvent::Ended {
|
||||
reason: "disconnected".into(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
handle
|
||||
}
|
||||
|
||||
/// Subscribe to the [`NdEvent`] stream for a `handle` returned by [`connect`].
|
||||
/// In Dart this is `Stream<NdEvent> sessionEvents({required BigInt handle})`.
|
||||
/// Call it once per handle, right after `connect`.
|
||||
pub fn session_events(handle: u64, sink: StreamSink<NdEvent>) {
|
||||
let rx = pending().lock().unwrap().remove(&handle);
|
||||
if let Some(mut rx) = rx {
|
||||
runtime().spawn(async move {
|
||||
while let Some(ev) = rx.recv().await {
|
||||
if sink.add(ev).is_err() {
|
||||
break; // Dart dropped the subscription
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a helper against the live session for `handle`, if it still exists.
|
||||
fn with_session<R>(handle: u64, f: impl FnOnce(&Session) -> R) -> Option<R> {
|
||||
sessions().lock().unwrap().get(&handle).map(f)
|
||||
}
|
||||
|
||||
/// Send a pointer event (coordinates in the remote screen's pixel space).
|
||||
/// `buttons` is the button bitmask, `wheel`/`hwheel` are scroll deltas.
|
||||
#[frb(sync)]
|
||||
pub fn send_mouse(handle: u64, x: i32, y: i32, buttons: u32, wheel: f32, hwheel: f32) {
|
||||
with_session(handle, |s| s.mouse(x, y, buttons, wheel, hwheel));
|
||||
}
|
||||
|
||||
/// Send a key event. `code` is a DOM-style key code (e.g. "KeyA", "Enter").
|
||||
#[frb(sync)]
|
||||
pub fn send_key(handle: u64, code: String, down: bool) {
|
||||
with_session(handle, |s| s.key(&code, down));
|
||||
}
|
||||
|
||||
/// Reconfigure the host encoder: target `fps`, `bitrate` (kbps), resolution `scale`.
|
||||
#[frb(sync)]
|
||||
pub fn set_quality(handle: u64, fps: u32, bitrate: u32, scale: f32) {
|
||||
with_session(handle, |s| s.set_quality(fps, bitrate, scale));
|
||||
}
|
||||
|
||||
/// Switch the captured monitor to index `i` (as advertised by
|
||||
/// [`session_monitors`]). Immediately asks the host for a fresh keyframe so the
|
||||
/// new display paints without waiting for the next natural IDR — the messages
|
||||
/// travel the same WebSocket in order, so the keyframe is of the *new* monitor.
|
||||
#[frb(sync)]
|
||||
pub fn monitor_switch(handle: u64, i: i32) {
|
||||
with_session(handle, |s| {
|
||||
s.monitor_switch(i);
|
||||
s.request_keyframe();
|
||||
});
|
||||
}
|
||||
|
||||
/// A display the host advertised at accept time (index + pixel size).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NdMonitor {
|
||||
pub i: i32,
|
||||
pub w: u32,
|
||||
pub h: u32,
|
||||
}
|
||||
|
||||
/// The real list of displays the host reported (its `info.monitors`). The UI
|
||||
/// uses this to show exactly the monitors that exist — never a hardcoded count.
|
||||
/// Returns empty until the session is accepted, or if the host sent none.
|
||||
#[frb(sync)]
|
||||
pub fn session_monitors(handle: u64) -> Vec<NdMonitor> {
|
||||
with_session(handle, |s| {
|
||||
s.monitors()
|
||||
.iter()
|
||||
.map(|m| NdMonitor {
|
||||
i: m.i,
|
||||
w: m.w,
|
||||
h: m.h,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The monitor index the host is currently capturing (as reported at accept).
|
||||
#[frb(sync)]
|
||||
pub fn session_current_monitor(handle: u64) -> i32 {
|
||||
with_session(handle, |s| s.current_monitor()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Send Ctrl+Alt+Del on the host.
|
||||
#[frb(sync)]
|
||||
pub fn ctrl_alt_del(handle: u64) {
|
||||
with_session(handle, |s| s.ctrl_alt_del());
|
||||
}
|
||||
|
||||
/// Send a chat message to the host.
|
||||
#[frb(sync)]
|
||||
pub fn send_chat(handle: u64, text: String) {
|
||||
with_session(handle, |s| s.chat(&text));
|
||||
}
|
||||
|
||||
/// Push text to the remote clipboard (and announce our local clipboard).
|
||||
#[frb(sync)]
|
||||
pub fn set_clipboard(handle: u64, text: String) {
|
||||
with_session(handle, |s| s.clipboard(&text));
|
||||
}
|
||||
|
||||
/// End the session: tell the host, then drop the handle. The event stream
|
||||
/// receives a final [`NdEvent::Ended`] when the network task unwinds.
|
||||
#[frb(sync)]
|
||||
pub fn disconnect(handle: u64) {
|
||||
// Tell the reconnect loop to stop re-dialing, then tear down the live
|
||||
// connection. The loop sees the cancel flag after the socket closes and
|
||||
// exits instead of auto-reconnecting.
|
||||
if let Some(c) = cancels().lock().unwrap().get(&handle) {
|
||||
c.store(true, Ordering::Relaxed);
|
||||
}
|
||||
pending().lock().unwrap().remove(&handle);
|
||||
if let Some(s) = sessions().lock().unwrap().remove(&handle) {
|
||||
s.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// HOST role — let a viewer connect to and control THIS device (Android host).
|
||||
// Mirrors the viewer API: host_start() returns a handle; host_events() taps the
|
||||
// input/control stream; host_push_frame() feeds encoded H.264 from the platform
|
||||
// encoder (MediaProjection+MediaCodec); host_stop() tears it down. The run loop
|
||||
// auto-reconnects (backoff 1-5s) so the device stays reachable.
|
||||
// ===========================================================================
|
||||
|
||||
/// Dart-facing projection of `netdesk_core::host::HostEvent`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum NdHostEvent {
|
||||
/// Registered with the server; the device is reachable at its host id.
|
||||
Registered,
|
||||
/// A viewer authenticated; the session is live (inject input, feed frames).
|
||||
ViewerConnected { e2e: bool },
|
||||
/// The viewer left; the host stays registered for the next one.
|
||||
ViewerLeft,
|
||||
/// Pointer in the captured image's pixel space (buttons = bitmask).
|
||||
Mouse { x: i32, y: i32, buttons: u32, wheel: f32 },
|
||||
/// Key: DOM-style code + down/up.
|
||||
Key { code: String, down: bool },
|
||||
/// The viewer asked for a fresh keyframe — request one from the encoder.
|
||||
KeyframeRequested,
|
||||
/// The viewer changed quality targets.
|
||||
Quality { fps: u32, bitrate: u32, scale: f32 },
|
||||
/// The server link dropped; auto-reconnecting.
|
||||
Reconnecting,
|
||||
/// The host loop stopped for good (host_stop / fatal).
|
||||
Ended { reason: String },
|
||||
}
|
||||
|
||||
fn map_host_event(e: HostEvent) -> Option<NdHostEvent> {
|
||||
Some(match e {
|
||||
HostEvent::Registered => NdHostEvent::Registered,
|
||||
HostEvent::ViewerConnected { e2e, .. } => NdHostEvent::ViewerConnected { e2e },
|
||||
HostEvent::ViewerLeft => NdHostEvent::ViewerLeft,
|
||||
HostEvent::Mouse { x, y, buttons, wheel } => NdHostEvent::Mouse { x, y, buttons, wheel },
|
||||
HostEvent::Key { code, down } => NdHostEvent::Key { code, down },
|
||||
// Relative/game mouse, Unicode (translate-keyboard) text, and the
|
||||
// annotation overlay are desktop-host input/overlay modes the native
|
||||
// engine handles directly; the Android host bridge doesn't surface them.
|
||||
HostEvent::MouseRel { .. } => return None,
|
||||
HostEvent::Text { .. } => return None,
|
||||
HostEvent::Annot { .. } => return None,
|
||||
HostEvent::KeyframeRequested => NdHostEvent::KeyframeRequested,
|
||||
HostEvent::Quality { fps, bitrate, scale } => NdHostEvent::Quality { fps, bitrate, scale },
|
||||
HostEvent::Ended { reason } => NdHostEvent::Ended { reason },
|
||||
// Android host clipboard-to-host / sys / file controls are not wired yet.
|
||||
HostEvent::Clipboard { .. } => return None,
|
||||
HostEvent::ClipboardImage { .. } => return None,
|
||||
HostEvent::Sys { .. } => return None,
|
||||
HostEvent::File { .. } => return None,
|
||||
HostEvent::FileChunk { .. } => return None,
|
||||
// Voice-call playback on the Android host is not wired yet (needs an
|
||||
// Android AudioTrack sink); the native desktop host handles it.
|
||||
HostEvent::Voice { .. } => return None,
|
||||
// Adaptive-QoS telemetry is consumed by the native engine, not the UI.
|
||||
HostEvent::NetStat { .. } => return None,
|
||||
// The viewer's audio-source pick is handled by the native capture/audio
|
||||
// engine; the Android host UI doesn't surface it.
|
||||
HostEvent::AudioSource { .. } => return None,
|
||||
// Codec pin is consumed by the native capture engine's codec selector.
|
||||
HostEvent::PinCodec { .. } => return None,
|
||||
// Codec capability is consumed by the native engine's codec selector.
|
||||
HostEvent::ViewerCaps { .. } => return None,
|
||||
// Monitor switching is handled by the native capture engine (the Android
|
||||
// host is single-display).
|
||||
HostEvent::SwitchMonitor { .. } => return None,
|
||||
// Approve-before-connect is off on Android (cfg.approve=false), so this
|
||||
// never fires; view-only is enforced inside the core engine already.
|
||||
HostEvent::ApproveRequest { .. } => return None,
|
||||
HostEvent::ViewOnly { .. } => return None,
|
||||
// TCP tunneling is a desktop-host feature; the Android host doesn't offer it.
|
||||
HostEvent::TunnelOpen { .. } => return None,
|
||||
HostEvent::TunnelClose { .. } => return None,
|
||||
HostEvent::TunnelData { .. } => return None,
|
||||
// Interactive terminal is a desktop-host feature; not on the Android host.
|
||||
HostEvent::TermOpen { .. } => return None,
|
||||
HostEvent::TermInput { .. } => return None,
|
||||
HostEvent::TermResize { .. } => return None,
|
||||
HostEvent::TermClose { .. } => return None,
|
||||
})
|
||||
}
|
||||
|
||||
type FrameSlot = Arc<Mutex<Option<mpsc::UnboundedSender<HostFrame>>>>;
|
||||
|
||||
/// Per-handle current frame sender (swapped each (re)connect so pushed frames go
|
||||
/// to the live run, or are dropped between runs).
|
||||
fn host_frames() -> &'static Mutex<HashMap<u64, FrameSlot>> {
|
||||
static M: OnceLock<Mutex<HashMap<u64, FrameSlot>>> = OnceLock::new();
|
||||
M.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Per-handle Dart-facing event receivers, parked until host_events() taps them.
|
||||
fn host_pending() -> &'static Mutex<HashMap<u64, mpsc::UnboundedReceiver<NdHostEvent>>> {
|
||||
static M: OnceLock<Mutex<HashMap<u64, mpsc::UnboundedReceiver<NdHostEvent>>>> = OnceLock::new();
|
||||
M.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Per-handle "stop" flags for the host reconnect loop.
|
||||
fn host_cancels() -> &'static Mutex<HashMap<u64, Arc<AtomicBool>>> {
|
||||
static M: OnceLock<Mutex<HashMap<u64, Arc<AtomicBool>>>> = OnceLock::new();
|
||||
M.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// A fresh 9-digit host id to persist on the device (shown to viewers).
|
||||
#[frb(sync)]
|
||||
pub fn host_gen_id() -> String {
|
||||
crypto::gen_host_id()
|
||||
}
|
||||
|
||||
/// A fresh friendly unattended password (no look-alike chars).
|
||||
#[frb(sync)]
|
||||
pub fn host_gen_password() -> String {
|
||||
crypto::gen_password(8)
|
||||
}
|
||||
|
||||
/// Start hosting: register `host_id` with the server and serve one viewer at a
|
||||
/// time, authenticating against `password`. Returns a handle; drive input/
|
||||
/// control via [`host_events`], feed video via [`host_push_frame`], stop with
|
||||
/// [`host_stop`]. Credentials are derived fresh from `password` each run (the
|
||||
/// challenge carries the salt, so the viewer only needs the password).
|
||||
pub fn host_start(
|
||||
ws_url: String,
|
||||
host_id: String,
|
||||
name: String,
|
||||
password: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> u64 {
|
||||
let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed);
|
||||
let (salt_b64, iters, verifier_hex) = crypto::new_host_credentials(&password);
|
||||
let cfg = HostConfig {
|
||||
ws_url,
|
||||
host_id,
|
||||
name,
|
||||
salt_b64,
|
||||
iters,
|
||||
verifier_hex,
|
||||
width,
|
||||
height,
|
||||
audio: false,
|
||||
monitors: Vec::new(),
|
||||
monitor: 0,
|
||||
// Android host: all channels allowed (the app's own permission flow —
|
||||
// MediaProjection consent + accessibility — is the gate), no approve
|
||||
// prompt (the consent dialog already is one).
|
||||
perms: Default::default(),
|
||||
approve: false,
|
||||
totp_secret: String::new(),
|
||||
};
|
||||
|
||||
let frame_slot: FrameSlot = Arc::new(Mutex::new(None));
|
||||
host_frames().lock().unwrap().insert(handle, frame_slot.clone());
|
||||
let (ev_tx, ev_rx) = mpsc::unbounded_channel::<NdHostEvent>();
|
||||
host_pending().lock().unwrap().insert(handle, ev_rx);
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
host_cancels().lock().unwrap().insert(handle, cancel.clone());
|
||||
|
||||
runtime().spawn(async move {
|
||||
// One core-event channel for the whole host lifetime; the mapper forwards
|
||||
// (and translates) to the Dart-facing stream across reconnects.
|
||||
let (core_tx, mut core_rx) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let ev_tx_map = ev_tx.clone();
|
||||
let mapper = runtime().spawn(async move {
|
||||
while let Some(e) = core_rx.recv().await {
|
||||
if let Some(m) = map_host_event(e) {
|
||||
if ev_tx_map.send(m).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut backoff: u64 = 1;
|
||||
loop {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let (ftx, frx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
*frame_slot.lock().unwrap() = Some(ftx);
|
||||
// Android host does not push outbound control yet; drop the sender so
|
||||
// run_host disables that arm.
|
||||
let (_ctl_tx, ctl_rx) = mpsc::unbounded_channel::<netdesk_core::host::HostControl>();
|
||||
let _ = run_host(cfg.clone(), frx, core_tx.clone(), ctl_rx).await;
|
||||
*frame_slot.lock().unwrap() = None; // drop frames while offline
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let _ = ev_tx.send(NdHostEvent::Reconnecting);
|
||||
tokio::time::sleep(Duration::from_secs(backoff)).await;
|
||||
backoff = (backoff * 2).min(5);
|
||||
}
|
||||
|
||||
mapper.abort();
|
||||
host_frames().lock().unwrap().remove(&handle);
|
||||
host_cancels().lock().unwrap().remove(&handle);
|
||||
let _ = ev_tx.send(NdHostEvent::Ended {
|
||||
reason: "stopped".into(),
|
||||
});
|
||||
});
|
||||
|
||||
handle
|
||||
}
|
||||
|
||||
/// Subscribe to the [`NdHostEvent`] stream for a `handle` from [`host_start`].
|
||||
pub fn host_events(handle: u64, sink: StreamSink<NdHostEvent>) {
|
||||
let rx = host_pending().lock().unwrap().remove(&handle);
|
||||
if let Some(mut rx) = rx {
|
||||
runtime().spawn(async move {
|
||||
while let Some(ev) = rx.recv().await {
|
||||
if sink.add(ev).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one encoded H.264 access unit (Annex-B) from the platform encoder. Cheap
|
||||
/// + non-blocking; dropped when no viewer is connected.
|
||||
#[frb(sync)]
|
||||
pub fn host_push_frame(handle: u64, data: Vec<u8>, keyframe: bool) {
|
||||
if let Some(slot) = host_frames().lock().unwrap().get(&handle) {
|
||||
if let Some(tx) = slot.lock().unwrap().as_ref() {
|
||||
// Android host encodes H.264 (MediaCodec) -> codec id 0.
|
||||
let _ = tx.send(HostFrame { data, keyframe, codec: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop hosting: end the reconnect loop and drop the handle.
|
||||
#[frb(sync)]
|
||||
pub fn host_stop(handle: u64) {
|
||||
if let Some(c) = host_cancels().lock().unwrap().get(&handle) {
|
||||
c.store(true, Ordering::Relaxed);
|
||||
}
|
||||
// dropping the frame sender unblocks run_host so the loop can see `cancel`
|
||||
if let Some(slot) = host_frames().lock().unwrap().get(&handle) {
|
||||
*slot.lock().unwrap() = None;
|
||||
}
|
||||
host_pending().lock().unwrap().remove(&handle);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,6 @@
|
||||
//! NetDesk flutter_rust_bridge FFI layer. The Dart-facing API lives in [`api`];
|
||||
//! `frb_generated` is produced by `flutter_rust_bridge_codegen generate` and
|
||||
//! wires the C ABI the Flutter engine calls. Developed by Akronlogic.
|
||||
|
||||
pub mod api;
|
||||
mod frb_generated;
|
||||
@@ -0,0 +1,81 @@
|
||||
# netdesk-host — the NATIVE RUST host (screen capture + H.264 encode + input
|
||||
# injection) that feeds netdesk_core::host::run_host. This replaces the Python
|
||||
# host engine so the Windows app is one unified Rust binary (host + viewer).
|
||||
# Developed by Akronlogic.
|
||||
[package]
|
||||
name = "netdesk-host"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
netdesk-core = { path = "../core" }
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = "0.4"
|
||||
# OS clipboard for viewer->host clipboard sync (RustDesk-style).
|
||||
arboard = "3"
|
||||
# Encode extracted custom-cursor bitmaps to PNG for cursor.shape (the viewers
|
||||
# render data:image/png). Base64 of the bytes is done in core on send.
|
||||
png = "0.17"
|
||||
env_logger = "0.11"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { workspace = true }
|
||||
openh264 = { workspace = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# VP9 software encode (libvpx) for constrained links — the constrained-link
|
||||
# codec alongside the hardware H.264 path.
|
||||
netdesk-vpx = { path = "../vpx" }
|
||||
# Opus encode for system-audio capture (WASAPI loopback -> Opus -> CHAN_AUDIO).
|
||||
netdesk-opus = { path = "../opus" }
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Media_Audio",
|
||||
"Win32_Media_KernelStreaming",
|
||||
"Win32_System_Com_StructuredStorage",
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Console",
|
||||
"Win32_System_Pipes",
|
||||
"Win32_System_IO",
|
||||
"Win32_Security",
|
||||
"Win32_System_Power",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_SystemInformation",
|
||||
"Win32_System_RemoteDesktop",
|
||||
"Win32_System_Services",
|
||||
"Win32_System_StationsAndDesktops",
|
||||
"Win32_Security_Authentication_Identity",
|
||||
"Win32_System_ProcessStatus",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_Media_MediaFoundation",
|
||||
"Win32_Graphics_Direct3D",
|
||||
"Win32_Graphics_Direct3D11",
|
||||
"Win32_Graphics_Dxgi",
|
||||
"Win32_Graphics_Dxgi_Common",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_UI_HiDpi",
|
||||
] }
|
||||
|
||||
[[bin]]
|
||||
name = "netdesk-host-probe"
|
||||
path = "src/bin/probe.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "netdesk-host-encbench"
|
||||
path = "src/bin/encbench.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "netdesk-host-engine"
|
||||
path = "src/bin/engine.rs"
|
||||
|
||||
# Privacy-mode feasibility probe: does WDA_EXCLUDEFROMCAPTURE hide a window from
|
||||
# this host's own DXGI + GDI capture? (determines the privacy-mode mechanism)
|
||||
[[bin]]
|
||||
name = "netdesk-privacy-probe"
|
||||
path = "src/bin/privacy_probe.rs"
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Collaborative annotation overlay. A fullscreen, topmost, click-through,
|
||||
//! color-keyed LAYERED window on which the remote viewer's ink is drawn, so the
|
||||
//! person AT the host sees the arrows/circles the operator draws (RustDesk-style
|
||||
//! "annotate"). Only the strokes are opaque; the rest of the window is a magenta
|
||||
//! color-key that is fully transparent, so the real desktop shows through and
|
||||
//! the host user's mouse/keyboard pass straight to it (WS_EX_TRANSPARENT).
|
||||
//!
|
||||
//! Marked `WDA_EXCLUDEFROMCAPTURE` so the overlay does NOT appear in the host's
|
||||
//! own capture — the drawing viewer already renders its ink locally, so this
|
||||
//! prevents a double image on that viewer. (Relaying strokes to OTHER viewers so
|
||||
//! they also see the ink is a follow-on.) Modeled on `privacy.rs`; same
|
||||
//! own-thread + message-pump lifecycle, dropped to clear + remove the overlay.
|
||||
|
||||
#![cfg(windows)]
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
use windows::core::w;
|
||||
use windows::Win32::Foundation::{COLORREF, HINSTANCE, HWND, LPARAM, LRESULT, WPARAM};
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
BeginPaint, CreatePen, CreateSolidBrush, DeleteObject, EndPaint, FillRect, InvalidateRect,
|
||||
LineTo, MoveToEx, SelectObject, HGDIOBJ, PAINTSTRUCT, PS_SOLID,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetSystemMetrics,
|
||||
PeekMessageW, RegisterClassW, SetLayeredWindowAttributes, SetWindowDisplayAffinity, ShowWindow,
|
||||
TranslateMessage, LWA_COLORKEY, MSG, PM_REMOVE, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN,
|
||||
SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, SW_SHOWNA, WDA_EXCLUDEFROMCAPTURE, WM_PAINT, WNDCLASSW,
|
||||
WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_EX_TRANSPARENT, WS_POPUP,
|
||||
WS_VISIBLE,
|
||||
};
|
||||
|
||||
/// One pen stroke in VIRTUAL-DESKTOP pixel coordinates.
|
||||
#[derive(Clone)]
|
||||
pub struct Stroke {
|
||||
pub color: u32, // 0xRRGGBB
|
||||
pub width: i32,
|
||||
pub points: Vec<(i32, i32)>,
|
||||
}
|
||||
|
||||
// The magenta transparent color-key (COLORREF is 0x00BBGGRR: B=FF, G=00, R=FF).
|
||||
const KEY_BGR: u32 = 0x00FF_00FF;
|
||||
|
||||
static STROKES: OnceLock<Mutex<Vec<Stroke>>> = OnceLock::new();
|
||||
static ORIGIN: OnceLock<(i32, i32)> = OnceLock::new(); // virtual-desktop top-left
|
||||
|
||||
fn strokes() -> &'static Mutex<Vec<Stroke>> {
|
||||
STROKES.get_or_init(|| Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn to_bgr(rgb: u32) -> u32 {
|
||||
let r = (rgb >> 16) & 0xFF;
|
||||
let g = (rgb >> 8) & 0xFF;
|
||||
let b = rgb & 0xFF;
|
||||
(b << 16) | (g << 8) | r
|
||||
}
|
||||
|
||||
unsafe extern "system" fn wndproc(h: HWND, m: u32, w: WPARAM, l: LPARAM) -> LRESULT {
|
||||
if m == WM_PAINT {
|
||||
let mut ps = PAINTSTRUCT::default();
|
||||
let hdc = BeginPaint(h, &mut ps);
|
||||
// Paint the whole client area with the transparent color-key first.
|
||||
let keybrush = CreateSolidBrush(COLORREF(KEY_BGR));
|
||||
FillRect(hdc, &ps.rcPaint, keybrush);
|
||||
let _ = DeleteObject(HGDIOBJ(keybrush.0));
|
||||
let (ox, oy) = *ORIGIN.get().unwrap_or(&(0, 0));
|
||||
if let Ok(list) = strokes().lock() {
|
||||
for st in list.iter() {
|
||||
if st.points.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let pen = CreatePen(PS_SOLID, st.width.max(1), COLORREF(to_bgr(st.color)));
|
||||
let old = SelectObject(hdc, HGDIOBJ(pen.0));
|
||||
let p0 = st.points[0];
|
||||
let _ = MoveToEx(hdc, p0.0 - ox, p0.1 - oy, None);
|
||||
for p in &st.points[1..] {
|
||||
let _ = LineTo(hdc, p.0 - ox, p.1 - oy);
|
||||
}
|
||||
if st.points.len() == 1 {
|
||||
// a single tap: a 1px nub so it is visible
|
||||
let _ = LineTo(hdc, p0.0 - ox + 1, p0.1 - oy + 1);
|
||||
}
|
||||
SelectObject(hdc, old);
|
||||
let _ = DeleteObject(HGDIOBJ(pen.0));
|
||||
}
|
||||
}
|
||||
let _ = EndPaint(h, &ps);
|
||||
return LRESULT(0);
|
||||
}
|
||||
DefWindowProcW(h, m, w, l)
|
||||
}
|
||||
|
||||
/// A live annotation overlay. Drop it to clear + remove the overlay.
|
||||
pub struct Annotations {
|
||||
stop: Arc<AtomicBool>,
|
||||
dirty: Arc<AtomicBool>,
|
||||
hwnd: Arc<AtomicIsize>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Annotations {
|
||||
/// Raise the (empty, invisible) overlay; strokes appear as they are added.
|
||||
pub fn start() -> Annotations {
|
||||
if let Ok(mut v) = strokes().lock() {
|
||||
v.clear();
|
||||
}
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let dirty = Arc::new(AtomicBool::new(true));
|
||||
let hwnd = Arc::new(AtomicIsize::new(0));
|
||||
let (s2, d2, h2) = (stop.clone(), dirty.clone(), hwnd.clone());
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("netdesk-annot".into())
|
||||
.spawn(move || unsafe { run(s2, d2, h2) })
|
||||
.ok();
|
||||
Annotations { stop, dirty, hwnd, thread }
|
||||
}
|
||||
|
||||
/// Append a stroke (virtual-desktop coords) and request a repaint.
|
||||
pub fn add_stroke(&self, s: Stroke) {
|
||||
if let Ok(mut v) = strokes().lock() {
|
||||
v.push(s);
|
||||
}
|
||||
self.dirty.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Erase all strokes.
|
||||
pub fn clear(&self) {
|
||||
if let Ok(mut v) = strokes().lock() {
|
||||
v.clear();
|
||||
}
|
||||
self.dirty.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The overlay window handle (0 until created) — for tests.
|
||||
pub fn hwnd(&self) -> isize {
|
||||
self.hwnd.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Annotations {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
if let Ok(mut v) = strokes().lock() {
|
||||
v.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn run(stop: Arc<AtomicBool>, dirty: Arc<AtomicBool>, hwnd_out: Arc<AtomicIsize>) {
|
||||
let hinst = HINSTANCE::default();
|
||||
let keybrush = CreateSolidBrush(COLORREF(KEY_BGR));
|
||||
let class = w!("NdAnnotOverlay");
|
||||
let wc = WNDCLASSW {
|
||||
lpfnWndProc: Some(wndproc),
|
||||
hInstance: hinst,
|
||||
lpszClassName: class,
|
||||
hbrBackground: keybrush,
|
||||
..Default::default()
|
||||
};
|
||||
let _ = RegisterClassW(&wc);
|
||||
|
||||
let x = GetSystemMetrics(SM_XVIRTUALSCREEN);
|
||||
let y = GetSystemMetrics(SM_YVIRTUALSCREEN);
|
||||
let cx = GetSystemMetrics(SM_CXVIRTUALSCREEN).max(1);
|
||||
let cy = GetSystemMetrics(SM_CYVIRTUALSCREEN).max(1);
|
||||
let _ = ORIGIN.set((x, y));
|
||||
|
||||
let hwnd = match CreateWindowExW(
|
||||
WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_NOACTIVATE,
|
||||
class,
|
||||
w!("nd-annot"),
|
||||
WS_POPUP | WS_VISIBLE,
|
||||
x,
|
||||
y,
|
||||
cx,
|
||||
cy,
|
||||
None,
|
||||
None,
|
||||
hinst,
|
||||
None,
|
||||
) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
log::warn!("annot: window create failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Magenta is the transparent color-key: only the strokes are opaque/visible.
|
||||
let _ = SetLayeredWindowAttributes(hwnd, COLORREF(KEY_BGR), 0, LWA_COLORKEY);
|
||||
// Keep the overlay out of OUR OWN capture (the drawing viewer renders ink
|
||||
// locally, so this prevents a double image on it).
|
||||
if let Err(e) = SetWindowDisplayAffinity(hwnd, WDA_EXCLUDEFROMCAPTURE) {
|
||||
log::warn!("annot: SetWindowDisplayAffinity failed: {e}");
|
||||
}
|
||||
let _ = ShowWindow(hwnd, SW_SHOWNA);
|
||||
hwnd_out.store(hwnd.0 as isize, Ordering::Relaxed);
|
||||
log::info!("annot: overlay up ({cx}x{cy} @ {x},{y}); click-through, excluded from capture");
|
||||
|
||||
let mut msg = MSG::default();
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
if dirty.swap(false, Ordering::Relaxed) {
|
||||
let _ = InvalidateRect(hwnd, None, false);
|
||||
}
|
||||
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
|
||||
let _ = TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(16));
|
||||
}
|
||||
let _ = DestroyWindow(hwnd);
|
||||
hwnd_out.store(0, Ordering::Relaxed);
|
||||
log::info!("annot: overlay down");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Instant;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{GetWindowLongPtrW, GWL_EXSTYLE};
|
||||
|
||||
/// The overlay must be a LAYERED + click-through (TRANSPARENT) + TOPMOST
|
||||
/// window and must accept strokes + clear without panicking. Proves the
|
||||
/// collaborative-overlay mechanism (pixel rendering is verified live after
|
||||
/// the capstone redeploy).
|
||||
#[test]
|
||||
fn overlay_is_layered_clickthrough_topmost_and_takes_strokes() {
|
||||
let a = Annotations::start();
|
||||
let end = Instant::now() + Duration::from_millis(1500);
|
||||
while a.hwnd() == 0 && Instant::now() < end {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let h = a.hwnd();
|
||||
assert!(h != 0, "overlay window did not create");
|
||||
let ex = unsafe { GetWindowLongPtrW(HWND(h as *mut core::ffi::c_void), GWL_EXSTYLE) } as u32;
|
||||
assert!(ex & WS_EX_LAYERED.0 != 0, "overlay must be layered");
|
||||
assert!(ex & WS_EX_TRANSPARENT.0 != 0, "overlay must be click-through");
|
||||
assert!(ex & WS_EX_TOPMOST.0 != 0, "overlay must be topmost");
|
||||
a.add_stroke(Stroke {
|
||||
color: 0xFF0000,
|
||||
width: 4,
|
||||
points: vec![(100, 100), (400, 300)],
|
||||
});
|
||||
a.add_stroke(Stroke {
|
||||
color: 0x00AAFF,
|
||||
width: 2,
|
||||
points: vec![(50, 50)],
|
||||
});
|
||||
std::thread::sleep(Duration::from_millis(120));
|
||||
a.clear();
|
||||
std::thread::sleep(Duration::from_millis(60));
|
||||
println!("ANNOT_OVERLAY_OK hwnd={h:#x} exstyle={ex:#x}");
|
||||
drop(a);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
//! System-audio capture for the native host: WASAPI loopback (capture "what you
|
||||
//! hear" from the default render endpoint) -> downmix to 48 kHz stereo i16 ->
|
||||
//! Opus (20 ms frames) -> `on_packet`. The host engine seals each packet and
|
||||
//! sends it as a `CHAN_AUDIO` frame.
|
||||
//!
|
||||
//! Off by default (NETDESK_AUDIO=1 to enable), so it can never affect a normal
|
||||
//! session. Entirely best-effort: any WASAPI error logs once and ends the
|
||||
//! thread — audio just will not stream, video/input are unaffected.
|
||||
//!
|
||||
//! Stock cpal/soundio cannot do WASAPI loopback (RustDesk pins a cpal fork for
|
||||
//! it), so we drive WASAPI directly through the `windows` crate — no extra
|
||||
//! dependency and full control over the format path.
|
||||
//!
|
||||
//! Developed by Akronlogic.
|
||||
|
||||
#![cfg(windows)]
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use netdesk_opus::{Decoder, Encoder, FRAME_SIZE, SAMPLE_RATE};
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use windows::core::Interface;
|
||||
use windows::Win32::Media::Audio::{
|
||||
eCapture, eConsole, eRender, IAudioCaptureClient, IAudioClient, IAudioRenderClient,
|
||||
IMMDeviceEnumerator, MMDeviceEnumerator, AUDCLNT_BUFFERFLAGS_SILENT, AUDCLNT_SHAREMODE_SHARED,
|
||||
AUDCLNT_STREAMFLAGS_LOOPBACK, WAVEFORMATEX, WAVEFORMATEXTENSIBLE,
|
||||
};
|
||||
use windows::Win32::System::Com::{
|
||||
CoCreateInstance, CoInitializeEx, CoTaskMemFree, CLSCTX_ALL, COINIT_MULTITHREADED,
|
||||
};
|
||||
|
||||
const WAVE_FORMAT_PCM: u16 = 1;
|
||||
const WAVE_FORMAT_IEEE_FLOAT: u16 = 3;
|
||||
const WAVE_FORMAT_EXTENSIBLE: u16 = 0xFFFE;
|
||||
/// 1 second loopback buffer (100-ns units) — generous so draining can lag while
|
||||
/// idle without overrunning.
|
||||
const BUFFER_HNS: i64 = 10_000_000;
|
||||
|
||||
/// Capture loopback audio until `stop`, encoding Opus only while `active`
|
||||
/// (a viewer is connected). Each Opus packet is handed to `on_packet`.
|
||||
///
|
||||
/// Returns (ending the thread) on any fatal WASAPI/Opus error.
|
||||
pub fn run_loopback_opus(
|
||||
stop: Arc<AtomicBool>,
|
||||
active: impl Fn() -> bool,
|
||||
mut on_packet: impl FnMut(Vec<u8>),
|
||||
) {
|
||||
// Back-compat shim: always-on system loopback.
|
||||
let src = Arc::new(AtomicU8::new(1));
|
||||
run_audio(stop, active, src, move |p| on_packet(p))
|
||||
}
|
||||
|
||||
/// On-demand host audio. `source` selects the live source and may change at
|
||||
/// runtime (the viewer picks it): 0 = off, 1 = SYSTEM (WASAPI render loopback,
|
||||
/// "what you hear"), 2 = MICROPHONE (default input). Opus packets go to
|
||||
/// `on_packet` only while `active` (a viewer is streaming). Changing the source
|
||||
/// re-opens the correct device.
|
||||
pub fn run_audio(
|
||||
stop: Arc<AtomicBool>,
|
||||
active: impl Fn() -> bool,
|
||||
source: Arc<AtomicU8>,
|
||||
mut on_packet: impl FnMut(Vec<u8>),
|
||||
) {
|
||||
unsafe {
|
||||
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
|
||||
}
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let src = source.load(Ordering::Relaxed);
|
||||
if src == 0 {
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = capture(&stop, &active, &source, src, &mut on_packet) {
|
||||
// DEVICE_INVALIDATED (0x88890004), a device format change, or a
|
||||
// default-endpoint switch all land here. Re-open the (possibly new)
|
||||
// default endpoint after a short backoff — non-fatal, video/input
|
||||
// are unaffected and the source selection is preserved.
|
||||
log::warn!("audio: capture ended (src={src}): {e} — re-opening endpoint");
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn format_params(mix: *const WAVEFORMATEX) -> (u32, u16, u16, bool) {
|
||||
// (sample_rate, channels, bits_per_sample, is_float)
|
||||
let base = &*mix;
|
||||
let mut is_float = base.wFormatTag == WAVE_FORMAT_IEEE_FLOAT;
|
||||
if base.wFormatTag == WAVE_FORMAT_EXTENSIBLE {
|
||||
// SubFormat GUID Data1: 3 = IEEE float, 1 = PCM.
|
||||
let ext = &*(mix as *const WAVEFORMATEXTENSIBLE);
|
||||
is_float = ext.SubFormat.data1 == WAVE_FORMAT_IEEE_FLOAT as u32;
|
||||
}
|
||||
(
|
||||
base.nSamplesPerSec,
|
||||
base.nChannels,
|
||||
base.wBitsPerSample,
|
||||
is_float,
|
||||
)
|
||||
}
|
||||
|
||||
fn capture(
|
||||
stop: &AtomicBool,
|
||||
active: &impl Fn() -> bool,
|
||||
source: &AtomicU8,
|
||||
src: u8,
|
||||
on_packet: &mut impl FnMut(Vec<u8>),
|
||||
) -> anyhow::Result<()> {
|
||||
unsafe {
|
||||
// COM: ignore the HRESULT — a non-S_OK (already initialized on this
|
||||
// thread with a different mode) is fine for our dedicated thread.
|
||||
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
|
||||
|
||||
let enumerator: IMMDeviceEnumerator =
|
||||
CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)?;
|
||||
// src 2 = microphone: default CAPTURE device, no loopback flag.
|
||||
// else (system): default RENDER device captured in loopback mode.
|
||||
let (flow, stream_flags) = if src == 2 {
|
||||
(eCapture, 0u32)
|
||||
} else {
|
||||
(eRender, AUDCLNT_STREAMFLAGS_LOOPBACK)
|
||||
};
|
||||
let device = enumerator.GetDefaultAudioEndpoint(flow, eConsole)?;
|
||||
let client: IAudioClient = device.Activate(CLSCTX_ALL, None)?;
|
||||
|
||||
let mix = client.GetMixFormat()?;
|
||||
let (rate, channels, bits, is_float) = format_params(mix);
|
||||
let channels = channels as usize;
|
||||
// Resample to 48 kHz only when the device mix rate differs. On Windows the
|
||||
// shared-mode mix is usually already 48000, so the common path is a no-op.
|
||||
// Previously a 44100 Hz device (many USB headsets / Bluetooth / capture
|
||||
// cards) DISABLED audio entirely via a hard bail; now we capture at the
|
||||
// device's native rate and resample the stereo stream to the 48 kHz the
|
||||
// Opus encoder and the web AudioDecoder both require.
|
||||
let need_resample = rate != SAMPLE_RATE;
|
||||
let mut resampler = LinearResampler::new(rate.max(1), SAMPLE_RATE);
|
||||
if need_resample {
|
||||
log::info!("audio: device mix {rate} Hz, {channels}ch -> resampling to 48000 stereo (src={src})");
|
||||
}
|
||||
|
||||
client.Initialize(
|
||||
AUDCLNT_SHAREMODE_SHARED,
|
||||
stream_flags,
|
||||
BUFFER_HNS,
|
||||
0,
|
||||
mix,
|
||||
None,
|
||||
)?;
|
||||
CoTaskMemFree(Some(mix as *const _));
|
||||
|
||||
let capture: IAudioCaptureClient = client.GetService()?;
|
||||
client.Start()?;
|
||||
|
||||
let mut enc = Encoder::new(SAMPLE_RATE, 2)?;
|
||||
let mut accum: Vec<i16> = Vec::with_capacity(FRAME_SIZE * 2 * 4);
|
||||
let frame_len = FRAME_SIZE * 2; // interleaved stereo samples per 20ms
|
||||
|
||||
// Exit (so run_audio re-opens) when the viewer changes the source.
|
||||
while !stop.load(Ordering::Relaxed) && source.load(Ordering::Relaxed) == src {
|
||||
let mut got_any = false;
|
||||
loop {
|
||||
let packet = capture.GetNextPacketSize()?;
|
||||
if packet == 0 {
|
||||
break;
|
||||
}
|
||||
got_any = true;
|
||||
let mut pdata: *mut u8 = std::ptr::null_mut();
|
||||
let mut nframes: u32 = 0;
|
||||
let mut flags: u32 = 0;
|
||||
capture.GetBuffer(&mut pdata, &mut nframes, &mut flags, None, None)?;
|
||||
let nframes = nframes as usize;
|
||||
|
||||
if active() && nframes > 0 {
|
||||
let silent = (flags & AUDCLNT_BUFFERFLAGS_SILENT.0 as u32) != 0;
|
||||
if silent || pdata.is_null() {
|
||||
if need_resample {
|
||||
for _ in 0..nframes {
|
||||
resampler.push(0.0, 0.0, &mut accum);
|
||||
}
|
||||
} else {
|
||||
for _ in 0..nframes {
|
||||
accum.push(0);
|
||||
accum.push(0);
|
||||
}
|
||||
}
|
||||
} else if need_resample {
|
||||
for_each_stereo(pdata, nframes, channels, bits, is_float, |l, r| {
|
||||
resampler.push(l, r, &mut accum);
|
||||
});
|
||||
} else {
|
||||
for_each_stereo(pdata, nframes, channels, bits, is_float, |l, r| {
|
||||
accum.push(to_i16(l));
|
||||
accum.push(to_i16(r));
|
||||
});
|
||||
}
|
||||
}
|
||||
capture.ReleaseBuffer(nframes as u32)?;
|
||||
}
|
||||
|
||||
// Emit whole 20 ms Opus frames.
|
||||
while accum.len() >= frame_len {
|
||||
match enc.encode(&accum[..frame_len], FRAME_SIZE) {
|
||||
Ok(pkt) => on_packet(pkt),
|
||||
Err(e) => log::warn!("audio: opus encode: {e}"),
|
||||
}
|
||||
accum.drain(..frame_len);
|
||||
}
|
||||
|
||||
if !got_any {
|
||||
// Silence / idle: nothing to drain, back off briefly.
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
let _ = client.Stop();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode one WASAPI buffer (`nframes` frames of `channels` samples) to stereo
|
||||
/// f32 pairs, calling `emit(l, r)` once per frame. Takes the first two channels
|
||||
/// (duplicating channel 0 for mono); extra channels are ignored. Yielding f32
|
||||
/// pairs lets the caller either quantise straight to i16 (native 48 kHz path) or
|
||||
/// feed them through the resampler (non-48 kHz devices) without a second pass.
|
||||
unsafe fn for_each_stereo(
|
||||
pdata: *mut u8,
|
||||
nframes: usize,
|
||||
channels: usize,
|
||||
bits: u16,
|
||||
is_float: bool,
|
||||
mut emit: impl FnMut(f32, f32),
|
||||
) {
|
||||
if is_float && bits == 32 {
|
||||
let src = std::slice::from_raw_parts(pdata as *const f32, nframes * channels);
|
||||
for f in 0..nframes {
|
||||
let base = f * channels;
|
||||
let l = src[base];
|
||||
let r = if channels >= 2 { src[base + 1] } else { l };
|
||||
emit(l, r);
|
||||
}
|
||||
} else if !is_float && bits == 16 {
|
||||
let src = std::slice::from_raw_parts(pdata as *const i16, nframes * channels);
|
||||
for f in 0..nframes {
|
||||
let base = f * channels;
|
||||
let l = src[base] as f32 / 32768.0;
|
||||
let r = if channels >= 2 { src[base + 1] as f32 / 32768.0 } else { l };
|
||||
emit(l, r);
|
||||
}
|
||||
} else if !is_float && bits == 32 {
|
||||
// 32-bit integer PCM (some pro/USB interfaces present this).
|
||||
let src = std::slice::from_raw_parts(pdata as *const i32, nframes * channels);
|
||||
for f in 0..nframes {
|
||||
let base = f * channels;
|
||||
let l = src[base] as f32 / 2_147_483_648.0;
|
||||
let r = if channels >= 2 { src[base + 1] as f32 / 2_147_483_648.0 } else { l };
|
||||
emit(l, r);
|
||||
}
|
||||
} else if is_float && bits == 64 {
|
||||
let src = std::slice::from_raw_parts(pdata as *const f64, nframes * channels);
|
||||
for f in 0..nframes {
|
||||
let base = f * channels;
|
||||
let l = src[base] as f32;
|
||||
let r = if channels >= 2 { src[base + 1] as f32 } else { l };
|
||||
emit(l, r);
|
||||
}
|
||||
} else {
|
||||
// Unknown bit depth: emit silence to keep timing rather than garbage.
|
||||
for _ in 0..nframes {
|
||||
emit(0.0, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal stateful linear resampler: stereo f32 in at `src_rate`, stereo i16 out
|
||||
/// at `dst_rate`, appended to `out`. The phase and previous sample persist across
|
||||
/// calls so consecutive WASAPI buffers resample seamlessly (no per-buffer clicks).
|
||||
/// No anti-alias filter — but transparent for speech/UI/media at the 44100->48000
|
||||
/// upsample that is by far the common non-48 kHz case.
|
||||
struct LinearResampler {
|
||||
step: f64, // source samples to advance per output sample (src/dst)
|
||||
t: f64, // phase in [0,1) between prev and cur
|
||||
prev_l: f32,
|
||||
prev_r: f32,
|
||||
primed: bool,
|
||||
}
|
||||
|
||||
impl LinearResampler {
|
||||
fn new(src_rate: u32, dst_rate: u32) -> Self {
|
||||
Self {
|
||||
step: src_rate as f64 / dst_rate as f64,
|
||||
t: 0.0,
|
||||
prev_l: 0.0,
|
||||
prev_r: 0.0,
|
||||
primed: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn push(&mut self, cur_l: f32, cur_r: f32, out: &mut Vec<i16>) {
|
||||
if !self.primed {
|
||||
self.prev_l = cur_l;
|
||||
self.prev_r = cur_r;
|
||||
self.primed = true;
|
||||
}
|
||||
// Emit every output sample whose source position falls in [prev, cur).
|
||||
while self.t < 1.0 {
|
||||
let ft = self.t as f32;
|
||||
out.push(to_i16(self.prev_l + (cur_l - self.prev_l) * ft));
|
||||
out.push(to_i16(self.prev_r + (cur_r - self.prev_r) * ft));
|
||||
self.t += self.step;
|
||||
}
|
||||
self.t -= 1.0;
|
||||
self.prev_l = cur_l;
|
||||
self.prev_r = cur_r;
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn to_i16(f: f32) -> i16 {
|
||||
(f.clamp(-1.0, 1.0) * 32767.0) as i16
|
||||
}
|
||||
|
||||
/// 200 ms shared-mode render buffer for voice playback.
|
||||
const VOICE_BUFFER_HNS: i64 = 2_000_000;
|
||||
|
||||
/// Play the viewer's microphone (two-way voice call) on the host's default render
|
||||
/// endpoint. Reads Opus packets from `rx`, decodes to 48 kHz stereo, converts to
|
||||
/// the device mix format (resampling when the device isn't 48 kHz), and writes to
|
||||
/// the shared-mode render buffer. Best-effort: any WASAPI/Opus error ends the
|
||||
/// current attempt and re-opens; capture + video are unaffected.
|
||||
pub fn run_voice_playback(stop: Arc<AtomicBool>, rx: std::sync::mpsc::Receiver<Vec<u8>>) {
|
||||
unsafe {
|
||||
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
|
||||
}
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
match unsafe { voice_playback(&stop, &rx) } {
|
||||
Ok(()) => break, // sender dropped / stop requested
|
||||
Err(e) => {
|
||||
log::warn!("voice: playback ended: {e} — re-opening");
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn voice_playback(
|
||||
stop: &AtomicBool,
|
||||
rx: &std::sync::mpsc::Receiver<Vec<u8>>,
|
||||
) -> anyhow::Result<()> {
|
||||
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
|
||||
let enumerator: IMMDeviceEnumerator =
|
||||
CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)?;
|
||||
let device = enumerator.GetDefaultAudioEndpoint(eRender, eConsole)?;
|
||||
let client: IAudioClient = device.Activate(CLSCTX_ALL, None)?;
|
||||
let mix = client.GetMixFormat()?;
|
||||
let (rate, channels, bits, is_float) = format_params(mix);
|
||||
let channels = channels as usize;
|
||||
client.Initialize(AUDCLNT_SHAREMODE_SHARED, 0, VOICE_BUFFER_HNS, 0, mix, None)?;
|
||||
CoTaskMemFree(Some(mix as *const _));
|
||||
let buf_frames = client.GetBufferSize()?;
|
||||
let render: IAudioRenderClient = client.GetService()?;
|
||||
let mut dec = Decoder::new(SAMPLE_RATE, 2)?;
|
||||
client.Start()?;
|
||||
|
||||
let need_resample = rate != SAMPLE_RATE;
|
||||
let mut rs = LinearResampler::new(SAMPLE_RATE, rate.max(1));
|
||||
// Decoded, device-rate, interleaved-stereo i16 awaiting the render buffer.
|
||||
let mut pcm: VecDeque<i16> = VecDeque::with_capacity(buf_frames as usize * 2);
|
||||
let mut scratch: Vec<i16> = Vec::new();
|
||||
log::info!("voice: render ready ({rate} Hz, {channels}ch) — awaiting viewer mic");
|
||||
let mut rx_pkts: u64 = 0;
|
||||
let mut rx_peak: i32 = 0;
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
// Drain queued Opus packets -> PCM (bounded so a burst can't balloon RAM).
|
||||
loop {
|
||||
if pcm.len() > (rate as usize) * 2 {
|
||||
break; // ~1 s already buffered; let it drain first
|
||||
}
|
||||
match rx.try_recv() {
|
||||
Ok(opus) => {
|
||||
if let Ok(frame) = dec.decode(&opus) {
|
||||
rx_pkts += 1;
|
||||
for &s in frame.iter().step_by(64) {
|
||||
let a = (s as i32).abs();
|
||||
if a > rx_peak { rx_peak = a; }
|
||||
}
|
||||
if rx_pkts % 250 == 0 {
|
||||
log::info!("voice: rx {rx_pkts} pkts from viewer mic, recent peak {rx_peak}");
|
||||
rx_peak = 0;
|
||||
}
|
||||
if need_resample {
|
||||
scratch.clear();
|
||||
let n = frame.len() / 2;
|
||||
for i in 0..n {
|
||||
let l = frame[i * 2] as f32 / 32768.0;
|
||||
let r = frame[i * 2 + 1] as f32 / 32768.0;
|
||||
rs.push(l, r, &mut scratch);
|
||||
}
|
||||
pcm.extend(scratch.iter().copied());
|
||||
} else {
|
||||
pcm.extend(frame.iter().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => break,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => return Ok(()),
|
||||
}
|
||||
}
|
||||
// Write as much PCM as the render buffer currently has room for.
|
||||
let padding = client.GetCurrentPadding()?;
|
||||
let avail = buf_frames.saturating_sub(padding);
|
||||
let have = (pcm.len() / 2) as u32;
|
||||
let to_write = avail.min(have);
|
||||
if to_write > 0 {
|
||||
let data = render.GetBuffer(to_write)?;
|
||||
write_render(data, to_write as usize, channels, bits, is_float, &mut pcm);
|
||||
render.ReleaseBuffer(to_write, 0)?;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(8));
|
||||
}
|
||||
let _ = client.Stop();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `nframes` of interleaved stereo i16 (popped from `pcm`) to a WASAPI
|
||||
/// render buffer in the device's mix format. Channel 0=L, 1=R, extra = silence;
|
||||
/// a mono device gets the L/R average.
|
||||
unsafe fn write_render(
|
||||
data: *mut u8,
|
||||
nframes: usize,
|
||||
channels: usize,
|
||||
bits: u16,
|
||||
is_float: bool,
|
||||
pcm: &mut VecDeque<i16>,
|
||||
) {
|
||||
if is_float && bits == 32 {
|
||||
let dst = std::slice::from_raw_parts_mut(data as *mut f32, nframes * channels);
|
||||
for f in 0..nframes {
|
||||
let l = pcm.pop_front().unwrap_or(0) as f32 / 32768.0;
|
||||
let r = pcm.pop_front().unwrap_or(0) as f32 / 32768.0;
|
||||
let base = f * channels;
|
||||
if channels == 1 {
|
||||
dst[base] = (l + r) * 0.5;
|
||||
} else {
|
||||
dst[base] = l;
|
||||
dst[base + 1] = r;
|
||||
for c in 2..channels {
|
||||
dst[base + c] = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !is_float && bits == 16 {
|
||||
let dst = std::slice::from_raw_parts_mut(data as *mut i16, nframes * channels);
|
||||
for f in 0..nframes {
|
||||
let l = pcm.pop_front().unwrap_or(0);
|
||||
let r = pcm.pop_front().unwrap_or(0);
|
||||
let base = f * channels;
|
||||
if channels == 1 {
|
||||
dst[base] = ((l as i32 + r as i32) / 2) as i16;
|
||||
} else {
|
||||
dst[base] = l;
|
||||
dst[base + 1] = r;
|
||||
for c in 2..channels {
|
||||
dst[base + c] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unsupported mix format: consume + emit silence to keep the clock.
|
||||
for _ in 0..nframes {
|
||||
pcm.pop_front();
|
||||
pcm.pop_front();
|
||||
}
|
||||
let bytes = nframes * channels * (bits as usize / 8).max(1);
|
||||
let dst = std::slice::from_raw_parts_mut(data, bytes);
|
||||
for b in dst.iter_mut() {
|
||||
*b = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Exercises the real WASAPI loopback init + drain/stop path on this machine
|
||||
// (a Windows box with audio devices). Tolerant: a box with no render
|
||||
// endpoint makes capture() bail early, which is still a clean return. The
|
||||
// point is that init + stop never panic and the thread exits promptly.
|
||||
// The non-48 kHz path that used to be a hard bail. Feed a 44100 Hz stereo
|
||||
// sine through the resampler and prove it (a) yields ~48000/44100x as many
|
||||
// output frames and (b) is not silent — i.e. real audio comes out, so a
|
||||
// 44.1 kHz device now streams instead of being disabled.
|
||||
#[test]
|
||||
fn resampler_44100_to_48000_is_audible() {
|
||||
let mut rs = LinearResampler::new(44100, 48000);
|
||||
let mut out: Vec<i16> = Vec::new();
|
||||
let n_in = 44100usize; // 1 second of source
|
||||
for i in 0..n_in {
|
||||
let t = i as f32 / 44100.0;
|
||||
let s = (2.0 * std::f32::consts::PI * 440.0 * t).sin() * 0.8;
|
||||
rs.push(s, s, &mut out);
|
||||
}
|
||||
let out_frames = out.len() / 2;
|
||||
// Expect ~48000 output frames (±1%).
|
||||
let expected = 48000i64;
|
||||
let diff = (out_frames as i64 - expected).abs();
|
||||
assert!(
|
||||
diff < 480,
|
||||
"resampled frame count {out_frames} not ~{expected} (diff {diff})"
|
||||
);
|
||||
let peak = out.iter().map(|&s| (s as i32).abs()).max().unwrap_or(0);
|
||||
assert!(peak > 10_000, "resampled audio is silent (peak={peak})");
|
||||
// Mono-fed => L and R must stay identical through the resampler.
|
||||
assert!(
|
||||
out.chunks(2).all(|c| c[0] == c[1]),
|
||||
"resampler desynced L/R channels"
|
||||
);
|
||||
println!("RESAMPLER_44K_OK out_frames={out_frames} peak={peak}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_inits_and_stops_cleanly() {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let got = Arc::new(std::sync::Mutex::new(0usize));
|
||||
let s2 = stop.clone();
|
||||
let g2 = got.clone();
|
||||
let h = std::thread::spawn(move || {
|
||||
run_loopback_opus(
|
||||
s2,
|
||||
|| true,
|
||||
move |_pkt| {
|
||||
*g2.lock().unwrap() += 1;
|
||||
},
|
||||
);
|
||||
});
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
let start = std::time::Instant::now();
|
||||
while !h.is_finished() && start.elapsed() < Duration::from_secs(3) {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
assert!(h.is_finished(), "audio capture thread did not stop within 3s");
|
||||
h.join().unwrap();
|
||||
println!("AUDIO_LOOPBACK_OK packets={}", *got.lock().unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Encode correctness + honest timing on synthetic MOVING content (independent
|
||||
//! of whether the live desktop is active). Encodes N frames, decodes them back
|
||||
//! with the real viewer decoder, and asserts the roundtrip reproduces the right
|
||||
//! dimensions — proving the Annex-B the host emits is valid for the viewer.
|
||||
//!
|
||||
//! Usage: netdesk-host-encbench [sw|mf] (default sw = openh264 software)
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
fn make_frame(w: usize, h: usize, t: usize) -> Vec<u8> {
|
||||
let mut buf = vec![0u8; w * h * 4];
|
||||
let bx = (t * 13) % (w.saturating_sub(120).max(1));
|
||||
let by = (t * 7) % (h.saturating_sub(120).max(1));
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = (y * w + x) * 4;
|
||||
let inbox = x >= bx && x < bx + 120 && y >= by && y < by + 120;
|
||||
if inbox {
|
||||
buf[i] = 255;
|
||||
buf[i + 1] = 255;
|
||||
buf[i + 2] = 255;
|
||||
buf[i + 3] = 255;
|
||||
} else {
|
||||
buf[i] = ((x + t * 3) & 0xff) as u8;
|
||||
buf[i + 1] = ((y + t * 2) & 0xff) as u8;
|
||||
buf[i + 2] = ((x + y + t) & 0xff) as u8;
|
||||
buf[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// Mean (R,G,B) of a decoded row.
|
||||
fn row_means(rgb: &[u8], w: usize, row: usize) -> (f64, f64, f64) {
|
||||
let base = row * w * 3;
|
||||
let (mut r, mut g, mut b) = (0u64, 0u64, 0u64);
|
||||
for col in 0..w {
|
||||
let o = base + col * 3;
|
||||
r += rgb[o] as u64;
|
||||
g += rgb[o + 1] as u64;
|
||||
b += rgb[o + 2] as u64;
|
||||
}
|
||||
let n = w as f64;
|
||||
(r as f64 / n, g as f64 / n, b as f64 / n)
|
||||
}
|
||||
|
||||
/// Solid gray frame (128,128,128) with a moving white box, so a correct decode
|
||||
/// is gray everywhere and any green row stands out immediately.
|
||||
fn gray_frame(w: usize, h: usize, t: usize) -> Vec<u8> {
|
||||
let mut buf = vec![128u8; w * h * 4];
|
||||
for p in buf.chunks_exact_mut(4) {
|
||||
p[3] = 255;
|
||||
}
|
||||
let bx = (t * 11) % (w.saturating_sub(100).max(1));
|
||||
for y in 0..100.min(h) {
|
||||
for x in bx..(bx + 100).min(w) {
|
||||
let i = (y * w + x) * 4;
|
||||
buf[i] = 255;
|
||||
buf[i + 1] = 255;
|
||||
buf[i + 2] = 255;
|
||||
}
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let mode = std::env::args().nth(1).unwrap_or_else(|| "sw".into());
|
||||
let (w, h) = (1920usize, 1080usize);
|
||||
let n = 120usize;
|
||||
println!("encode roundtrip [{}]: {}x{}, {} frames", mode, w, h, n);
|
||||
|
||||
// Green-line hunt: QSV-encode gray frames, decode through the HARDWARE
|
||||
// decoder (best_decoder), and scan every decoded row for anomalous green.
|
||||
#[cfg(windows)]
|
||||
if mode == "mfdec" {
|
||||
let _ = tracing_subscriber::fmt().with_max_level(tracing::Level::INFO).try_init();
|
||||
let w = std::env::var("ND_W").ok().and_then(|s| s.parse().ok()).unwrap_or(w);
|
||||
let h = std::env::var("ND_H").ok().and_then(|s| s.parse().ok()).unwrap_or(h);
|
||||
println!(" mfdec dims: {}x{}", w, h);
|
||||
let mut enc = netdesk_host::encode_mf::MfEncoder::new(w, h, 60, 8_000_000)?;
|
||||
let mut dec = netdesk_core::decode::best_decoder();
|
||||
println!("encoder: {}", enc.name());
|
||||
let mut aus: Vec<Vec<u8>> = Vec::new();
|
||||
for t in 0..30 {
|
||||
for ef in enc.encode(&gray_frame(w, h, t))? {
|
||||
aus.push(ef.data);
|
||||
}
|
||||
}
|
||||
for ef in enc.drain()? {
|
||||
aus.push(ef.data);
|
||||
}
|
||||
let mut frames_checked = 0usize;
|
||||
let mut green_rows_total = 0usize;
|
||||
let mut first_report = String::new();
|
||||
for au in &aus {
|
||||
if let Some(fr) = dec.decode(au)? {
|
||||
frames_checked += 1;
|
||||
let mut green_here = 0usize;
|
||||
let mut first = None;
|
||||
for row in 0..fr.h {
|
||||
let (r, g, b) = row_means(&fr.rgb, fr.w, row);
|
||||
// "green" = G clearly above both R and B (gray/white have R≈G≈B).
|
||||
if g - r > 40.0 && g - b > 40.0 {
|
||||
green_here += 1;
|
||||
if first.is_none() {
|
||||
first = Some((row, r, g, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
green_rows_total += green_here;
|
||||
if green_here > 0 && first_report.is_empty() {
|
||||
let (row, r, g, b) = first.unwrap();
|
||||
first_report = format!(
|
||||
"frame {}x{}: {} green rows; first row {} rgb=({:.0},{:.0},{:.0})",
|
||||
fr.w, fr.h, green_here, row, r, g, b
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("decoded {} frames; total green rows: {}", frames_checked, green_rows_total);
|
||||
if green_rows_total == 0 {
|
||||
println!("NO GREEN LINES via hardware decode");
|
||||
} else {
|
||||
println!("GREEN DETECTED: {}", first_report);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut dec = netdesk_core::decode::H264Decoder::new()?;
|
||||
let mut keyframes = 0usize;
|
||||
let mut decoded_ok = 0usize;
|
||||
let mut total_bytes = 0usize;
|
||||
let mut enc_ns = 0u128;
|
||||
let mut emitted = 0usize;
|
||||
|
||||
// Collect all AUs (with timing) then decode, so the two encoders share one path.
|
||||
let mut aus: Vec<(Vec<u8>, bool)> = Vec::new();
|
||||
|
||||
#[cfg(windows)]
|
||||
if mode == "mf" {
|
||||
let mut enc = match netdesk_host::encode_mf::MfEncoder::new(w, h, 60, 8_000_000) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
println!("no hardware encoder ({e}); skipping mf test");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
println!("hardware encoder: {} (hw={})", enc.name(), enc.is_hardware());
|
||||
for t in 0..n {
|
||||
let frame = make_frame(w, h, t);
|
||||
let t0 = Instant::now();
|
||||
let outs = enc.encode(&frame)?;
|
||||
enc_ns += t0.elapsed().as_nanos();
|
||||
for ef in outs {
|
||||
aus.push((ef.data, ef.keyframe));
|
||||
}
|
||||
}
|
||||
for ef in enc.drain()? {
|
||||
aus.push((ef.data, ef.keyframe));
|
||||
}
|
||||
}
|
||||
|
||||
if mode != "mf" {
|
||||
let mut enc = netdesk_host::encode::H264Encoder::new(w, h, 60, 8_000_000)?;
|
||||
for t in 0..n {
|
||||
let frame = make_frame(w, h, t);
|
||||
let t0 = Instant::now();
|
||||
let out = enc.encode(&frame)?;
|
||||
enc_ns += t0.elapsed().as_nanos();
|
||||
if let Some(ef) = out {
|
||||
aus.push((ef.data, ef.keyframe));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (data, keyframe) in &aus {
|
||||
emitted += 1;
|
||||
total_bytes += data.len();
|
||||
if *keyframe {
|
||||
keyframes += 1;
|
||||
}
|
||||
if let Some(fr) = dec.decode(data)? {
|
||||
if fr.w == w && fr.h == h {
|
||||
decoded_ok += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let enc_ms = enc_ns as f64 / n as f64 / 1e6;
|
||||
println!(" encode avg: {:.2} ms/frame (~{:.0} fps)", enc_ms, 1000.0 / enc_ms);
|
||||
println!(" AUs emitted: {} keyframes: {} decoded_ok: {}", emitted, keyframes, decoded_ok);
|
||||
println!(" avg AU: {:.1} KB", total_bytes as f64 / emitted.max(1) as f64 / 1024.0);
|
||||
|
||||
if keyframes == 0 {
|
||||
anyhow::bail!("no keyframe emitted");
|
||||
}
|
||||
if decoded_ok < emitted.saturating_sub(2) {
|
||||
anyhow::bail!("roundtrip decode failed: {}/{} decoded", decoded_ok, emitted);
|
||||
}
|
||||
println!("ROUNDTRIP OK");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Standalone NetDesk native host engine. Captures the screen and serves
|
||||
//! viewers over the NetDesk protocol with the best available H.264 encoder and
|
||||
//! SendInput injection. Configured via environment:
|
||||
//! ND_HOST_ID (required) the id viewers connect to
|
||||
//! ND_PASSWORD (required) session password (never leaves this machine)
|
||||
//! ND_WS_URL default wss://netdesk.space/ws
|
||||
//! ND_NAME default "NetDesk (Rust)"
|
||||
//! ND_MONITOR default 0
|
||||
//! ND_FPS default 60
|
||||
//! ND_BITRATE default 8000000
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let host_id = std::env::var("ND_HOST_ID").map_err(|_| anyhow::anyhow!("set ND_HOST_ID"))?;
|
||||
let password =
|
||||
std::env::var("ND_PASSWORD").map_err(|_| anyhow::anyhow!("set ND_PASSWORD"))?;
|
||||
let cfg = netdesk_host::run::EngineConfig {
|
||||
ws_url: std::env::var("ND_WS_URL").unwrap_or_else(|_| "wss://netdesk.space/ws".into()),
|
||||
host_id,
|
||||
name: std::env::var("ND_NAME").unwrap_or_else(|_| "NetDesk (Rust)".into()),
|
||||
password,
|
||||
monitor: envnum("ND_MONITOR", 0),
|
||||
fps: envnum("ND_FPS", 60),
|
||||
bitrate: envnum("ND_BITRATE", 8_000_000),
|
||||
// Host-enforced permissions + approve-before-connect (see run.rs):
|
||||
// ND_PERM_INPUT / ND_PERM_CLIPBOARD / ND_PERM_FILES / ND_PERM_AUDIO
|
||||
// (default 1) and ND_APPROVE (default 0).
|
||||
perms: netdesk_core::host::HostPerms {
|
||||
input: envflag("ND_PERM_INPUT", true),
|
||||
clipboard: envflag("ND_PERM_CLIPBOARD", true),
|
||||
files: envflag("ND_PERM_FILES", true),
|
||||
audio: envflag("ND_PERM_AUDIO", true),
|
||||
},
|
||||
approve: envflag("ND_APPROVE", false),
|
||||
totp_secret: std::env::var("ND_TOTP").unwrap_or_default(),
|
||||
// Encrypted direct-IP: ND_DIRECT_PORT makes the host listen on that
|
||||
// TCP port for a viewer that dials it directly (no relay);
|
||||
// ND_ALLOW_IPS is an optional CIDR allow-list.
|
||||
direct_port: std::env::var("ND_DIRECT_PORT").ok().and_then(|s| s.parse().ok()),
|
||||
allow_ips: std::env::var("ND_ALLOW_IPS").unwrap_or_default(),
|
||||
// ND_MAX_VIEWERS>1 allows multiple simultaneous direct-IP viewers
|
||||
// (1 controller + N observers of the one capture).
|
||||
max_viewers: std::env::var("ND_MAX_VIEWERS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1)
|
||||
.max(1),
|
||||
};
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let handle = netdesk_host::run::EngineHandle::new();
|
||||
rt.block_on(netdesk_host::run::run_engine(cfg, handle))?;
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
println!("engine is Windows-only");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn envnum<T: std::str::FromStr>(key: &str, default: T) -> T {
|
||||
std::env::var(key).ok().and_then(|s| s.parse().ok()).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// "1"/"true"/"yes" = true, "0"/"false"/"no" = false, unset = `default`.
|
||||
#[cfg(windows)]
|
||||
fn envflag(key: &str, default: bool) -> bool {
|
||||
match std::env::var(key).as_deref().map(str::trim) {
|
||||
Ok("1") | Ok("true") | Ok("yes") => true,
|
||||
Ok("0") | Ok("false") | Ok("no") => false,
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Privacy-mode feasibility probe. Determines whether a window marked
|
||||
//! `SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE)` is hidden from THIS host's
|
||||
//! own screen capture — both DXGI Desktop Duplication (`grab`) and the GDI
|
||||
//! BitBlt fallback (`grab_seed`). If it is, a fullscreen black excluded window
|
||||
//! is a real, driver-free privacy mode: the local monitor shows black while the
|
||||
//! remote operator still captures the desktop behind it.
|
||||
//!
|
||||
//! Method: show a small solid-magenta popup at a known spot, capture with the
|
||||
//! affinity OFF (control — magenta must appear) then ON (magenta must vanish),
|
||||
//! and count magenta pixels in each frame. Safe: the window is small and shown
|
||||
//! for ~2s, then destroyed. Developed by Akronlogic.
|
||||
|
||||
#![cfg(windows)]
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
use windows::core::w;
|
||||
use windows::Win32::Foundation::{COLORREF, HINSTANCE, HWND, LPARAM, LRESULT, WPARAM};
|
||||
use windows::Win32::Graphics::Gdi::CreateSolidBrush;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, PeekMessageW, RegisterClassW,
|
||||
SetWindowDisplayAffinity, ShowWindow, TranslateMessage, MSG, PM_REMOVE, SW_SHOW,
|
||||
WDA_EXCLUDEFROMCAPTURE, WDA_NONE, WNDCLASSW, WS_EX_TOPMOST, WS_POPUP, WS_VISIBLE,
|
||||
};
|
||||
|
||||
use netdesk_host::capture::DxgiCapturer;
|
||||
|
||||
unsafe extern "system" fn wndproc(h: HWND, m: u32, w: WPARAM, l: LPARAM) -> LRESULT {
|
||||
DefWindowProcW(h, m, w, l)
|
||||
}
|
||||
|
||||
fn pump(ms: u64) {
|
||||
let end = Instant::now() + Duration::from_millis(ms);
|
||||
while Instant::now() < end {
|
||||
unsafe {
|
||||
let mut msg = MSG::default();
|
||||
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
|
||||
let _ = TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
}
|
||||
|
||||
/// Count magenta (B=255,G=0,R=255 with tolerance) pixels in a BGRA buffer.
|
||||
fn count_magenta(buf: &[u8]) -> usize {
|
||||
buf.chunks_exact(4)
|
||||
.filter(|p| p[0] >= 250 && p[1] <= 6 && p[2] >= 250)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Freshest DXGI frame over `ms` (None if the duplication reported no change).
|
||||
fn freshest_dxgi(cap: &mut DxgiCapturer, ms: u64) -> Option<Vec<u8>> {
|
||||
let end = Instant::now() + Duration::from_millis(ms);
|
||||
let mut last = None;
|
||||
while Instant::now() < end {
|
||||
if let Ok(Some(f)) = cap.grab(80) {
|
||||
last = Some(f);
|
||||
}
|
||||
}
|
||||
last
|
||||
}
|
||||
|
||||
fn main() {
|
||||
unsafe {
|
||||
let hinst = HINSTANCE::default();
|
||||
let brush = CreateSolidBrush(COLORREF(0x00FF_00FF)); // B=FF,G=00,R=FF = magenta
|
||||
let class = w!("NdPrivacyProbe");
|
||||
let wc = WNDCLASSW {
|
||||
lpfnWndProc: Some(wndproc),
|
||||
hInstance: hinst,
|
||||
lpszClassName: class,
|
||||
hbrBackground: brush,
|
||||
..Default::default()
|
||||
};
|
||||
let atom = RegisterClassW(&wc);
|
||||
if atom == 0 {
|
||||
eprintln!("RegisterClassW failed");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let hwnd = match CreateWindowExW(
|
||||
WS_EX_TOPMOST,
|
||||
class,
|
||||
w!("nd-privacy-probe"),
|
||||
WS_POPUP | WS_VISIBLE,
|
||||
120,
|
||||
120,
|
||||
520,
|
||||
380,
|
||||
None,
|
||||
None,
|
||||
hinst,
|
||||
None,
|
||||
) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
eprintln!("CreateWindowExW failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let _ = ShowWindow(hwnd, SW_SHOW);
|
||||
pump(400);
|
||||
|
||||
let mut cap = match DxgiCapturer::new(0) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("DxgiCapturer::new failed: {e}");
|
||||
let _ = DestroyWindow(hwnd);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// CONTROL: affinity OFF — the window must be visible to both captures.
|
||||
let _ = SetWindowDisplayAffinity(hwnd, WDA_NONE);
|
||||
pump(300);
|
||||
let ctl_gdi = cap.grab_seed().map(|b| count_magenta(&b)).unwrap_or(usize::MAX);
|
||||
let ctl_dxgi = freshest_dxgi(&mut cap, 700).map(|b| count_magenta(&b));
|
||||
|
||||
// EXCLUDE: affinity ON — the window must vanish from capture.
|
||||
let _ = SetWindowDisplayAffinity(hwnd, WDA_EXCLUDEFROMCAPTURE);
|
||||
pump(400);
|
||||
let exc_gdi = cap.grab_seed().map(|b| count_magenta(&b)).unwrap_or(usize::MAX);
|
||||
let exc_dxgi = freshest_dxgi(&mut cap, 900).map(|b| count_magenta(&b));
|
||||
|
||||
let _ = DestroyWindow(hwnd);
|
||||
|
||||
println!("PRIVACY_PROBE control_gdi_magenta={ctl_gdi} exclude_gdi_magenta={exc_gdi}");
|
||||
println!(
|
||||
"PRIVACY_PROBE control_dxgi_magenta={:?} exclude_dxgi_magenta={:?}",
|
||||
ctl_dxgi, exc_dxgi
|
||||
);
|
||||
|
||||
let gdi_ok = ctl_gdi > 1000 && exc_gdi == 0;
|
||||
// DXGI: control should show magenta; exclude should show none. If the
|
||||
// duplication reported no change in a phase (None), note it.
|
||||
let dxgi_ok = matches!((ctl_dxgi, exc_dxgi), (Some(c), Some(e)) if c > 1000 && e == 0);
|
||||
if gdi_ok && dxgi_ok {
|
||||
println!("VERDICT: PRIVACY_WDA_WORKS — WDA_EXCLUDEFROMCAPTURE hides the window from BOTH DXGI and GDI capture");
|
||||
} else if gdi_ok {
|
||||
println!("VERDICT: PRIVACY_WDA_GDI_ONLY — GDI excludes it; DXGI result {:?}/{:?} (inconclusive or not excluded)", ctl_dxgi, exc_dxgi);
|
||||
} else {
|
||||
println!("VERDICT: PRIVACY_WDA_FAILS — the window was still captured; privacy needs an IDD virtual display");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Capture + encode benchmark: open DXGI duplication on monitor 0, encode each
|
||||
//! frame to H.264, and report per-stage timing + bitstream sizes. Proves the
|
||||
//! native capture->encode pipeline works and shows where we stand on latency.
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
netdesk_host::set_dpi_aware();
|
||||
let mut cap = netdesk_host::capture::DxgiCapturer::new(0)?;
|
||||
let (w, h) = (cap.width() as usize, cap.height() as usize);
|
||||
println!("capture: {}x{}", w, h);
|
||||
|
||||
let mut enc = netdesk_host::encode::H264Encoder::new(w, h, 60, 8_000_000)?;
|
||||
|
||||
let mut last: Option<Vec<u8>> = None;
|
||||
let mut frames = 0usize;
|
||||
let mut keyframes = 0usize;
|
||||
let mut cap_ns = 0u128;
|
||||
let mut enc_ns = 0u128;
|
||||
let mut total_bytes = 0usize;
|
||||
let mut encoded = 0usize;
|
||||
|
||||
// warm up + run ~150 iterations
|
||||
for i in 0..150 {
|
||||
let t0 = Instant::now();
|
||||
let frame = match cap.grab(50)? {
|
||||
Some(buf) => {
|
||||
last = Some(buf);
|
||||
last.as_ref().unwrap()
|
||||
}
|
||||
None => match last.as_ref() {
|
||||
Some(b) => b, // screen unchanged: re-encode last (keeps fps up)
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
let t1 = Instant::now();
|
||||
let out = enc.encode(frame)?;
|
||||
let t2 = Instant::now();
|
||||
|
||||
if i >= 10 {
|
||||
// skip warmup
|
||||
frames += 1;
|
||||
cap_ns += (t1 - t0).as_nanos();
|
||||
enc_ns += (t2 - t1).as_nanos();
|
||||
if let Some(ef) = &out {
|
||||
encoded += 1;
|
||||
total_bytes += ef.data.len();
|
||||
if ef.keyframe {
|
||||
keyframes += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if frames == 0 {
|
||||
anyhow::bail!("no frames");
|
||||
}
|
||||
let cap_ms = cap_ns as f64 / frames as f64 / 1e6;
|
||||
let enc_ms = enc_ns as f64 / frames as f64 / 1e6;
|
||||
println!("measured over {} frames ({} produced output, {} keyframes)", frames, encoded, keyframes);
|
||||
println!(" capture avg: {:.2} ms", cap_ms);
|
||||
println!(" encode avg: {:.2} ms", enc_ms);
|
||||
println!(" pipeline: {:.2} ms (~{:.0} fps ceiling)", cap_ms + enc_ms, 1000.0 / (cap_ms + enc_ms));
|
||||
if encoded > 0 {
|
||||
let avg_kb = total_bytes as f64 / encoded as f64 / 1024.0;
|
||||
println!(" avg AU size: {:.1} KB (~{:.1} Mbit/s @60fps)", avg_kb, avg_kb * 1024.0 * 8.0 * 60.0 / 1e6);
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
println!("probe is Windows-only");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
//! Windows screen capture via DXGI Desktop Duplication — the same GPU path
|
||||
//! dxcam / RustDesk use: the OS hands us the latest desktop frame as a GPU
|
||||
//! texture, we copy it to a CPU-readable staging texture and read BGRA out.
|
||||
//! `grab` returns `None` when the desktop is unchanged (no new frame), so an
|
||||
//! idle screen costs almost nothing.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use windows::core::Interface;
|
||||
use windows::Win32::Foundation::{HMODULE, E_ACCESSDENIED};
|
||||
use windows::Win32::Graphics::Direct3D::{
|
||||
D3D_DRIVER_TYPE_HARDWARE, D3D_FEATURE_LEVEL_11_0,
|
||||
};
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D,
|
||||
D3D11_CPU_ACCESS_READ, D3D11_CREATE_DEVICE_FLAG, D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_READ,
|
||||
D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, D3D11_USAGE_STAGING,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::DXGI_SAMPLE_DESC;
|
||||
use windows::Win32::Graphics::Dxgi::{
|
||||
IDXGIAdapter, IDXGIDevice, IDXGIOutput, IDXGIOutput1, IDXGIOutputDuplication, IDXGIResource,
|
||||
DXGI_ERROR_ACCESS_LOST, DXGI_ERROR_WAIT_TIMEOUT, DXGI_OUTDUPL_FRAME_INFO,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN,
|
||||
SM_YVIRTUALSCREEN,
|
||||
};
|
||||
|
||||
/// Geometry of one display output (flat DXGI output index + its desktop rect).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct MonitorDesc {
|
||||
pub index: u32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub left: i32,
|
||||
pub top: i32,
|
||||
}
|
||||
|
||||
/// A live Desktop Duplication session for one monitor.
|
||||
pub struct DxgiCapturer {
|
||||
device: ID3D11Device,
|
||||
context: ID3D11DeviceContext,
|
||||
dupl: IDXGIOutputDuplication,
|
||||
width: u32,
|
||||
height: u32,
|
||||
left: i32,
|
||||
top: i32,
|
||||
staging: Option<ID3D11Texture2D>,
|
||||
}
|
||||
|
||||
impl DxgiCapturer {
|
||||
/// Enumerate all display outputs on the default adapter, in the same flat
|
||||
/// index order `new(index)` uses. Best-effort: returns an empty vec on any
|
||||
/// failure (callers fall back to a single monitor). The index is what the
|
||||
/// host advertises as `monitors[].i` and what a `monitor.switch{mon}` sends.
|
||||
pub fn enumerate() -> Vec<MonitorDesc> {
|
||||
let mut out = Vec::new();
|
||||
unsafe {
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let levels = [D3D_FEATURE_LEVEL_11_0];
|
||||
if D3D11CreateDevice(
|
||||
None,
|
||||
D3D_DRIVER_TYPE_HARDWARE,
|
||||
HMODULE::default(),
|
||||
D3D11_CREATE_DEVICE_FLAG(0),
|
||||
Some(&levels),
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return out;
|
||||
}
|
||||
let device = match device {
|
||||
Some(d) => d,
|
||||
None => return out,
|
||||
};
|
||||
let dxgi_device: IDXGIDevice = match device.cast() {
|
||||
Ok(d) => d,
|
||||
Err(_) => return out,
|
||||
};
|
||||
let adapter: IDXGIAdapter = match dxgi_device.GetAdapter() {
|
||||
Ok(a) => a,
|
||||
Err(_) => return out,
|
||||
};
|
||||
let mut i = 0u32;
|
||||
loop {
|
||||
match adapter.EnumOutputs(i) {
|
||||
Ok(output) => {
|
||||
if let Ok(desc) = output.GetDesc() {
|
||||
let c = desc.DesktopCoordinates;
|
||||
out.push(MonitorDesc {
|
||||
index: i,
|
||||
width: (c.right - c.left) as u32,
|
||||
height: (c.bottom - c.top) as u32,
|
||||
left: c.left,
|
||||
top: c.top,
|
||||
});
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Err(_) => break, // DXGI_ERROR_NOT_FOUND: no more outputs
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Open duplication for `output_index` (0 = first monitor).
|
||||
pub fn new(output_index: u32) -> Result<Self> {
|
||||
unsafe {
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
let levels = [D3D_FEATURE_LEVEL_11_0];
|
||||
D3D11CreateDevice(
|
||||
None,
|
||||
D3D_DRIVER_TYPE_HARDWARE,
|
||||
HMODULE::default(),
|
||||
D3D11_CREATE_DEVICE_FLAG(0),
|
||||
Some(&levels),
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
Some(&mut context),
|
||||
)?;
|
||||
let device = device.ok_or_else(|| anyhow!("D3D11 device is null"))?;
|
||||
let context = context.ok_or_else(|| anyhow!("D3D11 context is null"))?;
|
||||
|
||||
let dxgi_device: IDXGIDevice = device.cast()?;
|
||||
let adapter: IDXGIAdapter = dxgi_device.GetAdapter()?;
|
||||
let output: IDXGIOutput = adapter.EnumOutputs(output_index)?;
|
||||
let desc = output.GetDesc()?;
|
||||
let width = (desc.DesktopCoordinates.right - desc.DesktopCoordinates.left) as u32;
|
||||
let height = (desc.DesktopCoordinates.bottom - desc.DesktopCoordinates.top) as u32;
|
||||
let left = desc.DesktopCoordinates.left;
|
||||
let top = desc.DesktopCoordinates.top;
|
||||
|
||||
let output1: IDXGIOutput1 = output.cast()?;
|
||||
let dupl = output1.DuplicateOutput(&device)?;
|
||||
|
||||
Ok(Self {
|
||||
device,
|
||||
context,
|
||||
dupl,
|
||||
width,
|
||||
height,
|
||||
left,
|
||||
top,
|
||||
staging: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn width(&self) -> u32 {
|
||||
self.width
|
||||
}
|
||||
pub fn height(&self) -> u32 {
|
||||
self.height
|
||||
}
|
||||
/// Top-left of this monitor in virtual-desktop coordinates (for mapping the
|
||||
/// global cursor position into frame-pixel space).
|
||||
pub fn origin(&self) -> (i32, i32) {
|
||||
(self.left, self.top)
|
||||
}
|
||||
|
||||
/// Grab the next frame as tightly-packed BGRA (`width*height*4`). Returns
|
||||
/// `Ok(None)` when nothing changed within `timeout_ms`. On a desktop switch
|
||||
/// the duplication is lost (`access lost`) and the caller re-creates us.
|
||||
pub fn grab(&mut self, timeout_ms: u32) -> Result<Option<Vec<u8>>> {
|
||||
unsafe {
|
||||
let mut info = DXGI_OUTDUPL_FRAME_INFO::default();
|
||||
let mut resource: Option<IDXGIResource> = None;
|
||||
match self.dupl.AcquireNextFrame(timeout_ms, &mut info, &mut resource) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.code() == DXGI_ERROR_WAIT_TIMEOUT => return Ok(None),
|
||||
Err(e) if e.code() == DXGI_ERROR_ACCESS_LOST || e.code() == E_ACCESSDENIED => {
|
||||
return Err(anyhow!("duplication access lost"));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
let resource = resource.ok_or_else(|| anyhow!("acquired null resource"))?;
|
||||
let tex: ID3D11Texture2D = resource.cast()?;
|
||||
|
||||
let mut tdesc = D3D11_TEXTURE2D_DESC::default();
|
||||
tex.GetDesc(&mut tdesc);
|
||||
|
||||
if self.staging.is_none() {
|
||||
let sdesc = D3D11_TEXTURE2D_DESC {
|
||||
Width: tdesc.Width,
|
||||
Height: tdesc.Height,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: tdesc.Format,
|
||||
SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 },
|
||||
Usage: D3D11_USAGE_STAGING,
|
||||
BindFlags: 0,
|
||||
CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32,
|
||||
MiscFlags: 0,
|
||||
};
|
||||
let mut st: Option<ID3D11Texture2D> = None;
|
||||
self.device.CreateTexture2D(&sdesc, None, Some(&mut st))?;
|
||||
self.staging = st;
|
||||
}
|
||||
let staging = self.staging.clone().unwrap();
|
||||
self.context.CopyResource(&staging, &tex);
|
||||
|
||||
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
|
||||
self.context
|
||||
.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut mapped))?;
|
||||
|
||||
let w = tdesc.Width as usize;
|
||||
let h = tdesc.Height as usize;
|
||||
let pitch = mapped.RowPitch as usize;
|
||||
let src = mapped.pData as *const u8;
|
||||
let mut out = vec![0u8; w * h * 4];
|
||||
for y in 0..h {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
src.add(y * pitch),
|
||||
out.as_mut_ptr().add(y * w * 4),
|
||||
w * 4,
|
||||
);
|
||||
}
|
||||
self.context.Unmap(&staging, 0);
|
||||
let _ = self.dupl.ReleaseFrame();
|
||||
Ok(Some(out))
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the CURRENT desktop image even when nothing has changed. A
|
||||
/// long-running duplication yields `WAIT_TIMEOUT` on a static screen (so
|
||||
/// `grab` returns `Ok(None)` indefinitely), which would leave a freshly
|
||||
/// connected viewer black because the keyframe flush has no pixels to
|
||||
/// encode. This gives DXGI a brief chance to produce the frame and otherwise
|
||||
/// falls back to a GDI BitBlt of the monitor rect, which always returns live
|
||||
/// pixels. Used only to seed a keyframe (viewer join / refresh), never in
|
||||
/// the per-frame hot path.
|
||||
pub fn grab_seed(&mut self) -> Result<Vec<u8>> {
|
||||
for _ in 0..2 {
|
||||
match self.grab(150) {
|
||||
Ok(Some(buf)) => return Ok(buf),
|
||||
Ok(None) => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
gdi_capture(self.left, self.top, self.width, self.height)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DxgiCapturer {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
let _ = self.dupl.ReleaseFrame();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture a monitor rect via GDI BitBlt as top-down BGRA. Unlike DXGI this
|
||||
/// always returns the CURRENT pixels (no "a frame changed" requirement), so it
|
||||
/// can seed the first keyframe for an idle/static screen. The host is
|
||||
/// per-monitor-DPI-aware, so `left/top/width/height` are physical virtual-
|
||||
/// desktop pixels that match the DXGI frame layout (BGRA, 4 bytes/pixel).
|
||||
pub(crate) fn gdi_capture(left: i32, top: i32, width: u32, height: u32) -> Result<Vec<u8>> {
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC,
|
||||
GetDIBits, ReleaseDC, SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS,
|
||||
HGDIOBJ, SRCCOPY,
|
||||
};
|
||||
let (w, h) = (width as i32, height as i32);
|
||||
unsafe {
|
||||
let screen = GetDC(None);
|
||||
if screen.is_invalid() {
|
||||
return Err(anyhow!("GetDC(screen) failed"));
|
||||
}
|
||||
let mem = CreateCompatibleDC(screen);
|
||||
let bmp = CreateCompatibleBitmap(screen, w, h);
|
||||
if mem.is_invalid() || bmp.is_invalid() {
|
||||
ReleaseDC(None, screen);
|
||||
return Err(anyhow!("GDI DC/bitmap alloc failed"));
|
||||
}
|
||||
let old = SelectObject(mem, HGDIOBJ(bmp.0));
|
||||
let blt = BitBlt(mem, 0, 0, w, h, screen, left, top, SRCCOPY);
|
||||
let mut bi = BITMAPINFO {
|
||||
bmiHeader: BITMAPINFOHEADER {
|
||||
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
|
||||
biWidth: w,
|
||||
biHeight: -h, // negative => top-down, matching the DXGI buffer
|
||||
biPlanes: 1,
|
||||
biBitCount: 32,
|
||||
biCompression: BI_RGB.0 as u32,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = vec![0u8; (width * height * 4) as usize];
|
||||
let lines = GetDIBits(
|
||||
mem,
|
||||
bmp,
|
||||
0,
|
||||
height,
|
||||
Some(out.as_mut_ptr() as *mut core::ffi::c_void),
|
||||
&mut bi,
|
||||
DIB_RGB_COLORS,
|
||||
);
|
||||
let _ = SelectObject(mem, old);
|
||||
let _ = DeleteObject(HGDIOBJ(bmp.0));
|
||||
let _ = DeleteDC(mem);
|
||||
ReleaseDC(None, screen);
|
||||
if blt.is_err() || lines == 0 {
|
||||
return Err(anyhow!("GDI BitBlt/GetDIBits failed"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach the calling (capture) thread to the desktop currently receiving input
|
||||
/// — the normal Default desktop, or the secure Winlogon desktop when the machine
|
||||
/// is locked, at the sign-in screen, or showing a UAC prompt. Running as SYSTEM
|
||||
/// (via the service), this lets GDI capture + SendInput reach the secure desktop
|
||||
/// so a remote viewer can sign in and unlock. Keeps exactly one desktop handle
|
||||
/// open, closing the previous one on each switch.
|
||||
static mut PREV_DESK: isize = 0;
|
||||
pub(crate) fn attach_input_desktop() -> bool {
|
||||
use windows::Win32::System::StationsAndDesktops::{
|
||||
CloseDesktop, OpenInputDesktop, SetThreadDesktop, DESKTOP_ACCESS_FLAGS,
|
||||
DESKTOP_CONTROL_FLAGS, HDESK,
|
||||
};
|
||||
unsafe {
|
||||
// GENERIC_ALL (0x10000000) → the kernel maps it to all desktop rights.
|
||||
match OpenInputDesktop(DESKTOP_CONTROL_FLAGS(0), false, DESKTOP_ACCESS_FLAGS(0x1000_0000)) {
|
||||
Ok(h) => {
|
||||
if SetThreadDesktop(h).is_ok() {
|
||||
if PREV_DESK != 0 {
|
||||
let _ = CloseDesktop(HDESK(PREV_DESK as *mut core::ffi::c_void));
|
||||
}
|
||||
PREV_DESK = h.0 as isize;
|
||||
true
|
||||
} else {
|
||||
let _ = CloseDesktop(h);
|
||||
false
|
||||
}
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The all-monitors sentinel: `Capturer::open(MON_ALL)` captures the whole
|
||||
/// virtual desktop (every monitor stitched) via GDI. On the wire it is
|
||||
/// advertised + switched as monitor index `-1`.
|
||||
pub const MON_ALL: u32 = u32::MAX;
|
||||
|
||||
/// Bounding rect of the whole virtual desktop (all monitors), physical pixels:
|
||||
/// `(left, top, width, height)`. Width/height are rounded DOWN to even (the
|
||||
/// encoder needs even dimensions).
|
||||
pub fn virtual_desktop_rect() -> (i32, i32, u32, u32) {
|
||||
unsafe {
|
||||
let left = GetSystemMetrics(SM_XVIRTUALSCREEN);
|
||||
let top = GetSystemMetrics(SM_YVIRTUALSCREEN);
|
||||
let w = (GetSystemMetrics(SM_CXVIRTUALSCREEN).max(0) as u32) & !1;
|
||||
let h = (GetSystemMetrics(SM_CYVIRTUALSCREEN).max(0) as u32) & !1;
|
||||
(left, top, w, h)
|
||||
}
|
||||
}
|
||||
|
||||
/// All-monitors capturer: a GDI BitBlt of the whole virtual desktop each grab.
|
||||
/// Slower than DXGI (no GPU path, no "changed-only" wait), so it is an opt-in
|
||||
/// overview source, not the per-monitor default. Always returns live pixels.
|
||||
pub struct VirtualGdiCapturer {
|
||||
left: i32,
|
||||
top: i32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
impl VirtualGdiCapturer {
|
||||
pub fn new() -> Result<Self> {
|
||||
let (left, top, width, height) = virtual_desktop_rect();
|
||||
if width == 0 || height == 0 {
|
||||
return Err(anyhow!("virtual desktop has zero size"));
|
||||
}
|
||||
Ok(Self { left, top, width, height })
|
||||
}
|
||||
pub fn width(&self) -> u32 {
|
||||
self.width
|
||||
}
|
||||
pub fn height(&self) -> u32 {
|
||||
self.height
|
||||
}
|
||||
pub fn origin(&self) -> (i32, i32) {
|
||||
(self.left, self.top)
|
||||
}
|
||||
/// GDI has no "unchanged" signal, so every grab returns the current frame.
|
||||
pub fn grab(&mut self, _timeout_ms: u32) -> Result<Option<Vec<u8>>> {
|
||||
Ok(Some(gdi_capture(self.left, self.top, self.width, self.height)?))
|
||||
}
|
||||
pub fn grab_seed(&mut self) -> Result<Vec<u8>> {
|
||||
gdi_capture(self.left, self.top, self.width, self.height)
|
||||
}
|
||||
}
|
||||
|
||||
/// A capture source: one DXGI-duplicated monitor, or the whole virtual desktop
|
||||
/// (GDI). The capture loop holds this and never branches on the variant — the
|
||||
/// enum delegates grab/seed/geometry, so `MON_ALL` "just works" as a monitor.
|
||||
pub enum Capturer {
|
||||
Dxgi(DxgiCapturer),
|
||||
VirtualAll(VirtualGdiCapturer),
|
||||
}
|
||||
|
||||
impl Capturer {
|
||||
/// Open monitor `mon` (`MON_ALL` => the whole virtual desktop via GDI).
|
||||
pub fn open(mon: u32) -> Result<Self> {
|
||||
if mon == MON_ALL {
|
||||
Ok(Capturer::VirtualAll(VirtualGdiCapturer::new()?))
|
||||
} else {
|
||||
Ok(Capturer::Dxgi(DxgiCapturer::new(mon)?))
|
||||
}
|
||||
}
|
||||
pub fn grab(&mut self, timeout_ms: u32) -> Result<Option<Vec<u8>>> {
|
||||
match self {
|
||||
Capturer::Dxgi(c) => c.grab(timeout_ms),
|
||||
Capturer::VirtualAll(c) => c.grab(timeout_ms),
|
||||
}
|
||||
}
|
||||
pub fn grab_seed(&mut self) -> Result<Vec<u8>> {
|
||||
match self {
|
||||
Capturer::Dxgi(c) => c.grab_seed(),
|
||||
Capturer::VirtualAll(c) => c.grab_seed(),
|
||||
}
|
||||
}
|
||||
pub fn width(&self) -> u32 {
|
||||
match self {
|
||||
Capturer::Dxgi(c) => c.width(),
|
||||
Capturer::VirtualAll(c) => c.width(),
|
||||
}
|
||||
}
|
||||
pub fn height(&self) -> u32 {
|
||||
match self {
|
||||
Capturer::Dxgi(c) => c.height(),
|
||||
Capturer::VirtualAll(c) => c.height(),
|
||||
}
|
||||
}
|
||||
pub fn origin(&self) -> (i32, i32) {
|
||||
match self {
|
||||
Capturer::Dxgi(c) => c.origin(),
|
||||
Capturer::VirtualAll(c) => c.origin(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gdi_seed_tests {
|
||||
use super::*;
|
||||
|
||||
/// The GDI fallback must return a full-size buffer of live (non-black)
|
||||
/// desktop pixels — this is what rescues an idle screen from a black
|
||||
/// keyframe. Runs only with a real desktop session.
|
||||
#[test]
|
||||
fn gdi_capture_returns_live_pixels() {
|
||||
// A small rect at the virtual-desktop origin; every machine has pixels
|
||||
// here (taskbar / wallpaper), so a correct capture is never all-black.
|
||||
let w = 64u32;
|
||||
let h = 64u32;
|
||||
match gdi_capture(0, 0, w, h) {
|
||||
Ok(buf) => {
|
||||
assert_eq!(buf.len(), (w * h * 4) as usize, "wrong buffer size");
|
||||
let lit = buf.chunks_exact(4).filter(|p| p[0] | p[1] | p[2] != 0).count();
|
||||
assert!(lit > 0, "GDI capture returned an all-black buffer");
|
||||
}
|
||||
Err(e) => panic!("gdi_capture failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The all-monitors capturer reports an even-sized virtual desktop and
|
||||
/// grabs a full-size live frame. Runs only with a real desktop session.
|
||||
#[test]
|
||||
fn virtual_capturer_grabs_full_desktop() {
|
||||
let (_l, _t, vw, vh) = virtual_desktop_rect();
|
||||
assert!(vw > 0 && vh > 0, "virtual desktop size is zero");
|
||||
assert!(vw % 2 == 0 && vh % 2 == 0, "virtual desktop size must be even");
|
||||
let mut cap = Capturer::open(MON_ALL).expect("open all-monitors capturer");
|
||||
assert_eq!((cap.width(), cap.height()), (vw, vh));
|
||||
let buf = cap.grab_seed().expect("grab virtual desktop");
|
||||
assert_eq!(buf.len(), (vw * vh * 4) as usize, "wrong buffer size");
|
||||
let lit = buf.chunks_exact(4).filter(|p| p[0] | p[1] | p[2] != 0).count();
|
||||
assert!(lit > 0, "all-monitors capture was all black");
|
||||
println!("VIRTUAL_CAP_OK {}x{} bytes={}", vw, vh, buf.len());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! BGRA -> NV12 color conversion (BT.601 limited range), the input most
|
||||
//! hardware H.264 encoders want. We sample chroma at the top-left of each 2x2
|
||||
//! block (fast; chroma is half-res anyway). Integer coefficients match the
|
||||
//! viewer decoder's NV12->RGB (BT.601 limited) so a capture->encode->decode
|
||||
//! roundtrip is color-stable.
|
||||
//!
|
||||
//! Output is tightly packed NV12: Y plane (w*h) then interleaved UV (w*h/2),
|
||||
//! total w*h*3/2 bytes, stride == width (callers keep width 2-aligned).
|
||||
|
||||
/// Convert a tightly-packed BGRA frame (`w*h*4`) into `dst` as NV12. `dst` must
|
||||
/// be at least `w*h*3/2` bytes. `w` and `h` must be even.
|
||||
pub fn bgra_to_nv12(bgra: &[u8], w: usize, h: usize, dst: &mut [u8]) {
|
||||
debug_assert!(w % 2 == 0 && h % 2 == 0);
|
||||
debug_assert!(bgra.len() >= w * h * 4);
|
||||
debug_assert!(dst.len() >= w * h * 3 / 2);
|
||||
|
||||
let (y_plane, uv_plane) = dst.split_at_mut(w * h);
|
||||
|
||||
// Luma: every pixel.
|
||||
for y in 0..h {
|
||||
let row = y * w;
|
||||
for x in 0..w {
|
||||
let p = (row + x) * 4;
|
||||
let b = bgra[p] as i32;
|
||||
let g = bgra[p + 1] as i32;
|
||||
let r = bgra[p + 2] as i32;
|
||||
// Y = 16 + (66R + 129G + 25B)/256
|
||||
let yy = (66 * r + 129 * g + 25 * b + 128) >> 8;
|
||||
y_plane[row + x] = (yy + 16).clamp(0, 255) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
// Chroma: one U,V per 2x2 block, sampled at the block's top-left pixel.
|
||||
let cw = w / 2;
|
||||
for by in 0..h / 2 {
|
||||
let srow = (by * 2) * w;
|
||||
let drow = by * cw * 2;
|
||||
for bx in 0..cw {
|
||||
let p = (srow + bx * 2) * 4;
|
||||
let b = bgra[p] as i32;
|
||||
let g = bgra[p + 1] as i32;
|
||||
let r = bgra[p + 2] as i32;
|
||||
// U = 128 + (-38R - 74G + 112B)/256 ; V = 128 + (112R - 94G - 18B)/256
|
||||
let u = ((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128;
|
||||
let v = ((112 * r - 94 * g - 18 * b + 128) >> 8) + 128;
|
||||
uv_plane[drow + bx * 2] = u.clamp(0, 255) as u8;
|
||||
uv_plane[drow + bx * 2 + 1] = v.clamp(0, 255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bilinear downscale of a tightly-packed BGRA image `sw`x`sh` into `dst`
|
||||
/// (`dw`x`dh`, also tightly packed BGRA). Used for the quality "scale" lever so
|
||||
/// a constrained link can trade resolution for bandwidth. Only called when
|
||||
/// dw<sw (downscale), where bilinear is a good quality/speed trade-off.
|
||||
pub fn resize_bgra(src: &[u8], sw: usize, sh: usize, dst: &mut [u8], dw: usize, dh: usize) {
|
||||
if dw == 0 || dh == 0 || sw == 0 || sh == 0 {
|
||||
return;
|
||||
}
|
||||
let x_ratio = sw as f32 / dw as f32;
|
||||
let y_ratio = sh as f32 / dh as f32;
|
||||
for dy in 0..dh {
|
||||
let sy = (dy as f32 + 0.5) * y_ratio - 0.5;
|
||||
let sy = sy.clamp(0.0, (sh - 1) as f32);
|
||||
let y0 = sy.floor() as usize;
|
||||
let y1 = (y0 + 1).min(sh - 1);
|
||||
let fy = sy - y0 as f32;
|
||||
let r0 = y0 * sw * 4;
|
||||
let r1 = y1 * sw * 4;
|
||||
let drow = dy * dw * 4;
|
||||
for dx in 0..dw {
|
||||
let sx = (dx as f32 + 0.5) * x_ratio - 0.5;
|
||||
let sx = sx.clamp(0.0, (sw - 1) as f32);
|
||||
let x0 = sx.floor() as usize;
|
||||
let x1 = (x0 + 1).min(sw - 1);
|
||||
let fx = sx - x0 as f32;
|
||||
let o = drow + dx * 4;
|
||||
let (a, b, c, d) = (r0 + x0 * 4, r0 + x1 * 4, r1 + x0 * 4, r1 + x1 * 4);
|
||||
for ch in 0..4 {
|
||||
let p00 = src[a + ch] as f32;
|
||||
let p01 = src[b + ch] as f32;
|
||||
let p10 = src[c + ch] as f32;
|
||||
let p11 = src[d + ch] as f32;
|
||||
let top = p00 + (p01 - p00) * fx;
|
||||
let bot = p10 + (p11 - p10) * fx;
|
||||
dst[o + ch] = (top + (bot - top) * fy).round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Target encode dimensions for a capture `w`x`h` at `scale` (0..1], rounded to
|
||||
/// even and clamped to a sane minimum.
|
||||
pub fn scaled_even(w: u32, h: u32, scale: f32) -> (u32, u32) {
|
||||
let s = scale.clamp(0.1, 1.0);
|
||||
let sw = (((w as f32 * s) as u32) & !1).max(64).min(w);
|
||||
let sh = (((h as f32 * s) as u32) & !1).max(64).min(h);
|
||||
(sw, sh)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
//! Remote cursor tracking (RustDesk-style overlay): report the host cursor's
|
||||
//! position and shape to the viewer, sent on the control channel separately
|
||||
//! from video so an idle screen still shows a live, correctly-shaped cursor.
|
||||
//!
|
||||
//! Shapes are reported as CSS names for the standard system cursors (arrow,
|
||||
//! text, hand, resize, …) — which the viewer renders as crisp OS-native
|
||||
//! pointers — so there is no fragile per-frame bitmap extraction. Non-standard
|
||||
//! (app-custom) cursors fall back to the viewer's default pointer.
|
||||
|
||||
#![cfg(windows)]
|
||||
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetCursorInfo, LoadCursorW, CURSORINFO, CURSOR_SHOWING, IDC_APPSTARTING, IDC_ARROW, IDC_CROSS,
|
||||
IDC_HAND, IDC_HELP, IDC_IBEAM, IDC_NO, IDC_SIZEALL, IDC_SIZENESW, IDC_SIZENS, IDC_SIZENWSE,
|
||||
IDC_SIZEWE, IDC_WAIT,
|
||||
};
|
||||
|
||||
/// `(hcursor_or_0, global_x, global_y)`. `0` means the cursor is hidden.
|
||||
pub fn cursor_state() -> Option<(u64, i32, i32)> {
|
||||
unsafe {
|
||||
let mut ci = CURSORINFO {
|
||||
cbSize: std::mem::size_of::<CURSORINFO>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
if GetCursorInfo(&mut ci).is_err() {
|
||||
return None;
|
||||
}
|
||||
let showing = (ci.flags.0 & CURSOR_SHOWING.0) != 0 && !ci.hCursor.is_invalid();
|
||||
let h = if showing { ci.hCursor.0 as u64 } else { 0 };
|
||||
Some((h, ci.ptScreenPos.x, ci.ptScreenPos.y))
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a cursor handle to a CSS cursor name by comparing it against the loaded
|
||||
/// standard system cursors. Returns `None` for app-custom cursors.
|
||||
pub fn cursor_css(hcursor: u64) -> Option<&'static str> {
|
||||
if hcursor == 0 {
|
||||
return None;
|
||||
}
|
||||
const STD: &[(windows::core::PCWSTR, &str)] = &[
|
||||
(IDC_ARROW, "default"),
|
||||
(IDC_IBEAM, "text"),
|
||||
(IDC_HAND, "pointer"),
|
||||
(IDC_CROSS, "crosshair"),
|
||||
(IDC_WAIT, "wait"),
|
||||
(IDC_APPSTARTING, "progress"),
|
||||
(IDC_SIZEALL, "move"),
|
||||
(IDC_SIZENS, "ns-resize"),
|
||||
(IDC_SIZEWE, "ew-resize"),
|
||||
(IDC_SIZENWSE, "nwse-resize"),
|
||||
(IDC_SIZENESW, "nesw-resize"),
|
||||
(IDC_NO, "not-allowed"),
|
||||
(IDC_HELP, "help"),
|
||||
];
|
||||
unsafe {
|
||||
for (idc, css) in STD {
|
||||
if let Ok(h) = LoadCursorW(None, *idc) {
|
||||
if h.0 as u64 == hcursor {
|
||||
return Some(css);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A decoded custom-cursor bitmap ready for `cursor.shape` (PNG + hotspot), so
|
||||
/// app-specific cursors render on the viewer instead of falling back to the
|
||||
/// default pointer. RustDesk sends the raw cursor image the same way.
|
||||
pub struct CursorBitmap {
|
||||
pub png: Vec<u8>,
|
||||
pub w: u32,
|
||||
pub h: u32,
|
||||
pub hx: i32,
|
||||
pub hy: i32,
|
||||
}
|
||||
|
||||
/// Extract a (color) cursor's RGBA bitmap + hotspot and PNG-encode it. Returns
|
||||
/// None for monochrome cursors (rare for app-custom shapes; the standard mono
|
||||
/// ones are already mapped to CSS) and on any GDI failure, so the caller simply
|
||||
/// keeps the default pointer. Mirrors capture.rs's GetDIBits usage.
|
||||
pub fn cursor_bitmap(hcursor: u64) -> Option<CursorBitmap> {
|
||||
use windows::Win32::Foundation::HANDLE;
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
DeleteObject, GetDC, GetDIBits, GetObjectW, ReleaseDC, BITMAP, BITMAPINFO,
|
||||
BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, HBITMAP, HGDIOBJ,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{GetIconInfo, HICON, ICONINFO};
|
||||
|
||||
if hcursor == 0 {
|
||||
return None;
|
||||
}
|
||||
unsafe {
|
||||
let mut ii = ICONINFO::default();
|
||||
if GetIconInfo(HICON(hcursor as *mut std::ffi::c_void), &mut ii).is_err() {
|
||||
return None;
|
||||
}
|
||||
// Always release the bitmaps GetIconInfo created for us.
|
||||
let cleanup = |ii: &ICONINFO| {
|
||||
if !ii.hbmColor.is_invalid() {
|
||||
let _ = DeleteObject(HGDIOBJ(ii.hbmColor.0));
|
||||
}
|
||||
if !ii.hbmMask.is_invalid() {
|
||||
let _ = DeleteObject(HGDIOBJ(ii.hbmMask.0));
|
||||
}
|
||||
};
|
||||
if ii.hbmColor.is_invalid() {
|
||||
// Monochrome cursor — fall back to the default pointer.
|
||||
cleanup(&ii);
|
||||
return None;
|
||||
}
|
||||
// Dimensions from the color bitmap.
|
||||
let mut bm = BITMAP::default();
|
||||
let got = GetObjectW(
|
||||
HGDIOBJ(ii.hbmColor.0),
|
||||
std::mem::size_of::<BITMAP>() as i32,
|
||||
Some(&mut bm as *mut _ as *mut std::ffi::c_void),
|
||||
);
|
||||
if got == 0 || bm.bmWidth <= 0 || bm.bmHeight <= 0 || bm.bmWidth > 512 || bm.bmHeight > 512 {
|
||||
cleanup(&ii);
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (bm.bmWidth as u32, bm.bmHeight as u32);
|
||||
let read_bgra = |hbm: HBITMAP| -> Option<Vec<u8>> {
|
||||
let screen = GetDC(None);
|
||||
let mut bi = BITMAPINFO {
|
||||
bmiHeader: BITMAPINFOHEADER {
|
||||
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
|
||||
biWidth: w as i32,
|
||||
biHeight: -(h as i32), // top-down
|
||||
biPlanes: 1,
|
||||
biBitCount: 32,
|
||||
biCompression: BI_RGB.0,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let mut buf = vec![0u8; (w * h * 4) as usize];
|
||||
let lines = GetDIBits(
|
||||
screen,
|
||||
hbm,
|
||||
0,
|
||||
h,
|
||||
Some(buf.as_mut_ptr() as *mut std::ffi::c_void),
|
||||
&mut bi,
|
||||
DIB_RGB_COLORS,
|
||||
);
|
||||
ReleaseDC(None, screen);
|
||||
if lines == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(buf)
|
||||
}
|
||||
};
|
||||
let color = read_bgra(ii.hbmColor);
|
||||
// The AND mask (same 32bpp read): nonzero means "transparent" where the
|
||||
// color bitmap carries no alpha of its own.
|
||||
let mask = read_bgra(ii.hbmMask);
|
||||
let hotspot = (ii.xHotspot as i32, ii.yHotspot as i32);
|
||||
cleanup(&ii);
|
||||
let color = color?;
|
||||
|
||||
// BGRA -> RGBA. Use the color bitmap's own alpha if it has any; otherwise
|
||||
// derive it from the AND mask (mask pixel != 0 => transparent).
|
||||
let has_alpha = color.chunks_exact(4).any(|px| px[3] != 0);
|
||||
let mut rgba = vec![0u8; (w * h * 4) as usize];
|
||||
for (i, px) in color.chunks_exact(4).enumerate() {
|
||||
let (b, g, r) = (px[0], px[1], px[2]);
|
||||
let a = if has_alpha {
|
||||
px[3]
|
||||
} else if let Some(m) = &mask {
|
||||
// AND mask: a "set" (white) pixel is transparent.
|
||||
let mp = &m[i * 4..i * 4 + 4];
|
||||
if mp[0] | mp[1] | mp[2] != 0 {
|
||||
0
|
||||
} else {
|
||||
255
|
||||
}
|
||||
} else {
|
||||
255
|
||||
};
|
||||
let o = i * 4;
|
||||
rgba[o] = r;
|
||||
rgba[o + 1] = g;
|
||||
rgba[o + 2] = b;
|
||||
rgba[o + 3] = a;
|
||||
}
|
||||
let _ = HANDLE::default(); // keep the HANDLE import used across windows versions
|
||||
let png = encode_png(&rgba, w, h)?;
|
||||
Some(CursorBitmap { png, w, h, hx: hotspot.0, hy: hotspot.1 })
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a top-down RGBA buffer to PNG bytes.
|
||||
pub(crate) fn encode_png(rgba: &[u8], w: u32, h: u32) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut out, w, h);
|
||||
enc.set_color(png::ColorType::Rgba);
|
||||
enc.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = enc.write_header().ok()?;
|
||||
writer.write_image_data(rgba).ok()?;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Decode PNG bytes to top-down RGBA8 + (w, h). Used for clipboard images that
|
||||
/// arrive from a viewer, to hand arboard an `ImageData`.
|
||||
pub(crate) fn decode_png(bytes: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
|
||||
let dec = png::Decoder::new(std::io::Cursor::new(bytes));
|
||||
let mut reader = dec.read_info().ok()?;
|
||||
let mut buf = vec![0u8; reader.output_buffer_size()];
|
||||
let info = reader.next_frame(&mut buf).ok()?;
|
||||
buf.truncate(info.buffer_size());
|
||||
// Normalize to RGBA8.
|
||||
let rgba = match (info.color_type, info.bit_depth) {
|
||||
(png::ColorType::Rgba, png::BitDepth::Eight) => buf,
|
||||
(png::ColorType::Rgb, png::BitDepth::Eight) => {
|
||||
let mut out = Vec::with_capacity((info.width * info.height * 4) as usize);
|
||||
for px in buf.chunks_exact(3) {
|
||||
out.extend_from_slice(&[px[0], px[1], px[2], 255]);
|
||||
}
|
||||
out
|
||||
}
|
||||
_ => return None, // uncommon formats -> skip (clipboard images are RGB/RGBA8)
|
||||
};
|
||||
Some((rgba, info.width, info.height))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Extract a real system cursor's bitmap (IDC_HAND is a color cursor on
|
||||
// modern Windows). In production cursor_css() catches the standard ones
|
||||
// first; here we call cursor_bitmap directly to prove the GDI extraction +
|
||||
// PNG encode produce a valid, sensibly-sized image.
|
||||
#[test]
|
||||
fn extracts_a_real_cursor_to_png() {
|
||||
let h = unsafe { LoadCursorW(None, IDC_HAND) };
|
||||
let h = match h {
|
||||
Ok(h) => h.0 as u64,
|
||||
Err(_) => return, // no interactive cursor resource (headless CI) — skip
|
||||
};
|
||||
if let Some(bmp) = cursor_bitmap(h) {
|
||||
assert!(bmp.w >= 8 && bmp.w <= 512, "width sane: {}", bmp.w);
|
||||
assert!(bmp.h >= 8 && bmp.h <= 512, "height sane: {}", bmp.h);
|
||||
// Valid PNG signature.
|
||||
assert_eq!(&bmp.png[..8], &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
// Round-trips through a decoder back to w*h*4 RGBA.
|
||||
let dec = png::Decoder::new(std::io::Cursor::new(&bmp.png));
|
||||
let mut reader = dec.read_info().expect("png header");
|
||||
let mut buf = vec![0u8; reader.output_buffer_size()];
|
||||
let info = reader.next_frame(&mut buf).expect("png frame");
|
||||
assert_eq!(info.width, bmp.w);
|
||||
assert_eq!(info.height, bmp.h);
|
||||
}
|
||||
// If extraction returned None (e.g. a monochrome IDC_HAND on some
|
||||
// builds), that's the documented safe fallback — not a failure.
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod clip_png_tests {
|
||||
use super::{decode_png, encode_png};
|
||||
|
||||
#[test]
|
||||
fn png_rgba_roundtrip_for_clipboard_images() {
|
||||
// A 2x2 RGBA image round-trips encode -> decode byte-for-byte.
|
||||
let w = 2u32;
|
||||
let h = 2u32;
|
||||
let rgba: Vec<u8> = vec![
|
||||
255, 0, 0, 255, 0, 255, 0, 255,
|
||||
0, 0, 255, 128, 10, 20, 30, 40,
|
||||
];
|
||||
let png = encode_png(&rgba, w, h).expect("encode");
|
||||
assert_eq!(&png[..8], &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
let (got, gw, gh) = decode_png(&png).expect("decode");
|
||||
assert_eq!((gw, gh), (w, h));
|
||||
assert_eq!(got, rgba, "clipboard image pixels survive the round-trip");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! H.264 encode via openh264 (pure-Rust-friendly, statically linked C core) —
|
||||
//! the portable software path that works on every Windows PC with no driver or
|
||||
//! GPU dependency. BGRA (from DXGI capture) is converted to I420 and encoded as
|
||||
//! Annex-B access units: keyframes carry SPS+PPS+IDR, deltas carry a P slice,
|
||||
//! exactly what the viewer's openh264/Media Foundation decoder expects.
|
||||
//!
|
||||
//! Hardware encoders (NVENC / QSV / AMF) layer in later behind this same
|
||||
//! `EncodedFrame` output; software-first guarantees the host always works.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use openh264::encoder::{Encoder, EncoderConfig, FrameType, RateControlMode, UsageType};
|
||||
use openh264::formats::{BgraSliceU8, YUVBuffer};
|
||||
use openh264::OpenH264API;
|
||||
|
||||
/// One encoded access unit ready to hand to `core::host`, which prefixes the
|
||||
/// video channel + keyframe byte and encrypts it.
|
||||
pub struct EncodedFrame {
|
||||
pub data: Vec<u8>,
|
||||
pub keyframe: bool,
|
||||
}
|
||||
|
||||
/// A live H.264 encoder for one resolution. Resolution changes re-init the
|
||||
/// encoder automatically (openh264 does this on dimension change).
|
||||
pub struct H264Encoder {
|
||||
enc: Encoder,
|
||||
width: usize,
|
||||
height: usize,
|
||||
}
|
||||
|
||||
impl H264Encoder {
|
||||
pub fn new(width: usize, height: usize, fps: u32, bitrate_bps: u32) -> Result<Self> {
|
||||
let config = EncoderConfig::new()
|
||||
.set_bitrate_bps(bitrate_bps)
|
||||
.max_frame_rate(fps as f32)
|
||||
.rate_control_mode(RateControlMode::Bitrate)
|
||||
.usage_type(UsageType::ScreenContentRealTime)
|
||||
.enable_skip_frame(true)
|
||||
.set_multiple_thread_idc(0); // auto: use all cores
|
||||
let enc = Encoder::with_api_config(OpenH264API::from_source(), config)
|
||||
.map_err(|e| anyhow!("openh264 encoder init: {e}"))?;
|
||||
Ok(Self { enc, width, height })
|
||||
}
|
||||
|
||||
/// Encode one tightly-packed BGRA frame (`width*height*4`). Returns `None`
|
||||
/// when rate control skipped this frame (nothing to send this tick).
|
||||
pub fn encode(&mut self, bgra: &[u8]) -> Result<Option<EncodedFrame>> {
|
||||
let src = BgraSliceU8::new(bgra, (self.width, self.height));
|
||||
let yuv = YUVBuffer::from_rgb_source(src);
|
||||
let bs = self.enc.encode(&yuv).map_err(|e| anyhow!("encode: {e}"))?;
|
||||
let ft = bs.frame_type();
|
||||
if ft == FrameType::Skip || ft == FrameType::Invalid {
|
||||
return Ok(None);
|
||||
}
|
||||
let data = bs.to_vec();
|
||||
if data.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let keyframe = matches!(ft, FrameType::IDR | FrameType::I);
|
||||
Ok(Some(EncodedFrame { data, keyframe }))
|
||||
}
|
||||
|
||||
/// Force the next encoded frame to be a keyframe (viewer asked for a refresh
|
||||
/// after a drop, or a new viewer joined).
|
||||
pub fn request_keyframe(&mut self) {
|
||||
self.enc.force_intra_frame();
|
||||
}
|
||||
|
||||
pub fn width(&self) -> usize {
|
||||
self.width
|
||||
}
|
||||
pub fn height(&self) -> usize {
|
||||
self.height
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
//! Windows hardware H.264 encode via Media Foundation.
|
||||
//!
|
||||
//! Enumerates the PC's hardware H.264 encoder MFTs (NVIDIA NVENC, Intel Quick
|
||||
//! Sync, AMD VCE/AMF — whatever is present) and drives the async MFT with a
|
||||
//! deadlock-free event pump. BGRA from DXGI capture is converted to NV12 and
|
||||
//! fed in; Annex-B access units come out, with SPS/PPS guaranteed on keyframes.
|
||||
//!
|
||||
//! This is the "use any hardware encoder and any GPU accelerator on any Windows
|
||||
//! PC" path. If no hardware encoder is present or init fails, the caller falls
|
||||
//! back to the portable openh264 software encoder.
|
||||
|
||||
#![cfg(windows)]
|
||||
|
||||
use crate::color::bgra_to_nv12;
|
||||
use crate::encode::EncodedFrame;
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::ffi::c_void;
|
||||
|
||||
use windows::core::{Interface, VARIANT, GUID, PWSTR};
|
||||
use windows::Win32::Media::MediaFoundation::*;
|
||||
use windows::Win32::System::Com::{CoInitializeEx, CoTaskMemFree, COINIT_MULTITHREADED};
|
||||
|
||||
/// MF_MT_FRAME_SIZE / FRAME_RATE / PIXEL_ASPECT_RATIO are packed UINT64s
|
||||
/// ((hi << 32) | lo). windows-rs does not expose the inline MFSetAttribute*
|
||||
/// helpers, so we pack them ourselves.
|
||||
unsafe fn set_size(mt: &IMFMediaType, key: &GUID, w: u32, h: u32) -> Result<()> {
|
||||
mt.SetUINT64(key, ((w as u64) << 32) | (h as u64))?;
|
||||
Ok(())
|
||||
}
|
||||
unsafe fn set_ratio(mt: &IMFMediaType, key: &GUID, num: u32, den: u32) -> Result<()> {
|
||||
mt.SetUINT64(key, ((num as u64) << 32) | (den as u64))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// ~60 fps in 100-ns units; the stream is low-latency so stamps only need to be
|
||||
/// monotonic.
|
||||
const HNS_PER_FRAME: i64 = 166_667;
|
||||
|
||||
pub struct MfEncoder {
|
||||
transform: IMFTransform,
|
||||
events: IMFMediaEventGenerator,
|
||||
width: usize,
|
||||
height: usize,
|
||||
nv12: Vec<u8>,
|
||||
seq_header: Vec<u8>,
|
||||
provides_samples: bool,
|
||||
output_size: usize,
|
||||
need_credits: u32,
|
||||
pts: i64,
|
||||
hardware: bool,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl MfEncoder {
|
||||
pub fn new(width: usize, height: usize, fps: u32, bitrate_bps: u32) -> Result<Self> {
|
||||
unsafe {
|
||||
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
|
||||
MFStartup(MF_VERSION, MFSTARTUP_FULL).map_err(|e| anyhow!("MFStartup: {e}"))?;
|
||||
|
||||
// Enumerate hardware H.264 encoders: NV12 in, H264 out.
|
||||
let input_info = MFT_REGISTER_TYPE_INFO {
|
||||
guidMajorType: MFMediaType_Video,
|
||||
guidSubtype: MFVideoFormat_NV12,
|
||||
};
|
||||
let output_info = MFT_REGISTER_TYPE_INFO {
|
||||
guidMajorType: MFMediaType_Video,
|
||||
guidSubtype: MFVideoFormat_H264,
|
||||
};
|
||||
let flags =
|
||||
MFT_ENUM_FLAG_HARDWARE | MFT_ENUM_FLAG_ASYNCMFT | MFT_ENUM_FLAG_SORTANDFILTER;
|
||||
|
||||
let mut pp: *mut Option<IMFActivate> = std::ptr::null_mut();
|
||||
let mut count: u32 = 0;
|
||||
MFTEnumEx(
|
||||
MFT_CATEGORY_VIDEO_ENCODER,
|
||||
flags,
|
||||
Some(&input_info),
|
||||
Some(&output_info),
|
||||
&mut pp,
|
||||
&mut count,
|
||||
)
|
||||
.map_err(|e| anyhow!("MFTEnumEx: {e}"))?;
|
||||
|
||||
if count == 0 || pp.is_null() {
|
||||
return Err(anyhow!("no hardware H.264 encoder MFT found"));
|
||||
}
|
||||
let activations = std::slice::from_raw_parts(pp, count as usize);
|
||||
|
||||
let mut chosen: Option<(IMFTransform, String)> = None;
|
||||
for act in activations.iter().flatten() {
|
||||
let mut pw = PWSTR::null();
|
||||
let mut len = 0u32;
|
||||
let name = if act
|
||||
.GetAllocatedString(&MFT_FRIENDLY_NAME_Attribute, &mut pw, &mut len)
|
||||
.is_ok()
|
||||
&& !pw.is_null()
|
||||
{
|
||||
let s =
|
||||
String::from_utf16_lossy(std::slice::from_raw_parts(pw.0, len as usize));
|
||||
CoTaskMemFree(Some(pw.0 as *const c_void));
|
||||
s
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if let Ok(t) = act.ActivateObject::<IMFTransform>() {
|
||||
chosen = Some((t, name));
|
||||
break;
|
||||
}
|
||||
}
|
||||
CoTaskMemFree(Some(pp as *const c_void));
|
||||
|
||||
let (transform, name) =
|
||||
chosen.ok_or_else(|| anyhow!("failed to activate any encoder MFT"))?;
|
||||
|
||||
// Async MFT: unlock before use, request low latency (1-in-1-out).
|
||||
if let Ok(attrs) = transform.GetAttributes() {
|
||||
let _ = attrs.SetUINT32(&MF_TRANSFORM_ASYNC_UNLOCK, 1);
|
||||
let _ = attrs.SetUINT32(&MF_LOW_LATENCY, 1);
|
||||
}
|
||||
|
||||
// OUTPUT type first (required for encoders): H.264.
|
||||
let out_t = MFCreateMediaType().map_err(|e| anyhow!("MFCreateMediaType(out): {e}"))?;
|
||||
out_t.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?;
|
||||
out_t.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_H264)?;
|
||||
out_t.SetUINT32(&MF_MT_AVG_BITRATE, bitrate_bps)?;
|
||||
out_t.SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32)?;
|
||||
out_t.SetUINT32(&MF_MT_MPEG2_PROFILE, eAVEncH264VProfile_Main.0 as u32)?;
|
||||
set_size(&out_t, &MF_MT_FRAME_SIZE, width as u32, height as u32)?;
|
||||
set_ratio(&out_t, &MF_MT_FRAME_RATE, fps, 1)?;
|
||||
set_ratio(&out_t, &MF_MT_PIXEL_ASPECT_RATIO, 1, 1)?;
|
||||
transform
|
||||
.SetOutputType(0, &out_t, 0)
|
||||
.map_err(|e| anyhow!("SetOutputType(H264): {e}"))?;
|
||||
|
||||
// INPUT type: NV12.
|
||||
let in_t = MFCreateMediaType().map_err(|e| anyhow!("MFCreateMediaType(in): {e}"))?;
|
||||
in_t.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?;
|
||||
in_t.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_NV12)?;
|
||||
in_t.SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32)?;
|
||||
set_size(&in_t, &MF_MT_FRAME_SIZE, width as u32, height as u32)?;
|
||||
set_ratio(&in_t, &MF_MT_FRAME_RATE, fps, 1)?;
|
||||
set_ratio(&in_t, &MF_MT_PIXEL_ASPECT_RATIO, 1, 1)?;
|
||||
transform
|
||||
.SetInputType(0, &in_t, 0)
|
||||
.map_err(|e| anyhow!("SetInputType(NV12): {e}"))?;
|
||||
|
||||
// Prefer ON-DEMAND keyframes (viewer join / refresh / QoS step) over a
|
||||
// tight periodic GOP: set a long GOP as a safety net and drive IDRs via
|
||||
// ICodecAPI force-keyframe (see request_keyframe), so the bitstream stays
|
||||
// lean on a static screen and a join gets a crisp keyframe without the
|
||||
// old whole-encoder re-init hitch. Best-effort (ignored if unsupported).
|
||||
if let Ok(api) = transform.cast::<ICodecAPI>() {
|
||||
let _ = api.SetValue(&CODECAPI_AVEncMPVGOPSize, &VARIANT::from(120u32));
|
||||
}
|
||||
|
||||
// SPS/PPS sequence header, to guarantee on keyframes.
|
||||
let mut seq_header = Vec::new();
|
||||
if let Ok(cur) = transform.GetOutputCurrentType(0) {
|
||||
if let Ok(sz) = cur.GetBlobSize(&MF_MT_MPEG_SEQUENCE_HEADER) {
|
||||
if sz > 0 {
|
||||
let mut buf = vec![0u8; sz as usize];
|
||||
if cur
|
||||
.GetBlob(&MF_MT_MPEG_SEQUENCE_HEADER, &mut buf, None)
|
||||
.is_ok()
|
||||
{
|
||||
seq_header = buf;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let info = transform
|
||||
.GetOutputStreamInfo(0)
|
||||
.map_err(|e| anyhow!("GetOutputStreamInfo: {e}"))?;
|
||||
let provides = (MFT_OUTPUT_STREAM_PROVIDES_SAMPLES.0
|
||||
| MFT_OUTPUT_STREAM_CAN_PROVIDE_SAMPLES.0) as u32;
|
||||
let provides_samples = (info.dwFlags & provides) != 0;
|
||||
let output_size = info.cbSize as usize;
|
||||
|
||||
let events: IMFMediaEventGenerator = transform
|
||||
.cast()
|
||||
.map_err(|e| anyhow!("MFT is not an event generator (not async?): {e}"))?;
|
||||
|
||||
transform.ProcessMessage(MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, 0)?;
|
||||
transform.ProcessMessage(MFT_MESSAGE_NOTIFY_START_OF_STREAM, 0)?;
|
||||
|
||||
Ok(Self {
|
||||
transform,
|
||||
events,
|
||||
width,
|
||||
height,
|
||||
nv12: vec![0u8; width * height * 3 / 2],
|
||||
seq_header,
|
||||
provides_samples,
|
||||
output_size,
|
||||
need_credits: 0,
|
||||
pts: 0,
|
||||
hardware: true,
|
||||
name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
pub fn is_hardware(&self) -> bool {
|
||||
self.hardware
|
||||
}
|
||||
pub fn width(&self) -> usize {
|
||||
self.width
|
||||
}
|
||||
pub fn height(&self) -> usize {
|
||||
self.height
|
||||
}
|
||||
|
||||
/// Encode one BGRA frame. Returns 0+ encoded AUs (an async encoder may run a
|
||||
/// frame or two of latency; outputs arrive in order).
|
||||
pub fn encode(&mut self, bgra: &[u8]) -> Result<Vec<EncodedFrame>> {
|
||||
unsafe {
|
||||
bgra_to_nv12(bgra, self.width, self.height, &mut self.nv12);
|
||||
let sample = self.make_input_sample()?;
|
||||
let mut out = Vec::new();
|
||||
|
||||
// Wait (blocking) until the MFT has asked for input at least once.
|
||||
while self.need_credits == 0 {
|
||||
let ev = self
|
||||
.events
|
||||
.GetEvent(MF_EVENT_FLAG_NONE)
|
||||
.map_err(|e| anyhow!("GetEvent(block): {e}"))?;
|
||||
self.handle_event(&ev, &mut out)?;
|
||||
}
|
||||
self.transform
|
||||
.ProcessInput(0, &sample, 0)
|
||||
.map_err(|e| anyhow!("ProcessInput: {e}"))?;
|
||||
self.need_credits -= 1;
|
||||
|
||||
// Collect whatever output is ready right now, without blocking.
|
||||
self.drain_ready(&mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush the pipeline tail (used by the bench / on shutdown).
|
||||
pub fn drain(&mut self) -> Result<Vec<EncodedFrame>> {
|
||||
unsafe {
|
||||
let mut out = Vec::new();
|
||||
self.transform
|
||||
.ProcessMessage(MFT_MESSAGE_NOTIFY_END_OF_STREAM, 0)?;
|
||||
self.transform.ProcessMessage(MFT_MESSAGE_COMMAND_DRAIN, 0)?;
|
||||
loop {
|
||||
let ev = self
|
||||
.events
|
||||
.GetEvent(MF_EVENT_FLAG_NONE)
|
||||
.map_err(|e| anyhow!("GetEvent(drain): {e}"))?;
|
||||
let t = ev.GetType()?;
|
||||
if t == METransformDrainComplete.0 as u32 {
|
||||
break;
|
||||
}
|
||||
self.handle_event(&ev, &mut out)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn drain_ready(&mut self, out: &mut Vec<EncodedFrame>) -> Result<()> {
|
||||
loop {
|
||||
match self.events.GetEvent(MF_EVENT_FLAG_NO_WAIT) {
|
||||
Ok(ev) => self.handle_event(&ev, out)?,
|
||||
Err(e) if e.code() == MF_E_NO_EVENTS_AVAILABLE => return Ok(()),
|
||||
Err(e) => return Err(anyhow!("GetEvent(nowait): {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn handle_event(
|
||||
&mut self,
|
||||
ev: &IMFMediaEvent,
|
||||
out: &mut Vec<EncodedFrame>,
|
||||
) -> Result<()> {
|
||||
let t = ev.GetType()?;
|
||||
if t == METransformNeedInput.0 as u32 {
|
||||
self.need_credits += 1;
|
||||
} else if t == METransformHaveOutput.0 as u32 {
|
||||
if let Some(f) = self.pull_output()? {
|
||||
out.push(f);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
unsafe fn make_input_sample(&mut self) -> Result<IMFSample> {
|
||||
let sample = MFCreateSample()?;
|
||||
let buffer = MFCreateMemoryBuffer(self.nv12.len() as u32)?;
|
||||
let mut ptr: *mut u8 = std::ptr::null_mut();
|
||||
buffer.Lock(&mut ptr, None, None)?;
|
||||
std::ptr::copy_nonoverlapping(self.nv12.as_ptr(), ptr, self.nv12.len());
|
||||
buffer.Unlock()?;
|
||||
buffer.SetCurrentLength(self.nv12.len() as u32)?;
|
||||
sample.AddBuffer(&buffer)?;
|
||||
let _ = sample.SetSampleTime(self.pts);
|
||||
let _ = sample.SetSampleDuration(HNS_PER_FRAME);
|
||||
self.pts += HNS_PER_FRAME;
|
||||
Ok(sample)
|
||||
}
|
||||
|
||||
/// Pick the H.264 output type (re)offered by the MFT and refresh the stored
|
||||
/// SPS/PPS. Hardware encoders finalize their output type after the first
|
||||
/// input and signal MF_E_TRANSFORM_STREAM_CHANGE; this renegotiates it.
|
||||
unsafe fn renegotiate_output(&mut self) -> Result<()> {
|
||||
let t = self
|
||||
.transform
|
||||
.GetOutputAvailableType(0, 0)
|
||||
.map_err(|e| anyhow!("GetOutputAvailableType(renegotiate): {e}"))?;
|
||||
self.transform
|
||||
.SetOutputType(0, &t, 0)
|
||||
.map_err(|e| anyhow!("SetOutputType(renegotiate): {e}"))?;
|
||||
if let Ok(sz) = t.GetBlobSize(&MF_MT_MPEG_SEQUENCE_HEADER) {
|
||||
if sz > 0 {
|
||||
let mut buf = vec![0u8; sz as usize];
|
||||
if t.GetBlob(&MF_MT_MPEG_SEQUENCE_HEADER, &mut buf, None).is_ok() {
|
||||
self.seq_header = buf;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(info) = self.transform.GetOutputStreamInfo(0) {
|
||||
let provides = (MFT_OUTPUT_STREAM_PROVIDES_SAMPLES.0
|
||||
| MFT_OUTPUT_STREAM_CAN_PROVIDE_SAMPLES.0) as u32;
|
||||
self.provides_samples = (info.dwFlags & provides) != 0;
|
||||
self.output_size = info.cbSize as usize;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
unsafe fn pull_output(&mut self) -> Result<Option<EncodedFrame>> {
|
||||
let sample_slot: Option<IMFSample> = if self.provides_samples {
|
||||
None
|
||||
} else {
|
||||
let s = MFCreateSample()?;
|
||||
let b = MFCreateMemoryBuffer(self.output_size.max(self.width * self.height) as u32)?;
|
||||
s.AddBuffer(&b)?;
|
||||
Some(s)
|
||||
};
|
||||
|
||||
let mut buffers = [MFT_OUTPUT_DATA_BUFFER {
|
||||
dwStreamID: 0,
|
||||
pSample: std::mem::ManuallyDrop::new(sample_slot),
|
||||
dwStatus: 0,
|
||||
pEvents: std::mem::ManuallyDrop::new(None),
|
||||
}];
|
||||
let mut status = 0u32;
|
||||
let res = self.transform.ProcessOutput(0, &mut buffers, &mut status);
|
||||
let out_sample = std::mem::ManuallyDrop::take(&mut buffers[0].pSample);
|
||||
let _events = std::mem::ManuallyDrop::take(&mut buffers[0].pEvents);
|
||||
|
||||
match res {
|
||||
Ok(()) => {
|
||||
let s = out_sample.ok_or_else(|| anyhow!("ProcessOutput gave no sample"))?;
|
||||
let keyframe = s.GetUINT32(&MFSampleExtension_CleanPoint).unwrap_or(0) == 1;
|
||||
let buf = s.GetBufferByIndex(0)?;
|
||||
let mut ptr: *mut u8 = std::ptr::null_mut();
|
||||
let mut len = 0u32;
|
||||
buf.Lock(&mut ptr, None, Some(&mut len))?;
|
||||
let mut data = std::slice::from_raw_parts(ptr, len as usize).to_vec();
|
||||
buf.Unlock()?;
|
||||
|
||||
if keyframe && !self.seq_header.is_empty() && !starts_with_sps(&data) {
|
||||
let mut with_hdr = self.seq_header.clone();
|
||||
with_hdr.extend_from_slice(&data);
|
||||
data = with_hdr;
|
||||
}
|
||||
Ok(Some(EncodedFrame { data, keyframe }))
|
||||
}
|
||||
Err(e) if e.code() == MF_E_TRANSFORM_NEED_MORE_INPUT => Ok(None),
|
||||
Err(e) if e.code() == MF_E_TRANSFORM_STREAM_CHANGE => {
|
||||
// Encoder finalized its output format. Renegotiate; the pending
|
||||
// output arrives on the next METransformHaveOutput event (QSV
|
||||
// catastrophes on a synchronous retry here).
|
||||
self.renegotiate_output()?;
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => Err(anyhow!("ProcessOutput: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Force the next encoded frame to be a keyframe (IDR) via ICodecAPI
|
||||
/// CODECAPI_AVEncVideoForceKeyFrame — NVENC/QSV/AMF all honor it — instead of
|
||||
/// tearing down + rebuilding the whole MFT (the old approach, which stalled
|
||||
/// the stream on every viewer join / refresh / QoS bitrate step).
|
||||
pub fn request_keyframe(&mut self) {
|
||||
unsafe {
|
||||
if let Ok(api) = self.transform.cast::<ICodecAPI>() {
|
||||
let _ = api.SetValue(&CODECAPI_AVEncVideoForceKeyFrame, &VARIANT::from(1u32));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Prove the ICodecAPI force-keyframe path emits an IDR ON DEMAND without a
|
||||
/// rebuild. With the long (120) GOP set at init, the only natural keyframe in
|
||||
/// a short run is the initial IDR at frame 0; a keyframe appearing shortly
|
||||
/// after request_keyframe() (and well before frame 120) is therefore the
|
||||
/// forced one. Skips cleanly if the box has no hardware H.264 MFT.
|
||||
#[test]
|
||||
fn forced_keyframe_emits_idr_on_demand() {
|
||||
let (w, h) = (320usize, 240usize);
|
||||
let mut enc = match MfEncoder::new(w, h, 30, 1_000_000) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return, // no hardware encoder on this box — skip
|
||||
};
|
||||
let frame = vec![0x70u8; w * h * 4]; // solid BGRA
|
||||
let mut kf_idx = Vec::new();
|
||||
let mut idx = 0usize;
|
||||
// Prime ~40 frames: collect keyframe positions. With GOP=120 only frame 0
|
||||
// should be a keyframe.
|
||||
for _ in 0..40 {
|
||||
if let Ok(frames) = enc.encode(&frame) {
|
||||
for f in frames {
|
||||
if f.keyframe {
|
||||
kf_idx.push(idx);
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let before = idx;
|
||||
enc.request_keyframe();
|
||||
for _ in 0..12 {
|
||||
if let Ok(frames) = enc.encode(&frame) {
|
||||
for f in frames {
|
||||
if f.keyframe {
|
||||
kf_idx.push(idx);
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(frames) = enc.drain() {
|
||||
for f in frames {
|
||||
if f.keyframe {
|
||||
kf_idx.push(idx);
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
// If the encoder emitted essentially no frames (some CI GPUs stall under
|
||||
// a headless session), don't assert on timing — just ensure no panic.
|
||||
if before < 5 {
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
kf_idx.iter().any(|&p| p >= before),
|
||||
"forced keyframe should emit an IDR after request_keyframe(); \
|
||||
keyframes at {kf_idx:?}, forced after frame {before} (GOP=120)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Does this Annex-B buffer begin (after a start code) with an SPS NAL (type 7)?
|
||||
fn starts_with_sps(d: &[u8]) -> bool {
|
||||
let mut i = 0;
|
||||
if d.len() >= 4 && d[0] == 0 && d[1] == 0 && d[2] == 0 && d[3] == 1 {
|
||||
i = 4;
|
||||
} else if d.len() >= 3 && d[0] == 0 && d[1] == 0 && d[2] == 1 {
|
||||
i = 3;
|
||||
}
|
||||
d.get(i).map(|b| (b & 0x1f) == 7).unwrap_or(false)
|
||||
}
|
||||
|
||||
impl Drop for MfEncoder {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
let _ = self
|
||||
.transform
|
||||
.ProcessMessage(MFT_MESSAGE_NOTIFY_END_STREAMING, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The MFT is only ever touched from the one thread that owns this encoder; MF
|
||||
// objects are agile under MTA.
|
||||
unsafe impl Send for MfEncoder {}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Unified video encoder: prefer the platform hardware encoder (Media
|
||||
//! Foundation NVENC/QSV/AMF on Windows), fall back to the portable openh264
|
||||
//! software encoder, or — on constrained links — VP9 software (libvpx), which
|
||||
//! is far better per-bitrate than H.264. One `encode` call returns 0+ coded
|
||||
//! frames (the async hardware path can run a frame of latency; software is 1:1).
|
||||
|
||||
use crate::encode::{EncodedFrame, H264Encoder};
|
||||
use anyhow::Result;
|
||||
|
||||
pub enum VideoEncoder {
|
||||
#[cfg(windows)]
|
||||
Hardware(crate::encode_mf::MfEncoder),
|
||||
Software(H264Encoder),
|
||||
#[cfg(windows)]
|
||||
Vp9(Vp9State),
|
||||
}
|
||||
|
||||
/// VP9 encoder state. libvpx is stateless about wall-clock, so we track a start
|
||||
/// instant (ms PTS) and a pending-keyframe flag (the encode loop asks for a
|
||||
/// keyframe on viewer join / refresh / re-init).
|
||||
#[cfg(windows)]
|
||||
pub struct Vp9State {
|
||||
enc: netdesk_vpx::Encoder,
|
||||
want_key: bool,
|
||||
start: std::time::Instant,
|
||||
}
|
||||
|
||||
impl VideoEncoder {
|
||||
/// Build the best available H.264 encoder for this machine (hardware MF,
|
||||
/// else openh264 software). This is the default fast-link path.
|
||||
pub fn best(width: usize, height: usize, fps: u32, bitrate_bps: u32) -> Result<Self> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
match crate::encode_mf::MfEncoder::new(width, height, fps, bitrate_bps) {
|
||||
Ok(e) => {
|
||||
log::info!("encode: hardware \"{}\"", e.name());
|
||||
return Ok(VideoEncoder::Hardware(e));
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("encode: no hardware encoder ({e}); using openh264 software");
|
||||
}
|
||||
}
|
||||
}
|
||||
let e = H264Encoder::new(width, height, fps, bitrate_bps)?;
|
||||
log::info!("encode: openh264 software");
|
||||
Ok(VideoEncoder::Software(e))
|
||||
}
|
||||
|
||||
/// Build an encoder for a specific codec (`proto::VideoCodec`): VP9 (1) uses
|
||||
/// libvpx software; anything else falls back to [`best`] (H.264). VP9 needs
|
||||
/// even dimensions, so callers must pass even `width`/`height` (the capture
|
||||
/// loop uses `color::scaled_even`).
|
||||
pub fn with_codec(
|
||||
codec_id: u8,
|
||||
i444: bool,
|
||||
width: usize,
|
||||
height: usize,
|
||||
fps: u32,
|
||||
bitrate_bps: u32,
|
||||
) -> Result<Self> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if codec_id == 1 {
|
||||
let kbps = (bitrate_bps / 1000).max(50);
|
||||
match netdesk_vpx::Encoder::new_i444(
|
||||
netdesk_vpx::VpxCodec::Vp9,
|
||||
width as u32,
|
||||
height as u32,
|
||||
fps,
|
||||
kbps,
|
||||
i444,
|
||||
) {
|
||||
Ok(enc) => {
|
||||
log::info!(
|
||||
"encode: VP9 software (libvpx) {width}x{height} @ {kbps} kbps{}",
|
||||
if i444 { " 4:4:4" } else { "" }
|
||||
);
|
||||
return Ok(VideoEncoder::Vp9(Vp9State {
|
||||
enc,
|
||||
want_key: true, // first frame is a keyframe anyway
|
||||
start: std::time::Instant::now(),
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("encode: VP9 init failed ({e}); using H.264");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = codec_id;
|
||||
Self::best(width, height, fps, bitrate_bps)
|
||||
}
|
||||
|
||||
pub fn encode(&mut self, bgra: &[u8]) -> Result<Vec<EncodedFrame>> {
|
||||
match self {
|
||||
#[cfg(windows)]
|
||||
VideoEncoder::Hardware(e) => e.encode(bgra),
|
||||
VideoEncoder::Software(e) => Ok(e.encode(bgra)?.into_iter().collect()),
|
||||
#[cfg(windows)]
|
||||
VideoEncoder::Vp9(s) => {
|
||||
let pts = s.start.elapsed().as_millis() as i64;
|
||||
let force_key = std::mem::take(&mut s.want_key);
|
||||
let frames = s.enc.encode_bgra(bgra, pts, force_key)?;
|
||||
Ok(frames
|
||||
.into_iter()
|
||||
.map(|f| EncodedFrame {
|
||||
data: f.data,
|
||||
keyframe: f.key,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request_keyframe(&mut self) {
|
||||
match self {
|
||||
#[cfg(windows)]
|
||||
VideoEncoder::Hardware(e) => e.request_keyframe(),
|
||||
VideoEncoder::Software(e) => e.request_keyframe(),
|
||||
#[cfg(windows)]
|
||||
VideoEncoder::Vp9(s) => s.want_key = true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Codec id for the frames this encoder emits (`proto::VideoCodec`):
|
||||
/// 0 = H.264 (hardware MF / openh264 software), 1 = VP9.
|
||||
pub fn codec_id(&self) -> u8 {
|
||||
match self {
|
||||
#[cfg(windows)]
|
||||
VideoEncoder::Hardware(_) => 0,
|
||||
VideoEncoder::Software(_) => 0,
|
||||
#[cfg(windows)]
|
||||
VideoEncoder::Vp9(_) => 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_hardware(&self) -> bool {
|
||||
match self {
|
||||
#[cfg(windows)]
|
||||
VideoEncoder::Hardware(_) => true,
|
||||
VideoEncoder::Software(_) => false,
|
||||
#[cfg(windows)]
|
||||
VideoEncoder::Vp9(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
#[cfg(windows)]
|
||||
VideoEncoder::Hardware(e) => format!("hardware: {}", e.name()),
|
||||
VideoEncoder::Software(_) => "software: openh264".to_string(),
|
||||
#[cfg(windows)]
|
||||
VideoEncoder::Vp9(_) => "software: VP9 (libvpx)".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use netdesk_core::decode::best_decoder_for;
|
||||
|
||||
// End-to-end through the real host encoder wrapper + the core decoder
|
||||
// selector: BGRA -> VideoEncoder(VP9) -> best_decoder_for(1) -> RGB, proving
|
||||
// the codec_id tag, pts/keyframe handling, and the BGRA<->RGB conversions
|
||||
// all line up (not just the raw netdesk-vpx roundtrip).
|
||||
#[test]
|
||||
fn vp9_encode_decode_via_video_encoder() {
|
||||
let (w, h) = (128usize, 96usize);
|
||||
let mut bgra = vec![0u8; w * h * 4];
|
||||
for j in 0..h {
|
||||
for i in 0..w {
|
||||
let p = (j * w + i) * 4;
|
||||
let (b, g, r) = if j < h / 2 && i < w / 2 {
|
||||
(30u8, 30, 200) // red
|
||||
} else if j < h / 2 {
|
||||
(30, 200, 30) // green
|
||||
} else if i < w / 2 {
|
||||
(200, 30, 30) // blue
|
||||
} else {
|
||||
(200, 200, 200) // gray
|
||||
};
|
||||
bgra[p] = b;
|
||||
bgra[p + 1] = g;
|
||||
bgra[p + 2] = r;
|
||||
bgra[p + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
let mut enc = VideoEncoder::with_codec(1, false, w, h, 30, 2_000_000).expect("vp9 encoder");
|
||||
assert_eq!(enc.codec_id(), 1, "must be VP9");
|
||||
let frames = enc.encode(&bgra).expect("encode");
|
||||
assert!(!frames.is_empty(), "no VP9 packets");
|
||||
assert!(frames[0].keyframe, "first VP9 frame must be a keyframe");
|
||||
|
||||
let mut dec = best_decoder_for(1);
|
||||
let mut got = None;
|
||||
for f in &frames {
|
||||
if let Ok(Some(fr)) = dec.decode(&f.data) {
|
||||
got = Some(fr);
|
||||
}
|
||||
}
|
||||
let fr = got.expect("decoded a frame");
|
||||
assert_eq!((fr.w, fr.h), (w, h), "decoded dims");
|
||||
|
||||
let sample = |x: usize, y: usize| {
|
||||
let p = (y * w + x) * 3;
|
||||
(fr.rgb[p], fr.rgb[p + 1], fr.rgb[p + 2]) // (R, G, B)
|
||||
};
|
||||
let approx = |a: u8, b: u8| (a as i32 - b as i32).abs() <= 48;
|
||||
let (r, g, b) = sample(w / 4, h / 4); // red quadrant
|
||||
assert!(
|
||||
approx(r, 200) && approx(g, 30) && approx(b, 30),
|
||||
"red quad decoded as r{r} g{g} b{b}"
|
||||
);
|
||||
println!("VP9_E2E_OK {w}x{h} key_bytes={}", frames[0].data.len());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Host-side file manager: enumerate drives/roots and directory contents, and
|
||||
//! the chunked read/write used by download/upload. Items are reported in the
|
||||
//! viewer's wire shape `{n:name, t:"d"|"f", s:size, m:mtime}`.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
/// Top-level roots: every present drive on Windows (`C:\`, `D:\`, …), plus the
|
||||
/// user's home, so the viewer's file panel has somewhere to start.
|
||||
pub fn roots() -> Vec<Value> {
|
||||
let mut items = Vec::new();
|
||||
#[cfg(windows)]
|
||||
{
|
||||
for c in b'A'..=b'Z' {
|
||||
let p = format!("{}:\\", c as char);
|
||||
if Path::new(&p).exists() {
|
||||
items.push(json!({ "n": p, "t": "d", "s": 0, "m": 0 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
items.push(json!({ "n": "/", "t": "d", "s": 0, "m": 0 }));
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// List a directory. Entries are sorted folders-first then by name; hidden
|
||||
/// handling is left to the OS (we surface everything `read_dir` returns).
|
||||
pub fn list_dir(path: &str) -> std::io::Result<Vec<Value>> {
|
||||
let mut items = Vec::new();
|
||||
for entry in std::fs::read_dir(path)? {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let meta = match entry.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let is_dir = meta.is_dir();
|
||||
let size = if is_dir { 0 } else { meta.len() };
|
||||
let mtime = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
items.push(json!({
|
||||
"n": name,
|
||||
"t": if is_dir { "d" } else { "f" },
|
||||
"s": size,
|
||||
"m": mtime,
|
||||
}));
|
||||
}
|
||||
items.sort_by(|a, b| {
|
||||
let ad = a.get("t").and_then(|x| x.as_str()) == Some("d");
|
||||
let bd = b.get("t").and_then(|x| x.as_str()) == Some("d");
|
||||
bd.cmp(&ad).then_with(|| {
|
||||
a.get("n")
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase()
|
||||
.cmp(&b.get("n").and_then(|x| x.as_str()).unwrap_or("").to_ascii_lowercase())
|
||||
})
|
||||
});
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Recursively walk `root` and return every FILE under it as
|
||||
/// `{ "rel": "<forward-slash relative path>", "s": <size> }`, so a viewer can
|
||||
/// recreate the tree locally and download each entry. Directories are implied
|
||||
/// by the relative paths (the viewer mkdir -p's each parent). Bounded to avoid
|
||||
/// a runaway walk: at most `MAX_ENTRIES` files and `MAX_DEPTH` deep; symlinks
|
||||
/// are not followed. Returns (entries, truncated).
|
||||
pub fn walk_dir(root: &str) -> std::io::Result<(Vec<Value>, bool)> {
|
||||
const MAX_ENTRIES: usize = 50_000;
|
||||
const MAX_DEPTH: usize = 40;
|
||||
let root_path = PathBuf::from(root);
|
||||
let mut out = Vec::new();
|
||||
let mut truncated = false;
|
||||
// Iterative DFS: (dir, depth). rel paths are built from `root`.
|
||||
let mut stack = vec![(root_path.clone(), 0usize)];
|
||||
while let Some((dir, depth)) = stack.pop() {
|
||||
if depth > MAX_DEPTH {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
let rd = match std::fs::read_dir(&dir) {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue, // unreadable dir -> skip, keep going
|
||||
};
|
||||
for entry in rd {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
// symlink_metadata: do NOT follow symlinks (avoid cycles / escapes).
|
||||
let meta = match entry.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let path = entry.path();
|
||||
if meta.file_type().is_symlink() {
|
||||
continue;
|
||||
}
|
||||
if meta.is_dir() {
|
||||
stack.push((path, depth + 1));
|
||||
} else if meta.is_file() {
|
||||
if out.len() >= MAX_ENTRIES {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
// Relative path with forward slashes (the viewer joins it under
|
||||
// the chosen local folder).
|
||||
let rel = path
|
||||
.strip_prefix(&root_path)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
out.push(json!({ "rel": rel, "s": meta.len() }));
|
||||
}
|
||||
}
|
||||
if out.len() >= MAX_ENTRIES {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| {
|
||||
a.get("rel").and_then(|x| x.as_str()).unwrap_or("")
|
||||
.cmp(b.get("rel").and_then(|x| x.as_str()).unwrap_or(""))
|
||||
});
|
||||
Ok((out, truncated))
|
||||
}
|
||||
|
||||
/// Where an in-progress upload is written before the atomic rename into place.
|
||||
pub fn part_path(final_path: &str) -> PathBuf {
|
||||
PathBuf::from(format!("{final_path}.netdesk.part"))
|
||||
}
|
||||
|
||||
/// Resolve an upload destination: a relative/empty dir drops onto the Desktop
|
||||
/// (TeamViewer-style default), mirroring the existing host.
|
||||
pub fn resolve_upload_path(path: &str) -> PathBuf {
|
||||
let p = Path::new(path);
|
||||
if p.is_absolute() {
|
||||
return p.to_path_buf();
|
||||
}
|
||||
// Relative: drop on the Desktop.
|
||||
let home = std::env::var("USERPROFILE")
|
||||
.or_else(|_| std::env::var("HOME"))
|
||||
.unwrap_or_else(|_| ".".into());
|
||||
Path::new(&home).join("Desktop").join(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn walk_dir_recurses_and_relativizes() {
|
||||
// Build a small temp tree: root/{a.txt, sub/b.txt, sub/deep/c.txt}.
|
||||
let base = std::env::temp_dir().join(format!("nd_walk_{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(base.join("sub").join("deep")).unwrap();
|
||||
std::fs::write(base.join("a.txt"), b"AA").unwrap();
|
||||
std::fs::write(base.join("sub").join("b.txt"), b"BBBB").unwrap();
|
||||
std::fs::write(base.join("sub").join("deep").join("c.txt"), b"CCCCCC").unwrap();
|
||||
|
||||
let (entries, truncated) = walk_dir(&base.to_string_lossy()).unwrap();
|
||||
assert!(!truncated);
|
||||
let rels: Vec<String> = entries
|
||||
.iter()
|
||||
.map(|e| e.get("rel").and_then(|x| x.as_str()).unwrap().to_string())
|
||||
.collect();
|
||||
assert_eq!(rels, vec!["a.txt", "sub/b.txt", "sub/deep/c.txt"]); // sorted, forward slashes
|
||||
// sizes carried through
|
||||
let size = |rel: &str| {
|
||||
entries.iter().find(|e| e.get("rel").and_then(|x| x.as_str()) == Some(rel))
|
||||
.and_then(|e| e.get("s").and_then(|x| x.as_u64())).unwrap()
|
||||
};
|
||||
assert_eq!(size("a.txt"), 2);
|
||||
assert_eq!(size("sub/b.txt"), 4);
|
||||
assert_eq!(size("sub/deep/c.txt"), 6);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
//! Input injection via SendInput — mouse move/buttons/wheel and keyboard, wire
|
||||
//! compatible with the existing NetDesk viewers (same convention the Python host
|
||||
//! uses): pointer coordinates arrive in virtual-desktop pixel space and are
|
||||
//! mapped to the 0..65535 absolute range over the whole virtual desktop; keys
|
||||
//! arrive as DOM-style codes ("KeyA", "Enter", "ArrowLeft") and are injected as
|
||||
//! scan codes so games and low-level apps see them correctly.
|
||||
|
||||
#![cfg(windows)]
|
||||
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::{
|
||||
MapVirtualKeyW, SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT,
|
||||
KEYBD_EVENT_FLAGS, KEYEVENTF_EXTENDEDKEY, KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE,
|
||||
KEYEVENTF_UNICODE, MAPVK_VK_TO_VSC,
|
||||
MOUSEINPUT, MOUSE_EVENT_FLAGS, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_HWHEEL, MOUSEEVENTF_LEFTDOWN,
|
||||
MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE,
|
||||
MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_VIRTUALDESK, MOUSEEVENTF_WHEEL,
|
||||
MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, VIRTUAL_KEY,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN,
|
||||
};
|
||||
|
||||
/// Stateful input injector. Tracks the current mouse-button bitmask so it can
|
||||
/// emit the correct press/release transitions.
|
||||
pub struct Injector {
|
||||
buttons: u32,
|
||||
}
|
||||
|
||||
impl Default for Injector {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Injector {
|
||||
pub fn new() -> Self {
|
||||
Self { buttons: 0 }
|
||||
}
|
||||
|
||||
/// Inject one pointer update: move, then any button transitions, then wheel.
|
||||
pub fn mouse(&mut self, x: i32, y: i32, buttons: u32, wheel: f32) {
|
||||
self.mouse_move(x, y);
|
||||
if buttons != self.buttons {
|
||||
self.mouse_buttons(buttons, self.buttons);
|
||||
self.buttons = buttons;
|
||||
}
|
||||
if wheel != 0.0 {
|
||||
self.wheel(wheel);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject a RELATIVE pointer update (game / pointer-lock mode): move by
|
||||
/// (dx, dy) pixels using MOUSEEVENTF_MOVE WITHOUT ABSOLUTE, then any
|
||||
/// button transitions, then wheel. Apps that read raw mouse motion (FPS
|
||||
/// games, 3D/CAD) receive the deltas directly. Subject to the system
|
||||
/// pointer-speed/accel settings, like any relative motion.
|
||||
pub fn mouse_rel(&mut self, dx: i32, dy: i32, buttons: u32, wheel: f32) {
|
||||
if dx != 0 || dy != 0 {
|
||||
unsafe { send_mouse(dx, dy, 0, MOUSEEVENTF_MOVE) };
|
||||
}
|
||||
if buttons != self.buttons {
|
||||
self.mouse_buttons(buttons, self.buttons);
|
||||
self.buttons = buttons;
|
||||
}
|
||||
if wheel != 0.0 {
|
||||
self.wheel(wheel);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject a string as literal Unicode (translate / international-keyboard
|
||||
/// mode): each UTF-16 code unit is sent via KEYEVENTF_UNICODE, so the
|
||||
/// exact character is typed regardless of the host's keyboard layout —
|
||||
/// this is how AltGr combos, dead-key compositions and non-US layouts
|
||||
/// come through correctly (the viewer's OS already resolved them to a
|
||||
/// character). No VK/scan is used, so it is layout-independent.
|
||||
pub fn text(&self, s: &str) {
|
||||
for u in s.encode_utf16() {
|
||||
unsafe {
|
||||
send_key(VIRTUAL_KEY(0), u, KEYEVENTF_UNICODE);
|
||||
send_key(VIRTUAL_KEY(0), u, KEYEVENTF_UNICODE | KEYEVENTF_KEYUP);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Release any held buttons (viewer left / session end), so nothing stays
|
||||
/// stuck down.
|
||||
pub fn release_buttons(&mut self) {
|
||||
if self.buttons != 0 {
|
||||
self.mouse_buttons(0, self.buttons);
|
||||
self.buttons = 0;
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_move(&self, x: i32, y: i32) {
|
||||
unsafe {
|
||||
let vl = GetSystemMetrics(SM_XVIRTUALSCREEN);
|
||||
let vt = GetSystemMetrics(SM_YVIRTUALSCREEN);
|
||||
let vw = GetSystemMetrics(SM_CXVIRTUALSCREEN).max(1);
|
||||
let vh = GetSystemMetrics(SM_CYVIRTUALSCREEN).max(1);
|
||||
let nx = ((x - vl) as i64 * 65535 / (vw - 1).max(1) as i64) as i32;
|
||||
let ny = ((y - vt) as i64 * 65535 / (vh - 1).max(1) as i64) as i32;
|
||||
send_mouse(
|
||||
nx,
|
||||
ny,
|
||||
0,
|
||||
MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_buttons(&self, new_b: u32, old_b: u32) {
|
||||
let mut evts: Vec<(MOUSE_EVENT_FLAGS, i32)> = Vec::new();
|
||||
for (mask, down, up) in [
|
||||
(1u32, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP),
|
||||
(2, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP),
|
||||
(4, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP),
|
||||
] {
|
||||
if new_b & mask != 0 && old_b & mask == 0 {
|
||||
evts.push((down, 0));
|
||||
}
|
||||
if new_b & mask == 0 && old_b & mask != 0 {
|
||||
evts.push((up, 0));
|
||||
}
|
||||
}
|
||||
// Side buttons: bit 8 = X1 (back), bit 16 = X2 (forward).
|
||||
for (mask, xb) in [(8u32, 0x0001i32), (16u32, 0x0002i32)] {
|
||||
if new_b & mask != 0 && old_b & mask == 0 {
|
||||
evts.push((MOUSEEVENTF_XDOWN, xb));
|
||||
}
|
||||
if new_b & mask == 0 && old_b & mask != 0 {
|
||||
evts.push((MOUSEEVENTF_XUP, xb));
|
||||
}
|
||||
}
|
||||
for (flags, data) in evts {
|
||||
unsafe { send_mouse(0, 0, (data as u32) << 16, flags) };
|
||||
}
|
||||
}
|
||||
|
||||
fn wheel(&self, rows: f32) {
|
||||
unsafe {
|
||||
send_mouse(0, 0, (rows * 120.0) as i32 as u32, MOUSEEVENTF_WHEEL);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject a key by DOM code + down/up. Unknown codes are ignored.
|
||||
pub fn key(&self, code: &str, down: bool) {
|
||||
let Some(vk) = code_to_vk(code) else {
|
||||
return;
|
||||
};
|
||||
unsafe {
|
||||
let scan = MapVirtualKeyW(vk as u32, MAPVK_VK_TO_VSC) as u16;
|
||||
let mut flags = KEYEVENTF_SCANCODE;
|
||||
if is_extended(code) {
|
||||
flags |= KEYEVENTF_EXTENDEDKEY;
|
||||
}
|
||||
if !down {
|
||||
flags |= KEYEVENTF_KEYUP;
|
||||
}
|
||||
if scan != 0 {
|
||||
send_key(VIRTUAL_KEY(0), scan, flags);
|
||||
} else {
|
||||
// No scan code (rare) — fall back to the virtual key.
|
||||
let mut f = KEYBD_EVENT_FLAGS(0);
|
||||
if !down {
|
||||
f |= KEYEVENTF_KEYUP;
|
||||
}
|
||||
send_key(VIRTUAL_KEY(vk), 0, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontal wheel (not surfaced by the core event yet, but available).
|
||||
#[allow(dead_code)]
|
||||
fn hwheel(&self, rows: f32) {
|
||||
unsafe {
|
||||
send_mouse(0, 0, (rows * 120.0) as i32 as u32, MOUSEEVENTF_HWHEEL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn send_mouse(dx: i32, dy: i32, mouse_data: u32, flags: MOUSE_EVENT_FLAGS) {
|
||||
let input = INPUT {
|
||||
r#type: INPUT_MOUSE,
|
||||
Anonymous: INPUT_0 {
|
||||
mi: MOUSEINPUT {
|
||||
dx,
|
||||
dy,
|
||||
mouseData: mouse_data,
|
||||
dwFlags: flags,
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
SendInput(&[input], std::mem::size_of::<INPUT>() as i32);
|
||||
}
|
||||
|
||||
unsafe fn send_key(vk: VIRTUAL_KEY, scan: u16, flags: KEYBD_EVENT_FLAGS) {
|
||||
let input = INPUT {
|
||||
r#type: INPUT_KEYBOARD,
|
||||
Anonymous: INPUT_0 {
|
||||
ki: KEYBDINPUT {
|
||||
wVk: vk,
|
||||
wScan: scan,
|
||||
dwFlags: flags,
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
SendInput(&[input], std::mem::size_of::<INPUT>() as i32);
|
||||
}
|
||||
|
||||
/// DOM `KeyboardEvent.code` -> Windows virtual-key. Mirrors the Python host so
|
||||
/// every existing viewer keeps working unchanged.
|
||||
fn code_to_vk(code: &str) -> Option<u16> {
|
||||
if let Some(c) = code.strip_prefix("Key") {
|
||||
let b = c.as_bytes();
|
||||
if b.len() == 1 && b[0].is_ascii_uppercase() {
|
||||
return Some(0x41 + (b[0] - b'A') as u16);
|
||||
}
|
||||
}
|
||||
if let Some(d) = code.strip_prefix("Digit") {
|
||||
let b = d.as_bytes();
|
||||
if b.len() == 1 && b[0].is_ascii_digit() {
|
||||
return Some(0x30 + (b[0] - b'0') as u16);
|
||||
}
|
||||
}
|
||||
if let Some(d) = code.strip_prefix("Numpad") {
|
||||
let b = d.as_bytes();
|
||||
if b.len() == 1 && b[0].is_ascii_digit() {
|
||||
return Some(0x60 + (b[0] - b'0') as u16);
|
||||
}
|
||||
}
|
||||
if let Some(n) = code.strip_prefix('F') {
|
||||
if let Ok(num) = n.parse::<u16>() {
|
||||
if (1..=24).contains(&num) {
|
||||
return Some(0x70 + num - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(match code {
|
||||
"Backquote" => 0xC0,
|
||||
"Minus" => 0xBD,
|
||||
"Equal" => 0xBB,
|
||||
"BracketLeft" => 0xDB,
|
||||
"BracketRight" => 0xDD,
|
||||
"Backslash" => 0xDC,
|
||||
"Semicolon" => 0xBA,
|
||||
"Quote" => 0xDE,
|
||||
"Comma" => 0xBC,
|
||||
"Period" => 0xBE,
|
||||
"Slash" => 0xBF,
|
||||
"IntlBackslash" => 0xE2,
|
||||
"CapsLock" => 0x14,
|
||||
"Space" => 0x20,
|
||||
"Enter" | "NumpadEnter" => 0x0D,
|
||||
"Backspace" => 0x08,
|
||||
"Tab" => 0x09,
|
||||
"Escape" => 0x1B,
|
||||
"Delete" => 0x2E,
|
||||
"Insert" => 0x2D,
|
||||
"Home" => 0x24,
|
||||
"End" => 0x23,
|
||||
"PageUp" => 0x21,
|
||||
"PageDown" => 0x22,
|
||||
"ArrowLeft" => 0x25,
|
||||
"ArrowUp" => 0x26,
|
||||
"ArrowRight" => 0x27,
|
||||
"ArrowDown" => 0x28,
|
||||
"ControlLeft" => 0xA2,
|
||||
"ControlRight" => 0xA3,
|
||||
"AltLeft" => 0xA4,
|
||||
"AltRight" => 0xA5,
|
||||
"ShiftLeft" => 0xA0,
|
||||
"ShiftRight" => 0xA1,
|
||||
"MetaLeft" => 0x5B,
|
||||
"MetaRight" => 0x5C,
|
||||
"ContextMenu" => 0x5D,
|
||||
"PrintScreen" => 0x2C,
|
||||
"ScrollLock" => 0x91,
|
||||
"Pause" => 0x13,
|
||||
"NumLock" => 0x90,
|
||||
"NumpadMultiply" => 0x6A,
|
||||
"NumpadAdd" => 0x6B,
|
||||
"NumpadSubtract" => 0x6D,
|
||||
"NumpadDecimal" => 0x6E,
|
||||
"NumpadDivide" => 0x6F,
|
||||
// Media / volume keys (DOM UI Events code values) -> VK_* media codes, so
|
||||
// a viewer that grabs them (system-key hook) drives the host's volume,
|
||||
// playback and browser the same as sitting at the machine.
|
||||
"AudioVolumeMute" => 0xAD,
|
||||
"AudioVolumeDown" => 0xAE,
|
||||
"AudioVolumeUp" => 0xAF,
|
||||
"MediaTrackNext" => 0xB0,
|
||||
"MediaTrackPrevious" => 0xB1,
|
||||
"MediaStop" => 0xB2,
|
||||
"MediaPlayPause" => 0xB3,
|
||||
"LaunchMail" => 0xB4,
|
||||
"MediaSelect" | "LaunchMediaPlayer" => 0xB5,
|
||||
"LaunchApp1" => 0xB6,
|
||||
"LaunchApp2" => 0xB7,
|
||||
"BrowserBack" => 0xA6,
|
||||
"BrowserForward" => 0xA7,
|
||||
"BrowserRefresh" => 0xA8,
|
||||
"BrowserStop" => 0xA9,
|
||||
"BrowserSearch" => 0xAA,
|
||||
"BrowserFavorites" => 0xAB,
|
||||
"BrowserHome" => 0xAC,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_extended(code: &str) -> bool {
|
||||
matches!(
|
||||
code,
|
||||
"ArrowRight"
|
||||
| "ArrowLeft"
|
||||
| "ArrowUp"
|
||||
| "ArrowDown"
|
||||
| "PageUp"
|
||||
| "PageDown"
|
||||
| "Home"
|
||||
| "End"
|
||||
| "Insert"
|
||||
| "Delete"
|
||||
| "NumLock"
|
||||
| "PrintScreen"
|
||||
| "Pause"
|
||||
| "ControlRight"
|
||||
| "AltRight"
|
||||
| "MetaLeft"
|
||||
| "MetaRight"
|
||||
| "ContextMenu"
|
||||
| "NumpadEnter"
|
||||
| "NumpadDivide"
|
||||
| "ScrollLock"
|
||||
// media / volume / browser keys are extended
|
||||
| "AudioVolumeMute"
|
||||
| "AudioVolumeDown"
|
||||
| "AudioVolumeUp"
|
||||
| "MediaTrackNext"
|
||||
| "MediaTrackPrevious"
|
||||
| "MediaStop"
|
||||
| "MediaPlayPause"
|
||||
| "LaunchMail"
|
||||
| "MediaSelect"
|
||||
| "LaunchMediaPlayer"
|
||||
| "LaunchApp1"
|
||||
| "LaunchApp2"
|
||||
| "BrowserBack"
|
||||
| "BrowserForward"
|
||||
| "BrowserRefresh"
|
||||
| "BrowserStop"
|
||||
| "BrowserSearch"
|
||||
| "BrowserFavorites"
|
||||
| "BrowserHome"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::code_to_vk;
|
||||
|
||||
#[test]
|
||||
fn maps_media_and_volume_keys() {
|
||||
// Volume + media-transport keys the viewer's system-key grab forwards.
|
||||
assert_eq!(code_to_vk("AudioVolumeMute"), Some(0xAD));
|
||||
assert_eq!(code_to_vk("AudioVolumeDown"), Some(0xAE));
|
||||
assert_eq!(code_to_vk("AudioVolumeUp"), Some(0xAF));
|
||||
assert_eq!(code_to_vk("MediaTrackNext"), Some(0xB0));
|
||||
assert_eq!(code_to_vk("MediaTrackPrevious"), Some(0xB1));
|
||||
assert_eq!(code_to_vk("MediaStop"), Some(0xB2));
|
||||
assert_eq!(code_to_vk("MediaPlayPause"), Some(0xB3));
|
||||
assert_eq!(code_to_vk("BrowserBack"), Some(0xA6));
|
||||
assert_eq!(code_to_vk("BrowserHome"), Some(0xAC));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_system_keys_the_hook_grabs() {
|
||||
// The WH_KEYBOARD_LL grab forwards these; the host must inject them.
|
||||
assert_eq!(code_to_vk("MetaLeft"), Some(0x5B)); // LWin
|
||||
assert_eq!(code_to_vk("MetaRight"), Some(0x5C)); // RWin
|
||||
assert_eq!(code_to_vk("PrintScreen"), Some(0x2C));
|
||||
assert_eq!(code_to_vk("Tab"), Some(0x09));
|
||||
assert_eq!(code_to_vk("Escape"), Some(0x1B));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn still_maps_ordinary_keys() {
|
||||
assert_eq!(code_to_vk("KeyA"), Some(0x41));
|
||||
assert_eq!(code_to_vk("Digit0"), Some(0x30));
|
||||
assert_eq!(code_to_vk("F5"), Some(0x74));
|
||||
assert_eq!(code_to_vk("Nonsense"), None);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod rel_mouse_tests {
|
||||
use super::*;
|
||||
use windows::Win32::Foundation::POINT;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{GetCursorPos, SetCursorPos};
|
||||
|
||||
/// Real OS effect: a relative move must shift the ACTUAL cursor in the
|
||||
/// commanded direction by a sane magnitude. Mirrors the e2e GetCursorPos
|
||||
/// proof but as a focused, networkless unit test. The exact magnitude is
|
||||
/// subject to the system pointer-speed/accel curve, so we assert direction
|
||||
/// + a generous band around the sent (40, 30) rather than an exact match.
|
||||
#[test]
|
||||
fn mouse_rel_moves_cursor_relatively() {
|
||||
unsafe {
|
||||
let vw = GetSystemMetrics(SM_CXVIRTUALSCREEN).max(2);
|
||||
let vh = GetSystemMetrics(SM_CYVIRTUALSCREEN).max(2);
|
||||
// Park at center so the delta is not clamped at a screen edge.
|
||||
let _ = SetCursorPos(vw / 2, vh / 2);
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
let mut before = POINT::default();
|
||||
let _ = GetCursorPos(&mut before);
|
||||
|
||||
let mut inj = Injector::new();
|
||||
inj.mouse_rel(40, 30, 0, 0.0);
|
||||
std::thread::sleep(std::time::Duration::from_millis(40));
|
||||
|
||||
let mut after = POINT::default();
|
||||
let _ = GetCursorPos(&mut after);
|
||||
let (ddx, ddy) = (after.x - before.x, after.y - before.y);
|
||||
println!(
|
||||
"REL_MOUSE_OK before=({},{}) after=({},{}) delta=({},{})",
|
||||
before.x, before.y, after.x, after.y, ddx, ddy
|
||||
);
|
||||
// "Enhance pointer precision" (mouse acceleration) scales the whole
|
||||
// relative vector by a system-dependent factor (seen 1.5x–3.2x on
|
||||
// this box run-to-run), so assert the DIRECTION + the 40:30 ≈ 4:3
|
||||
// ratio (preserved under uniform scaling) + sane bounds, never an
|
||||
// absolute magnitude. 3*ddx ≈ 4*ddy proves the sent vector landed.
|
||||
let ratio_ok = (3 * ddx - 4 * ddy).abs() <= (3 * ddx).max(1) / 4;
|
||||
assert!(
|
||||
ddx > 3 && ddy > 2 && ddx > ddy && ddx < 1000 && ratio_ok,
|
||||
"relative move delta ({ddx},{ddy}) must track the sent (40,30) vector \
|
||||
(right+down, ratio ~4:3), not stuck/absolute"
|
||||
);
|
||||
let _ = SetCursorPos(vw / 2, vh / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod translate_kbd_tests {
|
||||
use super::*;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use windows::Win32::Foundation::{LPARAM, LRESULT, WPARAM};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CallNextHookEx, DispatchMessageW, PeekMessageW, SetWindowsHookExW, TranslateMessage,
|
||||
UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT, MSG, PM_REMOVE, WH_KEYBOARD_LL, WM_KEYDOWN,
|
||||
};
|
||||
|
||||
// (vkCode, scanCode) captured on each keydown the LL hook sees.
|
||||
static CAP: OnceLock<Mutex<Vec<(u32, u32)>>> = OnceLock::new();
|
||||
|
||||
unsafe extern "system" fn cap_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
if code == 0 && wparam.0 as u32 == WM_KEYDOWN {
|
||||
let kb = &*(lparam.0 as *const KBDLLHOOKSTRUCT);
|
||||
if let Some(m) = CAP.get() {
|
||||
if let Ok(mut v) = m.lock() {
|
||||
v.push((kb.vkCode, kb.scanCode));
|
||||
}
|
||||
}
|
||||
}
|
||||
CallNextHookEx(HHOOK::default(), code, wparam, lparam)
|
||||
}
|
||||
|
||||
fn pump(ms: u64) {
|
||||
let end = Instant::now() + Duration::from_millis(ms);
|
||||
while Instant::now() < end {
|
||||
unsafe {
|
||||
let mut msg = MSG::default();
|
||||
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
|
||||
let _ = TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// Real OS effect, focus-INDEPENDENT: Injector::text injects via
|
||||
/// KEYEVENTF_UNICODE, which a WH_KEYBOARD_LL hook sees as VK_PACKET (0xE7)
|
||||
/// with the UTF-16 code unit in `scanCode`. So "Ae-acute" ("A" + U+00E9)
|
||||
/// must produce keydown packets 0x41 then 0xE9 — proving the exact character
|
||||
/// is injected regardless of the host keyboard layout (the point of
|
||||
/// translate mode).
|
||||
#[test]
|
||||
fn text_injects_unicode_as_vk_packet() {
|
||||
const VK_PACKET: u32 = 0xE7;
|
||||
let _ = CAP.set(Mutex::new(Vec::new()));
|
||||
let hook = unsafe { SetWindowsHookExW(WH_KEYBOARD_LL, Some(cap_proc), None, 0) }
|
||||
.expect("install LL hook");
|
||||
|
||||
Injector::new().text("A\u{00e9}");
|
||||
pump(400);
|
||||
|
||||
unsafe {
|
||||
let _ = UnhookWindowsHookEx(hook);
|
||||
}
|
||||
let got = CAP.get().unwrap().lock().unwrap().clone();
|
||||
let packets: Vec<u32> = got
|
||||
.iter()
|
||||
.filter(|(vk, _)| *vk == VK_PACKET)
|
||||
.map(|(_, sc)| *sc)
|
||||
.collect();
|
||||
println!("TRANSLATE_KBD_OK keydown unicode packets = {:04X?}", packets);
|
||||
assert_eq!(
|
||||
packets,
|
||||
vec![0x41u32, 0xE9],
|
||||
"expected Unicode packets for 'A','e-acute'"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
//! System-key grab for the viewer — a low-level keyboard hook (WH_KEYBOARD_LL)
|
||||
//! that catches the keys Windows normally eats before an app can see them: the
|
||||
//! Windows keys, PrintScreen, and the Alt+Tab / Alt+Esc / Ctrl+Esc task-switch
|
||||
//! combos. While [`set_active`]`(true)` (the viewer calls this when a session is
|
||||
//! live AND its window is focused), each grabbed key is forwarded to the host
|
||||
//! (as a DOM `code`, through the install() channel) and the LOCAL action is
|
||||
//! SUPPRESSED — so pressing Alt+Tab or the Windows key switches/opens on the
|
||||
//! REMOTE machine instead of this one (RustDesk's "transmit system keys").
|
||||
//!
|
||||
//! The hook is process-global but only acts while active, so it is inert when no
|
||||
//! session is focused. It lives in this shared crate (next to input.rs's
|
||||
//! injection) so it is unit-testable; only the viewer installs it.
|
||||
|
||||
#![cfg(windows)]
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use windows::Win32::Foundation::{LPARAM, LRESULT, WPARAM};
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_CONTROL, VK_MENU};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CallNextHookEx, SetWindowsHookExW, UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT, WH_KEYBOARD_LL,
|
||||
WM_KEYDOWN, WM_SYSKEYDOWN,
|
||||
};
|
||||
|
||||
static ACTIVE: AtomicBool = AtomicBool::new(false);
|
||||
static SINK: OnceLock<Sender<(&'static str, bool)>> = OnceLock::new();
|
||||
static RX: OnceLock<Mutex<Receiver<(&'static str, bool)>>> = OnceLock::new();
|
||||
static HOOK: AtomicIsize = AtomicIsize::new(0);
|
||||
|
||||
/// Turn grabbing on/off. The viewer sets this to `session_active && focused`
|
||||
/// every frame; when false the hook passes everything through untouched.
|
||||
pub fn set_active(on: bool) {
|
||||
ACTIVE.store(on, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether the grab is currently installed + active.
|
||||
pub fn is_active() -> bool {
|
||||
ACTIVE.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The DOM `code` the host's `code_to_vk` expects for a grabbed virtual key.
|
||||
fn system_code(vk: u16) -> Option<&'static str> {
|
||||
Some(match vk {
|
||||
0x5B => "MetaLeft", // Left Windows
|
||||
0x5C => "MetaRight", // Right Windows
|
||||
0x2C => "PrintScreen", // PrtScn
|
||||
0x09 => "Tab", // only grabbed with Alt down (Alt+Tab)
|
||||
0x1B => "Escape", // only grabbed with Alt/Ctrl down (Alt/Ctrl+Esc)
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn alt_down() -> bool {
|
||||
unsafe { (GetAsyncKeyState(VK_MENU.0 as i32) as u16 & 0x8000) != 0 }
|
||||
}
|
||||
fn ctrl_down() -> bool {
|
||||
unsafe { (GetAsyncKeyState(VK_CONTROL.0 as i32) as u16 & 0x8000) != 0 }
|
||||
}
|
||||
|
||||
/// Grab the Windows keys + PrtScn unconditionally; Tab/Esc only as a task-switch
|
||||
/// combo (so ordinary Tab/Esc still go through egui normally).
|
||||
fn should_grab(vk: u16) -> bool {
|
||||
match vk {
|
||||
0x5B | 0x5C | 0x2C => true,
|
||||
0x09 => alt_down(), // Alt+Tab
|
||||
0x1B => alt_down() || ctrl_down(), // Alt+Esc / Ctrl+Esc
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "system" fn proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
// HC_ACTION == 0: the struct is valid and we may inspect/act on it.
|
||||
if code == 0 && ACTIVE.load(Ordering::Relaxed) {
|
||||
let kb = &*(lparam.0 as *const KBDLLHOOKSTRUCT);
|
||||
let vk = kb.vkCode as u16;
|
||||
if should_grab(vk) {
|
||||
if let Some(code_str) = system_code(vk) {
|
||||
let down = matches!(wparam.0 as u32, WM_KEYDOWN | WM_SYSKEYDOWN);
|
||||
if let Some(tx) = SINK.get() {
|
||||
let _ = tx.send((code_str, down));
|
||||
}
|
||||
return LRESULT(1); // swallow it locally — it goes to the remote
|
||||
}
|
||||
}
|
||||
}
|
||||
CallNextHookEx(HHOOK::default(), code, wparam, lparam)
|
||||
}
|
||||
|
||||
/// Install the global low-level keyboard hook once (owns its own channel; drain
|
||||
/// grabbed keys with [`poll`]). Returns false if the hook could not be
|
||||
/// installed. Must be called from a thread that pumps messages (the viewer's UI
|
||||
/// thread does); idempotent.
|
||||
pub fn install() -> bool {
|
||||
if HOOK.load(Ordering::Relaxed) != 0 {
|
||||
return true; // already installed
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let _ = SINK.set(tx);
|
||||
let _ = RX.set(Mutex::new(rx));
|
||||
match unsafe { SetWindowsHookExW(WH_KEYBOARD_LL, Some(proc), None, 0) } {
|
||||
Ok(h) => {
|
||||
HOOK.store(h.0 as isize, Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("keyhook: SetWindowsHookExW failed: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the keys grabbed since the last call, as `(dom_code, down)`. The viewer
|
||||
/// calls this each frame and forwards each to the host via `Session::key`.
|
||||
pub fn poll() -> Vec<(&'static str, bool)> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(m) = RX.get() {
|
||||
if let Ok(rx) = m.lock() {
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
out.push(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Remove the hook (process exit also removes it).
|
||||
pub fn uninstall() {
|
||||
let raw = HOOK.swap(0, Ordering::Relaxed);
|
||||
if raw != 0 {
|
||||
unsafe {
|
||||
let _ = UnhookWindowsHookEx(HHOOK(raw as *mut core::ffi::c_void));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{Duration, Instant};
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::{
|
||||
SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_KEYUP,
|
||||
VIRTUAL_KEY,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
DispatchMessageW, PeekMessageW, TranslateMessage, MSG, PM_REMOVE,
|
||||
};
|
||||
|
||||
unsafe fn send_vk(vk: u16, down: bool) {
|
||||
let mut flags = KEYBD_EVENT_FLAGS(0);
|
||||
if !down {
|
||||
flags |= KEYEVENTF_KEYUP;
|
||||
}
|
||||
let input = INPUT {
|
||||
r#type: INPUT_KEYBOARD,
|
||||
Anonymous: INPUT_0 {
|
||||
ki: KEYBDINPUT {
|
||||
wVk: VIRTUAL_KEY(vk),
|
||||
wScan: 0,
|
||||
dwFlags: flags,
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
SendInput(&[input], std::mem::size_of::<INPUT>() as i32);
|
||||
}
|
||||
|
||||
fn pump(ms: u64) {
|
||||
let end = Instant::now() + Duration::from_millis(ms);
|
||||
while Instant::now() < end {
|
||||
unsafe {
|
||||
let mut msg = MSG::default();
|
||||
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
|
||||
let _ = TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
// One test (the hook + ACTIVE are process-global, so we must not run two in
|
||||
// parallel). Injected keys ARE delivered to WH_KEYBOARD_LL hooks, so we prove
|
||||
// the grab end to end: while ACTIVE, injecting the Left Windows key is
|
||||
// forwarded as "MetaLeft" down+up (and suppressed locally, returning 1);
|
||||
// while INACTIVE, nothing is grabbed (keys pass through to the app).
|
||||
#[test]
|
||||
fn grabs_system_keys_only_while_active() {
|
||||
assert!(install(), "hook should install");
|
||||
|
||||
// ACTIVE: the Windows key is grabbed + forwarded.
|
||||
set_active(true);
|
||||
unsafe {
|
||||
send_vk(0x5B, true);
|
||||
send_vk(0x5B, false);
|
||||
}
|
||||
pump(400);
|
||||
let got = poll();
|
||||
assert!(
|
||||
got.contains(&("MetaLeft", true)) && got.contains(&("MetaLeft", false)),
|
||||
"LWin should be grabbed as MetaLeft down+up while active; got {got:?}"
|
||||
);
|
||||
|
||||
// INACTIVE: the same key is passed through, grabbed nothing.
|
||||
set_active(false);
|
||||
unsafe {
|
||||
send_vk(0x5B, true);
|
||||
send_vk(0x5B, false);
|
||||
}
|
||||
pump(200);
|
||||
uninstall();
|
||||
assert!(poll().is_empty(), "nothing should be grabbed while inactive");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! netdesk-host — the native Rust host engine: DXGI screen capture + H.264
|
||||
//! encode + SendInput injection, wired to `netdesk_core::host::run_host`. This
|
||||
//! replaces the Python host so the Windows app is one unified Rust binary
|
||||
//! (host + viewer). Developed by Akronlogic.
|
||||
|
||||
#[cfg(windows)]
|
||||
pub mod capture;
|
||||
|
||||
pub mod encode;
|
||||
pub mod color;
|
||||
#[cfg(windows)]
|
||||
pub mod encode_mf;
|
||||
|
||||
#[cfg(windows)]
|
||||
pub mod input;
|
||||
|
||||
#[cfg(windows)]
|
||||
pub mod sys;
|
||||
|
||||
#[cfg(windows)]
|
||||
pub mod cursor;
|
||||
pub mod encoder;
|
||||
#[cfg(windows)]
|
||||
pub mod audio;
|
||||
pub mod files;
|
||||
#[cfg(windows)]
|
||||
pub mod qos;
|
||||
pub mod run;
|
||||
#[cfg(windows)]
|
||||
pub mod telemetry;
|
||||
#[cfg(windows)]
|
||||
pub mod service;
|
||||
pub mod wol;
|
||||
#[cfg(windows)]
|
||||
pub mod term;
|
||||
|
||||
#[cfg(windows)]
|
||||
pub mod annot;
|
||||
pub mod privacy;
|
||||
|
||||
#[cfg(windows)]
|
||||
pub mod keyhook;
|
||||
|
||||
/// Make this process per-monitor DPI aware so DXGI capture returns real
|
||||
/// physical pixels (not a virtualized, scaled desktop). Safe to call once at
|
||||
/// startup; a no-op on non-Windows.
|
||||
#[cfg(windows)]
|
||||
pub fn set_dpi_aware() {
|
||||
use windows::Win32::UI::HiDpi::{
|
||||
SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
|
||||
};
|
||||
unsafe {
|
||||
let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn set_dpi_aware() {}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Driver-free privacy mode. A fullscreen, topmost, opaque-black overlay marked
|
||||
//! `WDA_EXCLUDEFROMCAPTURE`, so:
|
||||
//! - the LOCAL monitor(s) show solid black — a bystander at the host sees
|
||||
//! nothing of what the remote operator is doing;
|
||||
//! - the host's OWN screen capture (DXGI Desktop Duplication + the GDI BitBlt
|
||||
//! fallback) EXCLUDES the overlay, so the remote operator still sees and
|
||||
//! controls the real desktop behind it.
|
||||
//! The overlay is also click-through + non-activating (WS_EX_TRANSPARENT |
|
||||
//! WS_EX_NOACTIVATE), so the operator's injected SendInput passes straight
|
||||
//! through to the real windows; blocking the LOCAL keyboard/mouse is the
|
||||
//! separate `block_input` toggle. No virtual-display driver is needed — verified
|
||||
//! by the `netdesk-privacy-probe` bin (WDA_EXCLUDEFROMCAPTURE hid a window from
|
||||
//! both DXGI and GDI capture, 197600 -> 0 magenta pixels).
|
||||
//!
|
||||
//! The overlay lives on its own thread with a message pump (a window must be
|
||||
//! serviced on its creating thread); dropping [`Privacy`] tears it down and
|
||||
//! restores the screen.
|
||||
|
||||
#![cfg(windows)]
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
use windows::core::w;
|
||||
use windows::Win32::Foundation::{COLORREF, HINSTANCE, HWND, LPARAM, LRESULT, WPARAM};
|
||||
use windows::Win32::Graphics::Gdi::CreateSolidBrush;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetSystemMetrics,
|
||||
PeekMessageW, RegisterClassW, SetLayeredWindowAttributes, SetWindowDisplayAffinity, ShowWindow,
|
||||
TranslateMessage, LWA_ALPHA, MSG, PM_REMOVE, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN,
|
||||
SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, SW_SHOWNA, WDA_EXCLUDEFROMCAPTURE, WNDCLASSW,
|
||||
WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_EX_TRANSPARENT, WS_POPUP,
|
||||
WS_VISIBLE,
|
||||
};
|
||||
|
||||
unsafe extern "system" fn wndproc(h: HWND, m: u32, w: WPARAM, l: LPARAM) -> LRESULT {
|
||||
DefWindowProcW(h, m, w, l)
|
||||
}
|
||||
|
||||
/// A live privacy-mode blackout. Drop it to restore the screen.
|
||||
pub struct Privacy {
|
||||
stop: Arc<AtomicBool>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Privacy {
|
||||
/// Raise the blackout overlay (returns once the window thread has started).
|
||||
pub fn start() -> Privacy {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let s2 = stop.clone();
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("netdesk-privacy".into())
|
||||
.spawn(move || unsafe { run(s2) })
|
||||
.ok();
|
||||
Privacy { stop, thread }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Privacy {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn run(stop: Arc<AtomicBool>) {
|
||||
let hinst = HINSTANCE::default();
|
||||
let brush = CreateSolidBrush(COLORREF(0x0000_0000)); // solid black
|
||||
let class = w!("NdPrivacyBlackout");
|
||||
let wc = WNDCLASSW {
|
||||
lpfnWndProc: Some(wndproc),
|
||||
hInstance: hinst,
|
||||
lpszClassName: class,
|
||||
hbrBackground: brush,
|
||||
..Default::default()
|
||||
};
|
||||
// 0 if the class is already registered (a prior toggle this process) — fine.
|
||||
let _ = RegisterClassW(&wc);
|
||||
|
||||
// Cover the whole virtual desktop (all monitors).
|
||||
let x = GetSystemMetrics(SM_XVIRTUALSCREEN);
|
||||
let y = GetSystemMetrics(SM_YVIRTUALSCREEN);
|
||||
let cx = GetSystemMetrics(SM_CXVIRTUALSCREEN).max(1);
|
||||
let cy = GetSystemMetrics(SM_CYVIRTUALSCREEN).max(1);
|
||||
|
||||
let hwnd = match CreateWindowExW(
|
||||
WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_NOACTIVATE,
|
||||
class,
|
||||
w!("nd-privacy"),
|
||||
WS_POPUP | WS_VISIBLE,
|
||||
x,
|
||||
y,
|
||||
cx,
|
||||
cy,
|
||||
None,
|
||||
None,
|
||||
hinst,
|
||||
None,
|
||||
) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
log::warn!("privacy: window create failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Opaque (alpha 255) so the overlay is solid black on the physical screen.
|
||||
let _ = SetLayeredWindowAttributes(hwnd, COLORREF(0), 255, LWA_ALPHA);
|
||||
// Exclude from THIS host's own capture so the remote still sees the desktop.
|
||||
if let Err(e) = SetWindowDisplayAffinity(hwnd, WDA_EXCLUDEFROMCAPTURE) {
|
||||
log::warn!("privacy: SetWindowDisplayAffinity failed: {e}");
|
||||
}
|
||||
// Show without activating (keyboard focus stays on the real foreground app,
|
||||
// so injected keystrokes still land there).
|
||||
let _ = ShowWindow(hwnd, SW_SHOWNA);
|
||||
log::info!("privacy: blackout up ({cx}x{cy} @ {x},{y}); excluded from capture, click-through");
|
||||
|
||||
let mut msg = MSG::default();
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
|
||||
let _ = TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
}
|
||||
let _ = DestroyWindow(hwnd);
|
||||
log::info!("privacy: blackout down; screen restored");
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Adaptive quality controller — the smoothness engine. A faithful adaptation
|
||||
//! of RustDesk's `src/server/video_qos.rs` (delay-threshold ABR + FPS/scale
|
||||
//! backoff with congestion confirmation and gradual recovery), distilled to a
|
||||
//! pure, deterministic state machine the host feeds with viewer telemetry.
|
||||
//!
|
||||
//! The viewer reports, ~once a second, how far behind it is — a `delay` (how
|
||||
//! long frames queue/decode in front of it) plus the transport RTT and packet
|
||||
//! loss. [`Qos::on_sample`] turns that into the next `(fps, bitrate, scale)` for
|
||||
//! the live encoder (the same knobs the manual `set.quality` lever drives). The
|
||||
//! user's manual quality is the CEILING; the controller only ever trims BELOW it
|
||||
//! under congestion and restores back toward it when the link is healthy.
|
||||
//!
|
||||
//! Priority mirrors RustDesk: **reduce bitrate first** (keep motion smooth),
|
||||
//! then frame-rate (down to a floor), then resolution scale as the last resort;
|
||||
//! recover in the reverse-ish order (frame-rate, then bitrate, then full
|
||||
//! resolution). Congestion must be CONFIRMED (several bad samples in a row)
|
||||
//! before cutting, so a single jittery reply does not thrash the stream.
|
||||
|
||||
/// A good-vs-bad network boundary: frames more than this far behind mean the
|
||||
/// link cannot keep up (RustDesk's `DELAY_THRESHOLD_150MS`).
|
||||
pub const DELAY_THRESHOLD_MS: u32 = 150;
|
||||
/// Packet loss above this also counts as congestion.
|
||||
pub const LOSS_THRESHOLD: f32 = 0.05;
|
||||
/// Consecutive bad samples required before a reduction (confirm, don't thrash).
|
||||
const BAD_CONFIRM: u8 = 3;
|
||||
/// Consecutive good samples required before a step of recovery.
|
||||
const GOOD_CONFIRM: u8 = 3;
|
||||
/// Multiplicative bitrate cut on MILD congestion (keep 80%). Severe congestion
|
||||
/// cuts harder (see [`severity_cut`]) so the stream sheds load fast when it is
|
||||
/// badly behind, then trims gently near the threshold — RustDesk's graduated
|
||||
/// backoff rather than one fixed step.
|
||||
const BITRATE_CUT: f32 = 0.8;
|
||||
|
||||
/// Bitrate retention factor for how far past the delay/loss threshold a sample
|
||||
/// is: gentle (0.8) just over the line, aggressive (0.6) when badly congested.
|
||||
fn severity_cut(delay_ms: u32, loss: f32, threshold_ms: u32) -> f32 {
|
||||
let t = threshold_ms.max(1);
|
||||
if delay_ms >= t * 3 || loss >= 0.15 {
|
||||
0.6
|
||||
} else if delay_ms >= t * 2 || loss >= 0.10 {
|
||||
0.7
|
||||
} else {
|
||||
BITRATE_CUT
|
||||
}
|
||||
}
|
||||
/// FPS step down/up.
|
||||
const FPS_STEP: u32 = 5;
|
||||
/// Scale step down/up (resolution), last resort.
|
||||
const SCALE_STEP: f32 = 0.1;
|
||||
|
||||
/// Startup frame-rate the ramp begins at (RustDesk-style slow start).
|
||||
pub const INIT_FPS: u32 = 15;
|
||||
/// How long the startup ramp takes to climb from [`INIT_FPS`] to the target.
|
||||
pub const RAMP_MS: u64 = 3000;
|
||||
|
||||
/// Startup FPS ramp: a fresh session begins at [`INIT_FPS`] and climbs linearly
|
||||
/// to the target over the first [`RAMP_MS`], so the initial keyframe + buffer
|
||||
/// fill don't arrive as a full-rate burst that stutters before the decode /
|
||||
/// jitter pipeline has warmed up. After [`RAMP_MS`] it returns the target
|
||||
/// unchanged (no limiting), and a target already at/below [`INIT_FPS`] is never
|
||||
/// slowed. Pure + deterministic; the capture loop caps its pacing fps with this.
|
||||
pub fn startup_fps(elapsed_ms: u64, target_fps: u32) -> u32 {
|
||||
let target = target_fps.max(1);
|
||||
if elapsed_ms >= RAMP_MS || target <= INIT_FPS {
|
||||
return target;
|
||||
}
|
||||
// Linear climb INIT_FPS -> target across RAMP_MS.
|
||||
let span = (target - INIT_FPS) as u64;
|
||||
let add = (span * elapsed_ms / RAMP_MS) as u32;
|
||||
(INIT_FPS + add).min(target)
|
||||
}
|
||||
|
||||
/// Static bounds + the user's manual ceiling. `ceil_*` are the manual quality
|
||||
/// (the maximum the controller will restore to).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct QosConfig {
|
||||
pub ceil_fps: u32,
|
||||
pub ceil_bitrate: u32, // kbps
|
||||
pub min_fps: u32,
|
||||
pub min_bitrate: u32, // kbps
|
||||
pub min_scale: f32,
|
||||
pub delay_threshold_ms: u32,
|
||||
}
|
||||
|
||||
impl QosConfig {
|
||||
/// Defaults around a 1080p/30fps/6Mbps session, floors that stay usable on a
|
||||
/// bad link (5fps, 400kbps, half resolution).
|
||||
pub fn new(ceil_fps: u32, ceil_bitrate: u32) -> Self {
|
||||
Self {
|
||||
ceil_fps: ceil_fps.max(1),
|
||||
ceil_bitrate: ceil_bitrate.max(1),
|
||||
min_fps: 5,
|
||||
min_bitrate: 400,
|
||||
min_scale: 0.5,
|
||||
delay_threshold_ms: DELAY_THRESHOLD_MS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the controller wants the encoder set to right now.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct QosTarget {
|
||||
pub fps: u32,
|
||||
pub bitrate: u32, // kbps
|
||||
pub scale: f32,
|
||||
}
|
||||
|
||||
/// The adaptive controller. Pure + deterministic: feed samples, read targets.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Qos {
|
||||
cfg: QosConfig,
|
||||
fps: u32,
|
||||
bitrate: u32,
|
||||
scale: f32,
|
||||
bad_run: u8,
|
||||
good_run: u8,
|
||||
}
|
||||
|
||||
impl Qos {
|
||||
/// Start at the user's ceiling (full quality) — the controller only trims
|
||||
/// below it under measured congestion.
|
||||
pub fn new(cfg: QosConfig) -> Self {
|
||||
Self {
|
||||
fps: cfg.ceil_fps,
|
||||
bitrate: cfg.ceil_bitrate,
|
||||
scale: 1.0,
|
||||
cfg,
|
||||
bad_run: 0,
|
||||
good_run: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The current target.
|
||||
pub fn target(&self) -> QosTarget {
|
||||
QosTarget {
|
||||
fps: self.fps,
|
||||
bitrate: self.bitrate,
|
||||
scale: self.scale,
|
||||
}
|
||||
}
|
||||
|
||||
/// Raise the ceiling (the user moved the manual quality lever). Current
|
||||
/// values are re-clamped under the new ceiling; a raised ceiling lets the
|
||||
/// controller recover higher.
|
||||
pub fn set_ceiling(&mut self, ceil_fps: u32, ceil_bitrate: u32) {
|
||||
self.cfg.ceil_fps = ceil_fps.max(1);
|
||||
self.cfg.ceil_bitrate = ceil_bitrate.max(1);
|
||||
self.fps = self.fps.min(self.cfg.ceil_fps);
|
||||
self.bitrate = self.bitrate.min(self.cfg.ceil_bitrate);
|
||||
}
|
||||
|
||||
/// Feed one telemetry sample (delay in ms, packet loss 0..1). Returns the
|
||||
/// new target. Congestion is confirmed over [`BAD_CONFIRM`] samples before a
|
||||
/// cut; recovery takes [`GOOD_CONFIRM`] good samples per step.
|
||||
pub fn on_sample(&mut self, delay_ms: u32, loss: f32) -> QosTarget {
|
||||
let congested = delay_ms >= self.cfg.delay_threshold_ms || loss >= LOSS_THRESHOLD;
|
||||
if congested {
|
||||
self.good_run = 0;
|
||||
self.bad_run = self.bad_run.saturating_add(1);
|
||||
if self.bad_run >= BAD_CONFIRM {
|
||||
let cut = severity_cut(delay_ms, loss, self.cfg.delay_threshold_ms);
|
||||
self.reduce(cut);
|
||||
self.bad_run = 0; // require fresh confirmation before the next cut
|
||||
}
|
||||
} else {
|
||||
self.bad_run = 0;
|
||||
self.good_run = self.good_run.saturating_add(1);
|
||||
if self.good_run >= GOOD_CONFIRM {
|
||||
self.grow();
|
||||
self.good_run = 0;
|
||||
}
|
||||
}
|
||||
self.target()
|
||||
}
|
||||
|
||||
/// Trim one step: bitrate first (keep motion), then fps, then resolution.
|
||||
/// `cut` is the bitrate retention factor for this sample's severity.
|
||||
fn reduce(&mut self, cut: f32) {
|
||||
if self.bitrate > self.cfg.min_bitrate {
|
||||
let next = (self.bitrate as f32 * cut) as u32;
|
||||
self.bitrate = next.max(self.cfg.min_bitrate);
|
||||
} else if self.fps > self.cfg.min_fps {
|
||||
self.fps = self.fps.saturating_sub(FPS_STEP).max(self.cfg.min_fps);
|
||||
} else if self.scale > self.cfg.min_scale + 1e-3 {
|
||||
self.scale = (self.scale - SCALE_STEP).max(self.cfg.min_scale);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore one step toward the ceiling: full resolution, then frame-rate,
|
||||
/// then bitrate.
|
||||
fn grow(&mut self) {
|
||||
if self.scale < 1.0 - 1e-3 {
|
||||
self.scale = (self.scale + SCALE_STEP).min(1.0);
|
||||
} else if self.fps < self.cfg.ceil_fps {
|
||||
self.fps = (self.fps + FPS_STEP).min(self.cfg.ceil_fps);
|
||||
} else if self.bitrate < self.cfg.ceil_bitrate {
|
||||
// +≤ a quarter of the ceiling per step, like RustDesk's capped ramp.
|
||||
let step = (self.cfg.ceil_bitrate / 4).max(200);
|
||||
self.bitrate = (self.bitrate + step).min(self.cfg.ceil_bitrate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn qos() -> Qos {
|
||||
Qos::new(QosConfig::new(30, 6000))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starts_at_ceiling() {
|
||||
let t = qos().target();
|
||||
assert_eq!((t.fps, t.bitrate, t.scale), (30, 6000, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn healthy_link_stays_at_ceiling() {
|
||||
let mut q = qos();
|
||||
for _ in 0..50 {
|
||||
q.on_sample(20, 0.0);
|
||||
}
|
||||
let t = q.target();
|
||||
assert_eq!((t.fps, t.bitrate, t.scale), (30, 6000, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_bad_sample_does_not_cut() {
|
||||
let mut q = qos();
|
||||
q.on_sample(300, 0.0); // one bad reply
|
||||
assert_eq!(q.target().bitrate, 6000, "must confirm congestion before cutting");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_congestion_cuts_bitrate_first() {
|
||||
let mut q = qos();
|
||||
for _ in 0..BAD_CONFIRM {
|
||||
q.on_sample(400, 0.0);
|
||||
}
|
||||
let t = q.target();
|
||||
assert!(t.bitrate < 6000, "bitrate cut first");
|
||||
assert_eq!(t.fps, 30, "fps untouched while bitrate has room");
|
||||
assert_eq!(t.scale, 1.0, "scale untouched while bitrate has room");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavy_congestion_walks_bitrate_then_fps_then_scale() {
|
||||
let mut q = qos();
|
||||
for _ in 0..400 {
|
||||
q.on_sample(500, 0.1); // relentless congestion
|
||||
}
|
||||
let t = q.target();
|
||||
assert_eq!(t.bitrate, 400, "bitrate at floor");
|
||||
assert_eq!(t.fps, 5, "fps at floor");
|
||||
assert!((t.scale - 0.5).abs() < 1e-3, "scale at floor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn severe_congestion_cuts_harder_than_mild() {
|
||||
// One confirmed cut at severe delay (>=3x threshold) must drop bitrate
|
||||
// further than one confirmed cut just over the threshold.
|
||||
let mut mild = qos();
|
||||
for _ in 0..BAD_CONFIRM {
|
||||
mild.on_sample(DELAY_THRESHOLD_MS + 5, 0.0); // ~threshold -> 0.8
|
||||
}
|
||||
let mut severe = qos();
|
||||
for _ in 0..BAD_CONFIRM {
|
||||
severe.on_sample(DELAY_THRESHOLD_MS * 4, 0.0); // deep -> 0.6
|
||||
}
|
||||
assert_eq!(mild.target().bitrate, (6000.0 * 0.8) as u32);
|
||||
assert_eq!(severe.target().bitrate, (6000.0 * 0.6) as u32);
|
||||
assert!(
|
||||
severe.target().bitrate < mild.target().bitrate,
|
||||
"severe congestion should shed more bitrate per cut"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loss_alone_triggers_congestion() {
|
||||
let mut q = qos();
|
||||
for _ in 0..BAD_CONFIRM {
|
||||
q.on_sample(10, 0.2); // low delay but 20% loss
|
||||
}
|
||||
assert!(q.target().bitrate < 6000, "packet loss counts as congestion");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovers_toward_ceiling_after_congestion() {
|
||||
let mut q = qos();
|
||||
for _ in 0..400 {
|
||||
q.on_sample(500, 0.1);
|
||||
}
|
||||
assert!(q.target().bitrate <= 400);
|
||||
// link heals
|
||||
for _ in 0..1000 {
|
||||
q.on_sample(20, 0.0);
|
||||
}
|
||||
let t = q.target();
|
||||
assert_eq!(t.fps, 30, "fps recovered to ceiling");
|
||||
assert_eq!(t.bitrate, 6000, "bitrate recovered to ceiling");
|
||||
assert!((t.scale - 1.0).abs() < 1e-3, "scale recovered to full");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_exceeds_ceiling_on_recovery() {
|
||||
let mut q = qos();
|
||||
for _ in 0..5000 {
|
||||
q.on_sample(5, 0.0);
|
||||
}
|
||||
let t = q.target();
|
||||
assert!(t.fps <= 30 && t.bitrate <= 6000 && t.scale <= 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raising_ceiling_lets_it_climb() {
|
||||
let mut q = Qos::new(QosConfig::new(15, 3000));
|
||||
for _ in 0..200 {
|
||||
q.on_sample(10, 0.0);
|
||||
}
|
||||
assert_eq!(q.target().fps, 15);
|
||||
q.set_ceiling(60, 12000);
|
||||
for _ in 0..1000 {
|
||||
q.on_sample(10, 0.0);
|
||||
}
|
||||
assert_eq!(q.target().fps, 60, "climbs to the raised ceiling");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_ramp_begins_at_init_fps() {
|
||||
assert_eq!(startup_fps(0, 60), INIT_FPS, "a fresh session starts at the ramp floor");
|
||||
assert_eq!(startup_fps(0, 30), INIT_FPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_ramp_reaches_target_by_ramp_ms() {
|
||||
assert_eq!(startup_fps(RAMP_MS, 60), 60, "full target once the ramp window elapses");
|
||||
assert_eq!(startup_fps(RAMP_MS + 1000, 60), 60, "stays at target after the ramp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_ramp_is_monotonic_nondecreasing() {
|
||||
let mut prev = 0;
|
||||
for ms in (0..=RAMP_MS).step_by(100) {
|
||||
let f = startup_fps(ms, 60);
|
||||
assert!(f >= prev, "ramp must never step backward (ms={ms})");
|
||||
assert!(f >= INIT_FPS && f <= 60, "ramp stays within [INIT_FPS, target]");
|
||||
prev = f;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_ramp_midpoint_is_between_floor_and_target() {
|
||||
let mid = startup_fps(RAMP_MS / 2, 60);
|
||||
assert!(mid > INIT_FPS && mid < 60, "half-way through, fps is between floor and target (got {mid})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_ramp_never_slows_a_low_target() {
|
||||
// A target already at/below the floor is served at full rate immediately.
|
||||
assert_eq!(startup_fps(0, 15), 15);
|
||||
assert_eq!(startup_fps(0, 10), 10);
|
||||
assert_eq!(startup_fps(500, 5), 5);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,289 @@
|
||||
//! Windows SYSTEM service for UNATTENDED access + lock/login-screen capture.
|
||||
//!
|
||||
//! The service runs as LocalSystem (session 0) and spawns the NetDesk host INTO
|
||||
//! the active console session **as SYSTEM**, with its thread desktop following
|
||||
//! the current input desktop. That lets the host capture and inject on the
|
||||
//! secure desktop (the lock screen, the Windows sign-in screen, and UAC) — so a
|
||||
//! remote viewer can sign in and unlock an unattended machine, TeamViewer-style.
|
||||
//!
|
||||
//! Opt-in and isolated: nothing here runs unless `--install-service` is executed
|
||||
//! elevated. The ordinary user-session host (Desktop autostart / admin task) is
|
||||
//! untouched, so installing or removing the service never risks the path that
|
||||
//! already works.
|
||||
//!
|
||||
//! Entry points (wired in the viewer binary's `main`):
|
||||
//! --install-service register + start the service (self-elevates via UAC)
|
||||
//! --uninstall-service stop + delete the service
|
||||
//! --service-run the SCM service entry (run by Windows, not by hand)
|
||||
//! --session-agent the per-session host the service spawns (follows the
|
||||
//! input desktop so the lock screen is captured)
|
||||
|
||||
#![cfg(windows)]
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use windows::core::{PCWSTR, PWSTR};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID};
|
||||
use windows::Win32::Security::{
|
||||
AdjustTokenPrivileges, DuplicateTokenEx, LookupPrivilegeValueW, SetTokenInformation,
|
||||
SecurityIdentification, TokenPrimary, TokenSessionId, LUID_AND_ATTRIBUTES,
|
||||
SE_PRIVILEGE_ENABLED, TOKEN_ACCESS_MASK, TOKEN_ADJUST_DEFAULT, TOKEN_ADJUST_PRIVILEGES,
|
||||
TOKEN_ADJUST_SESSIONID, TOKEN_ASSIGN_PRIMARY, TOKEN_DUPLICATE, TOKEN_PRIVILEGES, TOKEN_QUERY,
|
||||
};
|
||||
use windows::Win32::System::RemoteDesktop::WTSGetActiveConsoleSessionId;
|
||||
use windows::Win32::System::Services::{
|
||||
RegisterServiceCtrlHandlerW, SetServiceStatus, StartServiceCtrlDispatcherW,
|
||||
SERVICE_STATUS, SERVICE_STATUS_HANDLE, SERVICE_TABLE_ENTRYW, SERVICE_ACCEPT_STOP,
|
||||
SERVICE_CONTROL_SHUTDOWN, SERVICE_CONTROL_STOP, SERVICE_RUNNING, SERVICE_START_PENDING,
|
||||
SERVICE_STOP_PENDING, SERVICE_STOPPED, SERVICE_WIN32_OWN_PROCESS,
|
||||
};
|
||||
use windows::Win32::System::Threading::{
|
||||
CreateProcessAsUserW, GetCurrentProcess, OpenProcessToken, TerminateProcess,
|
||||
CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT, PROCESS_INFORMATION, STARTUPINFOW,
|
||||
};
|
||||
|
||||
const SERVICE_NAME: &str = "NetDeskHost";
|
||||
|
||||
static STOP: AtomicBool = AtomicBool::new(false);
|
||||
static mut STATUS_HANDLE: isize = 0;
|
||||
|
||||
fn wide(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// install / uninstall (shell out to sc.exe; requires elevation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Register the service (LocalSystem, auto-start, auto-restart) and start it.
|
||||
pub fn install_service(exe: &str) -> Result<()> {
|
||||
let bin = format!("\"{exe}\" --service-run");
|
||||
// Create (quirk: sc wants "key=" and the value as separate tokens).
|
||||
run_sc(&["create", SERVICE_NAME, "binPath=", &bin, "start=", "auto",
|
||||
"obj=", "LocalSystem", "DisplayName=", "NetDesk Host (unattended)"])?;
|
||||
let _ = run_sc(&["description", SERVICE_NAME,
|
||||
"NetDesk unattended host — captures the lock/sign-in screen so you can access this PC remotely."]);
|
||||
// Restart on crash so unattended access survives faults.
|
||||
let _ = run_sc(&["failure", SERVICE_NAME, "reset=", "0", "actions=",
|
||||
"restart/5000/restart/5000/restart/5000"]);
|
||||
run_sc(&["start", SERVICE_NAME])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop and delete the service.
|
||||
pub fn uninstall_service() -> Result<()> {
|
||||
let _ = run_sc(&["stop", SERVICE_NAME]);
|
||||
run_sc(&["delete", SERVICE_NAME])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_sc(args: &[&str]) -> Result<()> {
|
||||
let status = std::process::Command::new("sc")
|
||||
.args(args)
|
||||
.status()
|
||||
.map_err(|e| anyhow!("sc {args:?}: {e}"))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("sc {:?} exited with {:?}", args, status.code()))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// service entry (run by the SCM via `--service-run`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Hand control to the Service Control Manager. Blocks until the service stops.
|
||||
pub fn run_service() -> Result<()> {
|
||||
let name = wide(SERVICE_NAME);
|
||||
let table = [
|
||||
SERVICE_TABLE_ENTRYW {
|
||||
lpServiceName: PWSTR(name.as_ptr() as *mut u16),
|
||||
lpServiceProc: Some(service_main),
|
||||
},
|
||||
SERVICE_TABLE_ENTRYW {
|
||||
lpServiceName: PWSTR::null(),
|
||||
lpServiceProc: None,
|
||||
},
|
||||
];
|
||||
unsafe {
|
||||
StartServiceCtrlDispatcherW(table.as_ptr())
|
||||
.map_err(|e| anyhow!("StartServiceCtrlDispatcher: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
extern "system" fn service_main(_argc: u32, _argv: *mut PWSTR) {
|
||||
unsafe {
|
||||
let name = wide(SERVICE_NAME);
|
||||
match RegisterServiceCtrlHandlerW(PCWSTR(name.as_ptr()), Some(handler)) {
|
||||
Ok(h) => STATUS_HANDLE = h.0 as isize,
|
||||
Err(_) => return,
|
||||
}
|
||||
set_status(SERVICE_START_PENDING, 0);
|
||||
set_status(SERVICE_RUNNING, SERVICE_ACCEPT_STOP);
|
||||
// Main loop: keep a host alive in the active console session.
|
||||
let exe = std::env::current_exe()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_default();
|
||||
let mut child: Option<(HANDLE, u32)> = None;
|
||||
while !STOP.load(Ordering::Relaxed) {
|
||||
let session = WTSGetActiveConsoleSessionId();
|
||||
let need_spawn = match child {
|
||||
// 0xFFFFFFFF => no one attached to the console right now.
|
||||
_ if session == 0xFFFF_FFFF => false,
|
||||
None => true,
|
||||
Some((_, sess)) => sess != session || !process_alive(child),
|
||||
};
|
||||
if need_spawn && session != 0xFFFF_FFFF {
|
||||
if let Some((h, _)) = child.take() {
|
||||
let _ = TerminateProcess(h, 1);
|
||||
let _ = CloseHandle(h);
|
||||
}
|
||||
match spawn_host_in_session(session, &exe) {
|
||||
Ok(h) => child = Some((h, session)),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(1500));
|
||||
}
|
||||
if let Some((h, _)) = child.take() {
|
||||
let _ = TerminateProcess(h, 0);
|
||||
let _ = CloseHandle(h);
|
||||
}
|
||||
set_status(SERVICE_STOPPED, 0);
|
||||
}
|
||||
}
|
||||
|
||||
extern "system" fn handler(control: u32) {
|
||||
if control == SERVICE_CONTROL_STOP || control == SERVICE_CONTROL_SHUTDOWN {
|
||||
STOP.store(true, Ordering::Relaxed);
|
||||
unsafe { set_status(SERVICE_STOP_PENDING, 0) };
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn set_status(state: windows::Win32::System::Services::SERVICE_STATUS_CURRENT_STATE, accept: u32) {
|
||||
if STATUS_HANDLE == 0 {
|
||||
return;
|
||||
}
|
||||
let mut st = SERVICE_STATUS {
|
||||
dwServiceType: SERVICE_WIN32_OWN_PROCESS,
|
||||
dwCurrentState: state,
|
||||
dwControlsAccepted: accept,
|
||||
dwWin32ExitCode: 0,
|
||||
dwServiceSpecificExitCode: 0,
|
||||
dwCheckPoint: 0,
|
||||
dwWaitHint: 3000,
|
||||
};
|
||||
let _ = SetServiceStatus(SERVICE_STATUS_HANDLE(STATUS_HANDLE as *mut c_void), &mut st);
|
||||
}
|
||||
|
||||
fn process_alive(child: Option<(HANDLE, u32)>) -> bool {
|
||||
use windows::Win32::Foundation::{STILL_ACTIVE, WAIT_OBJECT_0};
|
||||
use windows::Win32::System::Threading::{GetExitCodeProcess, WaitForSingleObject};
|
||||
match child {
|
||||
None => false,
|
||||
Some((h, _)) => unsafe {
|
||||
// Fast path: a 0ms wait signals only if the process has exited.
|
||||
if WaitForSingleObject(h, 0) == WAIT_OBJECT_0 {
|
||||
return false;
|
||||
}
|
||||
let mut code = 0u32;
|
||||
if GetExitCodeProcess(h, &mut code).is_ok() {
|
||||
code == STILL_ACTIVE.0 as u32
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// spawn the host as SYSTEM in the active session (so it can reach the secure
|
||||
// desktop). Duplicates the service's SYSTEM token, re-targets it at the active
|
||||
// session, and CreateProcessAsUser's the host on winsta0\default.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn spawn_host_in_session(session: u32, exe: &str) -> Result<HANDLE> {
|
||||
unsafe {
|
||||
let proc = GetCurrentProcess();
|
||||
let access = TOKEN_ACCESS_MASK(
|
||||
TOKEN_DUPLICATE.0
|
||||
| TOKEN_QUERY.0
|
||||
| TOKEN_ASSIGN_PRIMARY.0
|
||||
| TOKEN_ADJUST_DEFAULT.0
|
||||
| TOKEN_ADJUST_SESSIONID.0
|
||||
| TOKEN_ADJUST_PRIVILEGES.0,
|
||||
);
|
||||
let mut tok = HANDLE::default();
|
||||
OpenProcessToken(proc, access, &mut tok).map_err(|e| anyhow!("OpenProcessToken: {e}"))?;
|
||||
enable_privilege(tok, "SeTcbPrivilege");
|
||||
|
||||
let mut dup = HANDLE::default();
|
||||
DuplicateTokenEx(
|
||||
tok,
|
||||
TOKEN_ACCESS_MASK(0x02000000), // MAXIMUM_ALLOWED
|
||||
None,
|
||||
SecurityIdentification,
|
||||
TokenPrimary,
|
||||
&mut dup,
|
||||
)
|
||||
.map_err(|e| anyhow!("DuplicateTokenEx: {e}"))?;
|
||||
|
||||
// Re-target the primary token at the active console session.
|
||||
let sess = session;
|
||||
SetTokenInformation(
|
||||
dup,
|
||||
TokenSessionId,
|
||||
&sess as *const u32 as *const c_void,
|
||||
std::mem::size_of::<u32>() as u32,
|
||||
)
|
||||
.map_err(|e| anyhow!("SetTokenInformation(session): {e}"))?;
|
||||
|
||||
let mut cmd = wide(&format!("\"{exe}\" --session-agent"));
|
||||
let mut desktop = wide("winsta0\\default");
|
||||
let mut si = STARTUPINFOW::default();
|
||||
si.cb = std::mem::size_of::<STARTUPINFOW>() as u32;
|
||||
si.lpDesktop = PWSTR(desktop.as_mut_ptr());
|
||||
let mut pi = PROCESS_INFORMATION::default();
|
||||
|
||||
CreateProcessAsUserW(
|
||||
dup,
|
||||
PCWSTR::null(),
|
||||
PWSTR(cmd.as_mut_ptr()),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT,
|
||||
None,
|
||||
PCWSTR::null(),
|
||||
&si,
|
||||
&mut pi,
|
||||
)
|
||||
.map_err(|e| anyhow!("CreateProcessAsUser: {e}"))?;
|
||||
|
||||
let _ = CloseHandle(pi.hThread);
|
||||
let _ = CloseHandle(dup);
|
||||
let _ = CloseHandle(tok);
|
||||
Ok(pi.hProcess)
|
||||
}
|
||||
}
|
||||
|
||||
fn enable_privilege(token: HANDLE, name: &str) {
|
||||
unsafe {
|
||||
let wname = wide(name);
|
||||
let mut luid = LUID::default();
|
||||
if LookupPrivilegeValueW(PCWSTR::null(), PCWSTR(wname.as_ptr()), &mut luid).is_err() {
|
||||
return;
|
||||
}
|
||||
let tp = TOKEN_PRIVILEGES {
|
||||
PrivilegeCount: 1,
|
||||
Privileges: [LUID_AND_ATTRIBUTES {
|
||||
Luid: luid,
|
||||
Attributes: SE_PRIVILEGE_ENABLED,
|
||||
}],
|
||||
};
|
||||
let _ = AdjustTokenPrivileges(token, false, Some(&tp), 0, None, None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
//! Host system controls (RustDesk/TeamViewer-grade): process list + kill,
|
||||
//! block-local-input, and wallpaper hide/restore. Native Win32 via the windows
|
||||
//! crate — no shelling out, no Python.
|
||||
|
||||
#![cfg(windows)]
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::ffi::c_void;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use windows::Win32::Foundation::CloseHandle;
|
||||
use windows::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows::Win32::System::ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS};
|
||||
use windows::Win32::System::Threading::{
|
||||
OpenProcess, TerminateProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE,
|
||||
};
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::BlockInput;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
SystemParametersInfoW, SPIF_UPDATEINIFILE, SPI_GETDESKWALLPAPER,
|
||||
SPI_SETDESKWALLPAPER,
|
||||
};
|
||||
|
||||
/// Running processes as `{name, pid, mem}` (mem in KB), heaviest first, capped
|
||||
/// at 300 — the shape the viewer's process panel expects.
|
||||
pub fn process_list() -> Vec<Value> {
|
||||
let mut items = Vec::new();
|
||||
unsafe {
|
||||
let snap = match CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return items,
|
||||
};
|
||||
let mut entry = PROCESSENTRY32W {
|
||||
dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
if Process32FirstW(snap, &mut entry).is_ok() {
|
||||
loop {
|
||||
let len = entry
|
||||
.szExeFile
|
||||
.iter()
|
||||
.position(|&c| c == 0)
|
||||
.unwrap_or(entry.szExeFile.len());
|
||||
let name = String::from_utf16_lossy(&entry.szExeFile[..len]);
|
||||
let pid = entry.th32ProcessID;
|
||||
let mem = process_mem_kb(pid);
|
||||
items.push(json!({ "name": name, "pid": pid, "mem": mem }));
|
||||
if Process32NextW(snap, &mut entry).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = CloseHandle(snap);
|
||||
}
|
||||
items.sort_by_key(|i| std::cmp::Reverse(i.get("mem").and_then(|m| m.as_u64()).unwrap_or(0)));
|
||||
items.truncate(300);
|
||||
items
|
||||
}
|
||||
|
||||
unsafe fn process_mem_kb(pid: u32) -> u64 {
|
||||
if pid == 0 {
|
||||
return 0;
|
||||
}
|
||||
let h = match OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
let mut c = PROCESS_MEMORY_COUNTERS::default();
|
||||
let r = GetProcessMemoryInfo(h, &mut c, std::mem::size_of::<PROCESS_MEMORY_COUNTERS>() as u32);
|
||||
let _ = CloseHandle(h);
|
||||
if r.is_ok() {
|
||||
(c.WorkingSetSize / 1024) as u64
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminate a process by pid. Returns (ok, message).
|
||||
pub fn process_kill(pid: u32) -> (bool, String) {
|
||||
if pid == 0 {
|
||||
return (false, "invalid pid".into());
|
||||
}
|
||||
unsafe {
|
||||
match OpenProcess(PROCESS_TERMINATE, false, pid) {
|
||||
Ok(h) => {
|
||||
let r = TerminateProcess(h, 1);
|
||||
let _ = CloseHandle(h);
|
||||
match r {
|
||||
Ok(()) => (true, format!("terminated {pid}")),
|
||||
Err(e) => (false, format!("terminate failed: {e}")),
|
||||
}
|
||||
}
|
||||
Err(e) => (false, format!("cannot open process {pid}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Block or unblock local keyboard/mouse so the person at the host cannot
|
||||
/// interfere during a control session. Returns true on success.
|
||||
pub fn set_block_input(on: bool) -> bool {
|
||||
unsafe { BlockInput(on).is_ok() }
|
||||
}
|
||||
|
||||
/// Current desktop wallpaper path (empty if none / unreadable).
|
||||
pub fn wallpaper_get() -> String {
|
||||
unsafe {
|
||||
let mut buf = [0u16; 520];
|
||||
if SystemParametersInfoW(
|
||||
SPI_GETDESKWALLPAPER,
|
||||
buf.len() as u32,
|
||||
Some(buf.as_mut_ptr() as *mut c_void),
|
||||
Default::default(),
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(0);
|
||||
String::from_utf16_lossy(&buf[..len])
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the desktop wallpaper (empty string clears it to the background colour).
|
||||
pub fn wallpaper_set(path: &str) -> bool {
|
||||
unsafe {
|
||||
let mut wide: Vec<u16> = path.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
SystemParametersInfoW(
|
||||
SPI_SETDESKWALLPAPER,
|
||||
0,
|
||||
Some(wide.as_mut_ptr() as *mut c_void),
|
||||
SPIF_UPDATEINIFILE,
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Windows process-creation flag: run without flashing a console window.
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
/// Run a shell command on the host and capture stdout+stderr (size-capped).
|
||||
/// Runs with the host process's privileges — full Administrator/SYSTEM when the
|
||||
/// host is elevated (service or run-as-admin), standard user otherwise. This is
|
||||
/// the "run CMD / PowerShell on the remote machine" feature.
|
||||
pub fn run_command(cmd: &str) -> (bool, String) {
|
||||
let out = std::process::Command::new("cmd")
|
||||
.args(["/c", cmd])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
match out {
|
||||
Ok(o) => {
|
||||
let mut s = String::from_utf8_lossy(&o.stdout).into_owned();
|
||||
let err = String::from_utf8_lossy(&o.stderr);
|
||||
if !err.trim().is_empty() {
|
||||
if !s.is_empty() {
|
||||
s.push('\n');
|
||||
}
|
||||
s.push_str(&err);
|
||||
}
|
||||
if s.len() > 60_000 {
|
||||
s.truncate(60_000);
|
||||
s.push_str("\n…[output truncated]");
|
||||
}
|
||||
if s.trim().is_empty() {
|
||||
s = format!("(exit {}, no output)", o.status.code().unwrap_or(-1));
|
||||
}
|
||||
(o.status.success(), s)
|
||||
}
|
||||
Err(e) => (false, format!("failed to launch: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_detached(program: &str, args: &[&str]) -> bool {
|
||||
std::process::Command::new(program)
|
||||
.args(args)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.spawn()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Lock the workstation (screen-lock). Works from the interactive user session.
|
||||
pub fn lock_workstation() -> bool {
|
||||
spawn_detached("rundll32.exe", &["user32.dll,LockWorkStation"])
|
||||
}
|
||||
|
||||
/// Log the interactive user off.
|
||||
pub fn logoff() -> bool {
|
||||
spawn_detached("shutdown.exe", &["/l", "/f"])
|
||||
}
|
||||
|
||||
/// Restart the machine now.
|
||||
pub fn reboot() -> bool {
|
||||
spawn_detached("shutdown.exe", &["/r", "/t", "0"])
|
||||
}
|
||||
|
||||
/// Shut the machine down now.
|
||||
pub fn shutdown() -> bool {
|
||||
spawn_detached("shutdown.exe", &["/s", "/t", "0"])
|
||||
}
|
||||
|
||||
/// Is this host process running with an elevated (Administrator) token? Reported
|
||||
/// to the viewer so the UI can show whether `run` commands have admin rights.
|
||||
pub fn is_elevated() -> bool {
|
||||
use windows::Win32::Foundation::HANDLE;
|
||||
use windows::Win32::Security::{
|
||||
GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY,
|
||||
};
|
||||
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
||||
unsafe {
|
||||
let mut token = HANDLE::default();
|
||||
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token).is_err() {
|
||||
return false;
|
||||
}
|
||||
let mut elev = TOKEN_ELEVATION::default();
|
||||
let mut ret = 0u32;
|
||||
let ok = GetTokenInformation(
|
||||
token,
|
||||
TokenElevation,
|
||||
Some(&mut elev as *mut _ as *mut c_void),
|
||||
std::mem::size_of::<TOKEN_ELEVATION>() as u32,
|
||||
&mut ret,
|
||||
)
|
||||
.is_ok();
|
||||
let _ = CloseHandle(token);
|
||||
ok && elev.TokenIsElevated != 0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod admin_tests {
|
||||
use super::*;
|
||||
|
||||
// Safe: runs `whoami` only — never fires lock/logoff/reboot/shutdown.
|
||||
#[test]
|
||||
fn run_command_captures_output() {
|
||||
let (ok, out) = run_command("whoami");
|
||||
assert!(ok, "whoami should succeed: {out}");
|
||||
assert!(!out.trim().is_empty(), "expected username output");
|
||||
let _ = is_elevated(); // must not panic
|
||||
eprintln!("whoami -> {} | elevated={}", out.trim(), is_elevated());
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a Secure Attention Sequence (Ctrl+Alt+Del) to the active session. The
|
||||
/// host must be elevated and the SoftwareSASGeneration policy must allow
|
||||
/// software SAS (set by `--install-admin-task`); fails gracefully otherwise.
|
||||
pub fn send_cad() -> bool {
|
||||
use windows::Win32::Security::Authentication::Identity::SendSAS;
|
||||
// AsUser = TRUE: generated as the interactive (elevated) user.
|
||||
unsafe { SendSAS(windows::Win32::Foundation::TRUE) };
|
||||
true
|
||||
}
|
||||
|
||||
/// Make UAC consent prompts appear on the NORMAL desktop (so a remote viewer can
|
||||
/// see and click them — "enter admin pass" over a session) instead of the
|
||||
/// isolated secure desktop. `visible=false` restores the Windows default.
|
||||
/// Writes HKLM, so it needs an elevated host; fails gracefully otherwise.
|
||||
pub fn set_uac_visible(visible: bool) -> bool {
|
||||
let val = if visible { "0" } else { "1" };
|
||||
std::process::Command::new("reg")
|
||||
.args([
|
||||
"add",
|
||||
r"HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System",
|
||||
"/v", "PromptOnSecureDesktop", "/t", "REG_DWORD", "/d", val, "/f",
|
||||
])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Approve-before-connect prompt: a topmost Yes/No box on the interactive
|
||||
/// desktop ("<peer> (<user>) wants to control this PC"). Auto-dismisses after
|
||||
/// 20s (returns false), matching the engine's own approve timeout, via
|
||||
/// user32's MessageBoxTimeoutW (exported by name; stable since XP). Returns
|
||||
/// true only on an explicit Yes.
|
||||
pub fn approve_prompt(peer: &str, user: &str) -> bool {
|
||||
#[link(name = "user32")]
|
||||
extern "system" {
|
||||
fn MessageBoxTimeoutW(
|
||||
hwnd: isize,
|
||||
lptext: *const u16,
|
||||
lpcaption: *const u16,
|
||||
utype: u32,
|
||||
wlangid: u16,
|
||||
dwmilliseconds: u32,
|
||||
) -> i32;
|
||||
}
|
||||
let from = match (peer.is_empty(), user.is_empty()) {
|
||||
(true, true) => "an unknown viewer".to_string(),
|
||||
(false, true) => peer.to_string(),
|
||||
(true, false) => user.to_string(),
|
||||
(false, false) => format!("{peer} ({user})"),
|
||||
};
|
||||
let text = format!(
|
||||
"Incoming NetDesk connection\n\nFrom: {from}\n\nThe viewer entered the correct password.\nAccept remote control of this PC?"
|
||||
);
|
||||
let wide = |s: &str| s.encode_utf16().chain(std::iter::once(0)).collect::<Vec<u16>>();
|
||||
let t = wide(&text);
|
||||
let c = wide("NetDesk — accept connection?");
|
||||
const MB_YESNO: u32 = 0x0000_0004;
|
||||
const MB_ICONQUESTION: u32 = 0x0000_0020;
|
||||
const MB_SYSTEMMODAL: u32 = 0x0000_1000; // topmost, above fullscreen apps
|
||||
const MB_SETFOREGROUND: u32 = 0x0001_0000;
|
||||
const MB_DEFBUTTON2: u32 = 0x0000_0100; // default = No (Enter won't accept)
|
||||
const IDYES: i32 = 6;
|
||||
let r = unsafe {
|
||||
MessageBoxTimeoutW(
|
||||
0,
|
||||
t.as_ptr(),
|
||||
c.as_ptr(),
|
||||
MB_YESNO | MB_ICONQUESTION | MB_SYSTEMMODAL | MB_SETFOREGROUND | MB_DEFBUTTON2,
|
||||
0,
|
||||
20_000,
|
||||
)
|
||||
};
|
||||
r == IDYES
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Lightweight host metrics (CPU %, RAM, disk, battery, uptime) for the portal's
|
||||
//! live monitoring. Windows-only; sampled every few seconds by the engine and
|
||||
//! pushed to the server as a `host.telemetry` control message.
|
||||
#![cfg(windows)]
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Keeps the previous CPU time sample so each `sample()` yields a real % over
|
||||
/// the interval since the last call.
|
||||
pub struct Telemetry {
|
||||
prev_idle: u64,
|
||||
prev_total: u64,
|
||||
}
|
||||
|
||||
impl Telemetry {
|
||||
pub fn new() -> Self {
|
||||
Self { prev_idle: 0, prev_total: 0 }
|
||||
}
|
||||
|
||||
pub fn sample(&mut self) -> Value {
|
||||
let cpu = self.cpu_percent();
|
||||
let (ram_used, ram_total) = mem();
|
||||
let (disk_used, disk_total) = disk();
|
||||
let (battery, charging) = power();
|
||||
json!({
|
||||
"t": "host.telemetry",
|
||||
"cpu": cpu,
|
||||
"ram_used": ram_used, "ram_total": ram_total,
|
||||
"disk_used": disk_used, "disk_total": disk_total,
|
||||
"battery": battery, "charging": charging,
|
||||
"uptime": uptime_secs(),
|
||||
})
|
||||
}
|
||||
|
||||
fn cpu_percent(&mut self) -> u32 {
|
||||
use windows::Win32::Foundation::FILETIME;
|
||||
use windows::Win32::System::Threading::GetSystemTimes;
|
||||
unsafe {
|
||||
let mut idle = FILETIME::default();
|
||||
let mut kern = FILETIME::default();
|
||||
let mut user = FILETIME::default();
|
||||
if GetSystemTimes(Some(&mut idle), Some(&mut kern), Some(&mut user)).is_err() {
|
||||
return 0;
|
||||
}
|
||||
let ft = |f: FILETIME| ((f.dwHighDateTime as u64) << 32) | f.dwLowDateTime as u64;
|
||||
let idle_t = ft(idle);
|
||||
// Kernel time INCLUDES idle, so kernel+user is the full busy+idle total.
|
||||
let total_t = ft(kern) + ft(user);
|
||||
let d_total = total_t.saturating_sub(self.prev_total);
|
||||
let d_idle = idle_t.saturating_sub(self.prev_idle);
|
||||
self.prev_total = total_t;
|
||||
self.prev_idle = idle_t;
|
||||
if d_total == 0 {
|
||||
return 0;
|
||||
}
|
||||
let busy = d_total.saturating_sub(d_idle);
|
||||
((busy as f64 / d_total as f64) * 100.0).round().clamp(0.0, 100.0) as u32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mem() -> (u64, u64) {
|
||||
use windows::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
|
||||
unsafe {
|
||||
let mut m = MEMORYSTATUSEX {
|
||||
dwLength: std::mem::size_of::<MEMORYSTATUSEX>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
if GlobalMemoryStatusEx(&mut m).is_ok() {
|
||||
(m.ullTotalPhys.saturating_sub(m.ullAvailPhys), m.ullTotalPhys)
|
||||
} else {
|
||||
(0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn disk() -> (u64, u64) {
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Storage::FileSystem::GetDiskFreeSpaceExW;
|
||||
unsafe {
|
||||
let path: Vec<u16> = "C:\\".encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let mut free_avail = 0u64;
|
||||
let mut total = 0u64;
|
||||
let mut total_free = 0u64;
|
||||
if GetDiskFreeSpaceExW(
|
||||
PCWSTR(path.as_ptr()),
|
||||
Some(&mut free_avail),
|
||||
Some(&mut total),
|
||||
Some(&mut total_free),
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
(total.saturating_sub(total_free), total)
|
||||
} else {
|
||||
(0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn power() -> (i32, bool) {
|
||||
use windows::Win32::System::Power::GetSystemPowerStatus;
|
||||
use windows::Win32::System::Power::SYSTEM_POWER_STATUS;
|
||||
unsafe {
|
||||
let mut s = SYSTEM_POWER_STATUS::default();
|
||||
if GetSystemPowerStatus(&mut s).is_ok() {
|
||||
let pct = if s.BatteryLifePercent == 255 { -1 } else { s.BatteryLifePercent as i32 };
|
||||
let charging = s.ACLineStatus == 1; // 1 = plugged in
|
||||
(pct, charging)
|
||||
} else {
|
||||
(-1, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn uptime_secs() -> u64 {
|
||||
use windows::Win32::System::SystemInformation::GetTickCount64;
|
||||
unsafe { GetTickCount64() / 1000 }
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! Interactive terminal via Windows ConPTY (pseudo-console) — a real shell the
|
||||
//! viewer drives, not the one-shot `run` command. Mirrors RustDesk 1.5's
|
||||
//! terminal: `term.open` spawns cmd.exe attached to a pseudo-console, its output
|
||||
//! streams back as `term.data`, the viewer's keystrokes are written to its
|
||||
//! input, `term.resize` resizes it, and `term.close` / shell exit tears it down.
|
||||
|
||||
#![cfg(windows)]
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use windows::core::PWSTR;
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
use windows::Win32::Storage::FileSystem::{ReadFile, WriteFile};
|
||||
use windows::Win32::System::Console::{
|
||||
ClosePseudoConsole, CreatePseudoConsole, ResizePseudoConsole, COORD, HPCON,
|
||||
};
|
||||
use windows::Win32::System::Pipes::CreatePipe;
|
||||
use windows::Win32::System::Threading::{
|
||||
CreateProcessW, DeleteProcThreadAttributeList, InitializeProcThreadAttributeList,
|
||||
TerminateProcess, UpdateProcThreadAttribute, EXTENDED_STARTUPINFO_PRESENT, PROCESS_INFORMATION,
|
||||
STARTUPINFOEXW, STARTUPINFOW,
|
||||
};
|
||||
|
||||
// PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = 0x00020016 (not a named const in the
|
||||
// windows crate's Threading module); value from the Windows SDK.
|
||||
const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE: usize = 0x0002_0016;
|
||||
|
||||
/// A running ConPTY terminal: write to its input, resize, or kill it. The
|
||||
/// output is pumped by a reader thread (see [`Terminal::spawn`]).
|
||||
pub struct Terminal {
|
||||
hpc: HPCON,
|
||||
input_write: HANDLE,
|
||||
proc: HANDLE,
|
||||
thread: HANDLE,
|
||||
stop: Arc<AtomicBool>,
|
||||
attr_list: Vec<u8>, // keep the proc-thread attribute buffer alive til drop
|
||||
}
|
||||
|
||||
// The handles are owned by this struct + used from the engine thread; the reader
|
||||
// thread only touches a cloned output handle. Mark Send so run.rs can hold it in
|
||||
// its HashMap across .await points (the engine loop is single-threaded per host).
|
||||
unsafe impl Send for Terminal {}
|
||||
|
||||
impl Terminal {
|
||||
/// Spawn `cmd.exe` on a fresh pseudo-console sized `cols`x`rows`. `on_output`
|
||||
/// is called (on a reader thread) with each chunk of raw terminal bytes
|
||||
/// (VT-encoded); `on_exit` is called once when the reader sees EOF. Returns
|
||||
/// the live terminal handle.
|
||||
pub fn spawn(
|
||||
cols: u32,
|
||||
rows: u32,
|
||||
on_output: impl Fn(Vec<u8>) + Send + 'static,
|
||||
on_exit: impl FnOnce() + Send + 'static,
|
||||
) -> std::io::Result<Terminal> {
|
||||
unsafe {
|
||||
// Two pipes: viewer->pty input, pty->viewer output.
|
||||
let mut in_r = HANDLE::default();
|
||||
let mut in_w = HANDLE::default();
|
||||
let mut out_r = HANDLE::default();
|
||||
let mut out_w = HANDLE::default();
|
||||
CreatePipe(&mut in_r, &mut in_w, None, 0)
|
||||
.map_err(|e| std::io::Error::other(format!("CreatePipe(in): {e}")))?;
|
||||
CreatePipe(&mut out_r, &mut out_w, None, 0)
|
||||
.map_err(|e| std::io::Error::other(format!("CreatePipe(out): {e}")))?;
|
||||
|
||||
let size = COORD {
|
||||
X: cols.clamp(1, 1000) as i16,
|
||||
Y: rows.clamp(1, 1000) as i16,
|
||||
};
|
||||
let hpc: HPCON = CreatePseudoConsole(size, in_r, out_w, 0)
|
||||
.map_err(|e| std::io::Error::other(format!("CreatePseudoConsole: {e}")))?;
|
||||
// The pseudo-console now owns these ends; we keep in_w + out_r.
|
||||
let _ = CloseHandle(in_r);
|
||||
let _ = CloseHandle(out_w);
|
||||
|
||||
// Build STARTUPINFOEX with the PSEUDOCONSOLE attribute.
|
||||
let mut attr_size: usize = 0;
|
||||
let _ = InitializeProcThreadAttributeList(
|
||||
windows::Win32::System::Threading::LPPROC_THREAD_ATTRIBUTE_LIST(std::ptr::null_mut()),
|
||||
1,
|
||||
0,
|
||||
&mut attr_size,
|
||||
);
|
||||
let mut attr_list = vec![0u8; attr_size];
|
||||
let attr =
|
||||
windows::Win32::System::Threading::LPPROC_THREAD_ATTRIBUTE_LIST(attr_list.as_mut_ptr() as *mut c_void);
|
||||
InitializeProcThreadAttributeList(attr, 1, 0, &mut attr_size)
|
||||
.map_err(|e| std::io::Error::other(format!("InitAttrList: {e}")))?;
|
||||
UpdateProcThreadAttribute(
|
||||
attr,
|
||||
0,
|
||||
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
|
||||
// Per the Win32 ConPTY sample (EchoCon), lpValue is the HPCON
|
||||
// handle value passed as the pointer, cbValue = sizeof(HPCON).
|
||||
Some(hpc.0 as *const c_void),
|
||||
std::mem::size_of::<HPCON>(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.map_err(|e| std::io::Error::other(format!("UpdateAttr: {e}")))?;
|
||||
|
||||
let mut si = STARTUPINFOEXW {
|
||||
StartupInfo: STARTUPINFOW {
|
||||
cb: std::mem::size_of::<STARTUPINFOEXW>() as u32,
|
||||
..Default::default()
|
||||
},
|
||||
lpAttributeList: attr,
|
||||
};
|
||||
let mut pi = PROCESS_INFORMATION::default();
|
||||
// Mutable, NUL-terminated command line for CreateProcessW.
|
||||
let mut cmd: Vec<u16> = "cmd.exe".encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let created = CreateProcessW(
|
||||
None,
|
||||
PWSTR(cmd.as_mut_ptr()),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
EXTENDED_STARTUPINFO_PRESENT,
|
||||
None,
|
||||
None,
|
||||
&si.StartupInfo,
|
||||
&mut pi,
|
||||
);
|
||||
// DeleteProcThreadAttributeList uses lpAttributeList; keep attr_list alive.
|
||||
let _ = &mut si;
|
||||
if let Err(e) = created {
|
||||
DeleteProcThreadAttributeList(attr);
|
||||
let _ = CloseHandle(in_w);
|
||||
let _ = CloseHandle(out_r);
|
||||
ClosePseudoConsole(hpc);
|
||||
return Err(std::io::Error::other(format!("CreateProcessW(cmd): {e}")));
|
||||
}
|
||||
|
||||
// Reader thread: pump ConPTY output -> on_output, EOF -> on_exit.
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let r_stop = stop.clone();
|
||||
let out_r_raw = out_r.0 as isize;
|
||||
std::thread::Builder::new()
|
||||
.name("netdesk-term-read".into())
|
||||
.spawn(move || {
|
||||
let out_r = HANDLE(out_r_raw as *mut c_void);
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
if r_stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let mut read = 0u32;
|
||||
let ok = unsafe { ReadFile(out_r, Some(&mut buf), Some(&mut read), None) };
|
||||
if ok.is_err() || read == 0 {
|
||||
break; // pipe closed / shell exited
|
||||
}
|
||||
on_output(buf[..read as usize].to_vec());
|
||||
}
|
||||
unsafe {
|
||||
let _ = CloseHandle(out_r);
|
||||
}
|
||||
on_exit();
|
||||
})
|
||||
.ok();
|
||||
|
||||
Ok(Terminal {
|
||||
hpc,
|
||||
input_write: in_w,
|
||||
proc: pi.hProcess,
|
||||
thread: pi.hThread,
|
||||
stop,
|
||||
attr_list,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Write viewer keystrokes / command bytes to the shell.
|
||||
pub fn write_input(&self, data: &[u8]) {
|
||||
unsafe {
|
||||
let mut written = 0u32;
|
||||
let _ = WriteFile(self.input_write, Some(data), Some(&mut written), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize the pseudo-console.
|
||||
pub fn resize(&self, cols: u32, rows: u32) {
|
||||
unsafe {
|
||||
let _ = ResizePseudoConsole(
|
||||
self.hpc,
|
||||
COORD {
|
||||
X: cols.clamp(1, 1000) as i16,
|
||||
Y: rows.clamp(1, 1000) as i16,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Terminal {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
unsafe {
|
||||
// Closing the PTY + input signals the shell to exit; then reap it.
|
||||
ClosePseudoConsole(self.hpc);
|
||||
let _ = CloseHandle(self.input_write);
|
||||
let _ = TerminateProcess(self.proc, 0);
|
||||
let _ = CloseHandle(self.proc);
|
||||
let _ = CloseHandle(self.thread);
|
||||
}
|
||||
let _ = &self.attr_list; // kept alive until here
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Wake-on-LAN magic-packet sender. An ONLINE NetDesk host can wake another PC
|
||||
//! on its LAN on command (sys action `wol`), so a remote user whose own PC is
|
||||
//! asleep can wake it through any online peer on the same network — the server
|
||||
//! routes the `wol` command to a same-LAN peer, which calls [`send_wol`].
|
||||
//!
|
||||
//! Driverless + dependency-free: a standard WoL magic packet is 6×0xFF followed
|
||||
//! by the target MAC repeated 16 times (102 bytes), UDP-broadcast to the LAN.
|
||||
//! Developed by Akronlogic.
|
||||
|
||||
use std::net::UdpSocket;
|
||||
|
||||
/// Parse "AA:BB:CC:DD:EE:FF" or "AA-BB-..." into 6 bytes.
|
||||
pub fn parse_mac(s: &str) -> Option<[u8; 6]> {
|
||||
let parts: Vec<&str> = s.split(|c| c == ':' || c == '-').collect();
|
||||
if parts.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
let mut mac = [0u8; 6];
|
||||
for (i, p) in parts.iter().enumerate() {
|
||||
mac[i] = u8::from_str_radix(p.trim(), 16).ok()?;
|
||||
}
|
||||
Some(mac)
|
||||
}
|
||||
|
||||
/// Build the 102-byte WoL magic packet: 6×0xFF, then the MAC ×16.
|
||||
pub fn magic_packet(mac: &[u8; 6]) -> [u8; 102] {
|
||||
let mut pkt = [0u8; 102];
|
||||
for b in pkt.iter_mut().take(6) {
|
||||
*b = 0xFF;
|
||||
}
|
||||
for i in 0..16 {
|
||||
pkt[6 + i * 6..6 + i * 6 + 6].copy_from_slice(mac);
|
||||
}
|
||||
pkt
|
||||
}
|
||||
|
||||
/// Broadcast the magic packet for `mac_str` to the LAN. Sends to the limited
|
||||
/// broadcast on the two conventional WoL ports (9 and 7). Returns how many of
|
||||
/// the sends succeeded (0 = total failure).
|
||||
pub fn send_wol(mac_str: &str) -> anyhow::Result<usize> {
|
||||
let mac = parse_mac(mac_str).ok_or_else(|| anyhow::anyhow!("bad MAC: {mac_str}"))?;
|
||||
let pkt = magic_packet(&mac);
|
||||
let sock = UdpSocket::bind("0.0.0.0:0")?;
|
||||
sock.set_broadcast(true)?;
|
||||
let mut sent = 0usize;
|
||||
for addr in ["255.255.255.255:9", "255.255.255.255:7"] {
|
||||
if sock.send_to(&pkt, addr).is_ok() {
|
||||
sent += 1;
|
||||
}
|
||||
}
|
||||
if sent == 0 {
|
||||
anyhow::bail!("no broadcast send succeeded");
|
||||
}
|
||||
Ok(sent)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_and_builds_magic_packet() {
|
||||
assert_eq!(parse_mac("01:02:03:04:05:06").unwrap(), [1, 2, 3, 4, 5, 6]);
|
||||
assert_eq!(parse_mac("AA-BB-CC-DD-EE-FF").unwrap(), [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
|
||||
assert!(parse_mac("nope").is_none());
|
||||
let p = magic_packet(&[1, 2, 3, 4, 5, 6]);
|
||||
assert_eq!(p.len(), 102);
|
||||
assert_eq!(&p[0..6], &[0xFF; 6]);
|
||||
assert_eq!(&p[6..12], &[1, 2, 3, 4, 5, 6]);
|
||||
assert_eq!(&p[96..102], &[1, 2, 3, 4, 5, 6]); // 16th repeat
|
||||
}
|
||||
|
||||
// Prove the packet is actually built + transmitted correctly by sending it
|
||||
// to a bound loopback receiver and reading the 102 bytes back.
|
||||
#[test]
|
||||
fn sends_magic_packet_over_udp_loopback() {
|
||||
let rx = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let port = rx.local_addr().unwrap().port();
|
||||
let mac = parse_mac("aa:bb:cc:dd:ee:ff").unwrap();
|
||||
let pkt = magic_packet(&mac);
|
||||
let tx = UdpSocket::bind("0.0.0.0:0").unwrap();
|
||||
tx.send_to(&pkt, ("127.0.0.1", port)).unwrap();
|
||||
rx.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap();
|
||||
let mut buf = [0u8; 256];
|
||||
let (n, _) = rx.recv_from(&mut buf).unwrap();
|
||||
assert_eq!(n, 102);
|
||||
assert_eq!(&buf[0..6], &[0xFF; 6]);
|
||||
assert_eq!(&buf[6..12], &mac);
|
||||
assert_eq!(&buf[96..102], &mac);
|
||||
println!("WOL_SEND_OK 102-byte magic packet transmitted + verified over UDP");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "netdesk-opus"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
description = "Opus (libopus) encode/decode for NetDesk audio. Developed by Akronlogic."
|
||||
links = "opus"
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
bindgen = "=0.65.1"
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Link the vendored static libopus (UCRT MinGW ABI) + generate FFI bindings
|
||||
//! from the vendored opus headers via bindgen (libclang). Same vendoring
|
||||
//! approach as netdesk-vpx: a UCRT-ABI libopus.a so there is no vcpkg/MSYS2 or
|
||||
//! runtime DLL dependency, and no fragile build-from-source (audiopus_sys's
|
||||
//! build script mangles extended-length paths on this toolchain).
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
|
||||
let tp = manifest.join("..").join("third_party").join("ucrt64");
|
||||
let lib = tp.join("lib");
|
||||
let inc = tp.join("include");
|
||||
|
||||
println!("cargo:rustc-link-search=native={}", lib.display());
|
||||
println!("cargo:rustc-link-lib=static=opus");
|
||||
println!("cargo:rerun-if-changed=wrapper.h");
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-changed={}", lib.join("libopus.a").display());
|
||||
|
||||
if std::env::var("LIBCLANG_PATH").is_err() {
|
||||
for p in ["C:/Program Files/LLVM/bin", "C:/Program Files (x86)/LLVM/bin"] {
|
||||
if PathBuf::from(p).join("libclang.dll").exists() {
|
||||
std::env::set_var("LIBCLANG_PATH", p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OpusEncoder / OpusDecoder are opaque (used only through pointers), so
|
||||
// bindgen's default opaque representation is exactly right here — unlike
|
||||
// libvpx's config structs, we never set their fields.
|
||||
let regex = "^(opus|Opus|OPUS).*";
|
||||
let bindings = bindgen::Builder::default()
|
||||
.header("wrapper.h")
|
||||
.clang_arg(format!("-I{}", inc.display()))
|
||||
.allowlist_type(regex)
|
||||
.allowlist_var(regex)
|
||||
.allowlist_function(regex)
|
||||
.rustified_enum(regex)
|
||||
.trust_clang_mangling(false)
|
||||
.layout_tests(false)
|
||||
.generate_comments(false)
|
||||
.generate()
|
||||
.expect("bindgen libopus");
|
||||
bindings
|
||||
.write_to_file(PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("opus_ffi.rs"))
|
||||
.expect("write opus_ffi.rs");
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Opus audio encode/decode over the vendored static libopus (UCRT MinGW).
|
||||
//!
|
||||
//! The raw FFI is generated by bindgen from the vendored headers (build.rs).
|
||||
//! `OpusEncoder`/`OpusDecoder` are opaque (used only through pointers), so
|
||||
//! bindgen's default handling is correct — no hand-written structs needed,
|
||||
//! unlike netdesk-vpx's config structs.
|
||||
//!
|
||||
//! NetDesk audio is 48 kHz stereo, one 20 ms frame (960 samples/channel) per
|
||||
//! Opus packet, RESTRICTED_LOWDELAY — matching what the web viewer sends/decodes
|
||||
//! and what the host system-audio capture produces.
|
||||
#![allow(non_upper_case_globals)]
|
||||
#![allow(non_camel_case_types)]
|
||||
#![allow(non_snake_case)]
|
||||
#![allow(dead_code)]
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
pub mod ffi {
|
||||
#![allow(
|
||||
non_camel_case_types,
|
||||
non_snake_case,
|
||||
non_upper_case_globals,
|
||||
dead_code,
|
||||
improper_ctypes
|
||||
)]
|
||||
include!(concat!(env!("OUT_DIR"), "/opus_ffi.rs"));
|
||||
}
|
||||
|
||||
use std::ffi::CStr;
|
||||
use std::os::raw::c_int;
|
||||
|
||||
/// 48 kHz — the Opus "fullband" rate NetDesk uses end to end.
|
||||
pub const SAMPLE_RATE: u32 = 48_000;
|
||||
/// 20 ms frame at 48 kHz = 960 samples per channel.
|
||||
pub const FRAME_SIZE: usize = 960;
|
||||
/// Opus can emit up to 120 ms per packet = 5760 samples/channel; size any decode
|
||||
/// scratch for the max so a decode can never overflow.
|
||||
pub const MAX_FRAME_SIZE: usize = 5760;
|
||||
|
||||
/// libopus version string, e.g. "libopus 1.5.2".
|
||||
pub fn version() -> String {
|
||||
unsafe {
|
||||
let p = ffi::opus_get_version_string();
|
||||
if p.is_null() {
|
||||
return String::new();
|
||||
}
|
||||
CStr::from_ptr(p).to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Opus encoder for system audio: RESTRICTED_LOWDELAY (lowest algorithmic delay,
|
||||
/// which is what a screen-share wants). Input PCM is interleaved i16.
|
||||
pub struct Encoder {
|
||||
enc: *mut ffi::OpusEncoder,
|
||||
channels: usize,
|
||||
}
|
||||
|
||||
impl Encoder {
|
||||
pub fn new(sample_rate: u32, channels: u32) -> Result<Self> {
|
||||
let mut err: c_int = 0;
|
||||
let enc = unsafe {
|
||||
ffi::opus_encoder_create(
|
||||
sample_rate as i32,
|
||||
channels as i32,
|
||||
ffi::OPUS_APPLICATION_RESTRICTED_LOWDELAY as i32,
|
||||
&mut err,
|
||||
)
|
||||
};
|
||||
if enc.is_null() || err != 0 {
|
||||
bail!("opus_encoder_create failed: {err}");
|
||||
}
|
||||
// Quality tuning for crystal-clear system/mic audio (defaults are
|
||||
// conservative). 128 kbps VBR, max complexity, music-optimised — the
|
||||
// audio stream is a tiny fraction of the video bitrate, so this costs
|
||||
// almost nothing on the wire but is near-transparent for music/voice.
|
||||
unsafe {
|
||||
ffi::opus_encoder_ctl(enc, ffi::OPUS_SET_BITRATE_REQUEST as c_int, 128_000 as c_int);
|
||||
ffi::opus_encoder_ctl(enc, ffi::OPUS_SET_VBR_REQUEST as c_int, 1 as c_int);
|
||||
ffi::opus_encoder_ctl(enc, ffi::OPUS_SET_COMPLEXITY_REQUEST as c_int, 10 as c_int);
|
||||
ffi::opus_encoder_ctl(
|
||||
enc,
|
||||
ffi::OPUS_SET_SIGNAL_REQUEST as c_int,
|
||||
ffi::OPUS_SIGNAL_MUSIC as c_int,
|
||||
);
|
||||
}
|
||||
Ok(Self {
|
||||
enc,
|
||||
channels: channels as usize,
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode one frame of interleaved i16 PCM into a single Opus packet.
|
||||
/// `frame_size` is samples PER CHANNEL (e.g. [`FRAME_SIZE`]).
|
||||
pub fn encode(&mut self, pcm: &[i16], frame_size: usize) -> Result<Vec<u8>> {
|
||||
if pcm.len() < frame_size * self.channels {
|
||||
bail!(
|
||||
"pcm too short: {} < {}",
|
||||
pcm.len(),
|
||||
frame_size * self.channels
|
||||
);
|
||||
}
|
||||
let mut out = vec![0u8; 4000]; // max sensible Opus packet
|
||||
let n = unsafe {
|
||||
ffi::opus_encode(
|
||||
self.enc,
|
||||
pcm.as_ptr(),
|
||||
frame_size as i32,
|
||||
out.as_mut_ptr(),
|
||||
out.len() as i32,
|
||||
)
|
||||
};
|
||||
if n < 0 {
|
||||
bail!("opus_encode failed: {n}");
|
||||
}
|
||||
out.truncate(n as usize);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Encoder {
|
||||
fn drop(&mut self) {
|
||||
unsafe { ffi::opus_encoder_destroy(self.enc) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Opus decoder. Output PCM is interleaved i16.
|
||||
pub struct Decoder {
|
||||
dec: *mut ffi::OpusDecoder,
|
||||
channels: usize,
|
||||
}
|
||||
|
||||
impl Decoder {
|
||||
pub fn new(sample_rate: u32, channels: u32) -> Result<Self> {
|
||||
let mut err: c_int = 0;
|
||||
let dec =
|
||||
unsafe { ffi::opus_decoder_create(sample_rate as i32, channels as i32, &mut err) };
|
||||
if dec.is_null() || err != 0 {
|
||||
bail!("opus_decoder_create failed: {err}");
|
||||
}
|
||||
Ok(Self {
|
||||
dec,
|
||||
channels: channels as usize,
|
||||
})
|
||||
}
|
||||
|
||||
/// Decode one Opus packet into interleaved i16 PCM. Returns
|
||||
/// `samples_per_channel * channels` samples.
|
||||
pub fn decode(&mut self, data: &[u8]) -> Result<Vec<i16>> {
|
||||
let mut pcm = vec![0i16; MAX_FRAME_SIZE * self.channels];
|
||||
let n = unsafe {
|
||||
ffi::opus_decode(
|
||||
self.dec,
|
||||
data.as_ptr(),
|
||||
data.len() as i32,
|
||||
pcm.as_mut_ptr(),
|
||||
MAX_FRAME_SIZE as i32,
|
||||
0,
|
||||
)
|
||||
};
|
||||
if n < 0 {
|
||||
bail!("opus_decode failed: {n}");
|
||||
}
|
||||
pcm.truncate(n as usize * self.channels);
|
||||
Ok(pcm)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Decoder {
|
||||
fn drop(&mut self) {
|
||||
unsafe { ffi::opus_decoder_destroy(self.dec) };
|
||||
}
|
||||
}
|
||||
|
||||
// Opus contexts hold raw pointers; they are not shared (no Sync) but a single
|
||||
// owner may move them between threads (the host capture thread owns the Encoder,
|
||||
// the viewer audio thread owns the Decoder).
|
||||
unsafe impl Send for Encoder {}
|
||||
unsafe impl Send for Decoder {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn links_libopus() {
|
||||
let v = version();
|
||||
assert!(v.to_lowercase().contains("opus"), "opus version: {v}");
|
||||
println!("OPUS_LINK_OK {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opus_roundtrip_48k_stereo() {
|
||||
let mut enc = Encoder::new(SAMPLE_RATE, 2).unwrap();
|
||||
let mut dec = Decoder::new(SAMPLE_RATE, 2).unwrap();
|
||||
|
||||
// 5 frames of a full-scale 440 Hz stereo sine.
|
||||
let mut max_abs = 0i32;
|
||||
let mut total_out = 0usize;
|
||||
let mut packet_bytes = 0usize;
|
||||
for frame in 0..5 {
|
||||
let mut pcm = vec![0i16; FRAME_SIZE * 2];
|
||||
for i in 0..FRAME_SIZE {
|
||||
let t = (frame * FRAME_SIZE + i) as f32 / SAMPLE_RATE as f32;
|
||||
let s = (2.0 * std::f32::consts::PI * 440.0 * t).sin();
|
||||
let v = (s * 30000.0) as i16;
|
||||
pcm[i * 2] = v;
|
||||
pcm[i * 2 + 1] = v;
|
||||
}
|
||||
let pkt = enc.encode(&pcm, FRAME_SIZE).unwrap();
|
||||
assert!(!pkt.is_empty(), "empty opus packet");
|
||||
packet_bytes += pkt.len();
|
||||
let out = dec.decode(&pkt).unwrap();
|
||||
assert_eq!(out.len(), FRAME_SIZE * 2, "decoded frame size (stereo)");
|
||||
total_out += out.len();
|
||||
for &s in &out {
|
||||
max_abs = max_abs.max((s as i32).abs());
|
||||
}
|
||||
}
|
||||
assert_eq!(total_out, 5 * FRAME_SIZE * 2);
|
||||
// After the encoder's lookahead delay, the sine energy must come through
|
||||
// (proves the codec actually ran, not just returned silence).
|
||||
assert!(max_abs > 1000, "decoded audio is silent (max_abs={max_abs})");
|
||||
println!("OPUS_ROUNDTRIP_OK 48k/stereo 5x20ms total_pkt_bytes={packet_bytes} peak={max_abs}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
#include <opus/opus.h>
|
||||
@@ -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 <THUMB>`.
|
||||
|
||||
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 <cert-in-store>` 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 <APPLE_TEAM_ID>
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
@@ -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/<abi>/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/<abi>/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 = '<your-strong-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<LibName>.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 <path> / `$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 <JniLibs>/<abi>/<SoName> directly (that IS the "copy").
|
||||
# cargo ndk -t <abi>... -o <jniLibs> --platform <api> build --release --package <RustPackage> --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/<abi>/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 = '<your-strong-password>'
|
||||
... -GenerateKeystore
|
||||
|
||||
or pass -KeystorePassword '<your-strong-password>'.
|
||||
'@
|
||||
}
|
||||
$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
|
||||
@@ -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-<Name>\<Name>-server=<Server>[,port=<Port>][,key=<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."
|
||||
@@ -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 <THUMB>
|
||||
#>
|
||||
[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))
|
||||
@@ -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<LIB_NAME>.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 <org>.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
|
||||
=== NetDesk iOS build (Akronlogic) ===
|
||||
root : $ROOT
|
||||
crate : $CORE_CRATE (lib$LIB_NAME.a)
|
||||
targets: $DEVICE_TARGET + $SIM_TARGET
|
||||
flutter: $FLUTTER org: $FLUTTER_ORG ios-min: $IOS_MIN
|
||||
ipa : $([ "$SKIP_IPA" = 1 ] && echo "SKIPPED (--skip-ipa)" || echo "method=$EXPORT_METHOD team=${TEAM_ID:-<unset>}")
|
||||
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" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key><string>$EXPORT_METHOD</string>
|
||||
<key>teamID</key><string>$TEAM_ID</string>
|
||||
<key>signingStyle</key><string>automatic</string>
|
||||
<key>uploadSymbols</key><true/>
|
||||
<key>stripSwiftSymbols</key><true/>
|
||||
<key>destination</key><string>export</string>
|
||||
</dict>
|
||||
</plist>
|
||||
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 <KEY_ID> --apiIssuer <ISSUER_ID>"
|
||||
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
|
||||
@@ -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-<ver>-x86_64.AppImage) -- single-file, run anywhere,
|
||||
# libav* bundled via linuxdeploy; no install needed.
|
||||
# * .deb (netdesk_<ver>_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 <triple>
|
||||
#
|
||||
# (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 <https://akronlogic.com>"
|
||||
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" <<CONTROL
|
||||
Package: $PKG_NAME
|
||||
Version: $VERSION
|
||||
Section: net
|
||||
Priority: optional
|
||||
Architecture: $DEB_ARCH
|
||||
Maintainer: $MAINTAINER
|
||||
Installed-Size: $INSTALLED_KB
|
||||
Depends: $DEPENDS
|
||||
Recommends: intel-media-va-driver | i965-va-driver | mesa-va-drivers
|
||||
Suggests: vainfo
|
||||
Homepage: $HOMEPAGE
|
||||
Description: NetDesk remote desktop viewer by Akronlogic
|
||||
NetDesk is a secure, self-hosted remote desktop client. The native viewer
|
||||
renders the remote screen with GPU-filtered scaling over a TeamViewer/
|
||||
RustDesk-style toolbar (eframe + egui).
|
||||
.
|
||||
H.264 video is hardware-decoded with VAAPI through FFmpeg/libavcodec when a
|
||||
GPU decoder is available, and falls back to an openh264 software decoder
|
||||
otherwise, so it also works on headless / GPU-less machines.
|
||||
CONTROL
|
||||
|
||||
# maintainer scripts: refresh the desktop + icon caches ------------------
|
||||
cat > "$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
|
||||
@@ -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 <name> a keychain profile created once with:
|
||||
# xcrun notarytool store-credentials <name> --apple-id <e> --team-id <t> --password <app-pw>
|
||||
# --apple-id <email> --team-id <TEAMID> + env NETDESK_NOTARY_PASSWORD=<app-specific-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" <<PLIST_EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key> <string>$APP_NAME</string>
|
||||
<key>CFBundleDisplayName</key> <string>$APP_NAME</string>
|
||||
<key>CFBundleIdentifier</key> <string>$BUNDLE_ID</string>
|
||||
<key>CFBundleExecutable</key> <string>$APP_NAME</string>
|
||||
<key>CFBundleIconFile</key> <string>$APP_NAME</string>
|
||||
<key>CFBundleVersion</key> <string>$VERSION</string>
|
||||
<key>CFBundleShortVersionString</key> <string>$VERSION</string>
|
||||
<key>CFBundlePackageType</key> <string>APPL</string>
|
||||
<key>CFBundleSignature</key> <string>????</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key> <string>6.0</string>
|
||||
<key>LSMinimumSystemVersion</key> <string>$MIN_MACOS</string>
|
||||
<key>LSApplicationCategoryType</key> <string>$CATEGORY</string>
|
||||
<key>NSHighResolutionCapable</key> <true/>
|
||||
<key>NSHumanReadableCopyright</key> <string>$COPYRIGHT</string>
|
||||
<key>NSSupportsAutomaticGraphicsSwitching</key> <true/>
|
||||
</dict>
|
||||
</plist>
|
||||
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 <name>, or"
|
||||
warn " --apple-id <e> --team-id <t> with env NETDESK_NOTARY_PASSWORD=<app-pw>."
|
||||
warn " Create a profile once with: xcrun notarytool store-credentials <name> --apple-id <e> --team-id <t> --password <app-pw>"
|
||||
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
|
||||
@@ -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=..."
|
||||
@@ -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 <path> 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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -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
|
||||
@@ -0,0 +1,81 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
NetDesk by Akronlogic — WiX v4 MSI for enterprise / unattended deployment.
|
||||
|
||||
Installs the portable payload (NetDesk.exe + the 3 MinGW runtime DLLs) to
|
||||
%ProgramFiles%\NetDesk, then runs `NetDesk.exe --config ...` as a deferred
|
||||
custom action to write config.json non-interactively from the deployment
|
||||
properties, and registers the unattended host task.
|
||||
|
||||
Build (see build-msi.ps1 — needs the WiX toolset, which needs the .NET SDK):
|
||||
dotnet tool install --global wix
|
||||
wix build packaging\netdesk.wxs -d Payload="packaging\dist\NetDesk-Portable" -arch x64 -o packaging\dist\NetDesk.msi
|
||||
|
||||
Deploy (GPO / SCCM / Intune, fully silent):
|
||||
msiexec /i NetDesk.msi /qn SERVER="wss://relay.corp/ws" REGKEY="CORP1" PASSWORD="Sup3rSecret!" HOSTID="123456789"
|
||||
|
||||
NOTE: authored on a box that has only the .NET runtime (no SDK, no WiX), so this
|
||||
source has NOT been compiled here. The unattended `--config` custom action it
|
||||
drives IS proven (viewer config_cli_tests + a live `NetDesk.exe --config` run).
|
||||
Verify this .wxs with `wix build` on a box with the WiX toolset before shipping.
|
||||
-->
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
|
||||
<Package Name="NetDesk"
|
||||
Manufacturer="Akronlogic"
|
||||
Version="1.5.0"
|
||||
UpgradeCode="6f9a1d2e-3b4c-4d5e-8f10-ac11de550001"
|
||||
Scope="perMachine">
|
||||
|
||||
<MajorUpgrade DowngradeErrorMessage="A newer version of NetDesk is already installed." />
|
||||
<MediaTemplate EmbedCab="yes" />
|
||||
|
||||
<!-- Unattended-deployment properties (passed on the msiexec command line).
|
||||
PASSWORD is hidden so it never lands in the verbose MSI log. -->
|
||||
<Property Id="SERVER" Secure="yes" />
|
||||
<Property Id="REGKEY" Secure="yes" />
|
||||
<Property Id="PASSWORD" Secure="yes" Hidden="yes" />
|
||||
<Property Id="HOSTID" Secure="yes" />
|
||||
|
||||
<StandardDirectory Id="ProgramFiles64Folder">
|
||||
<Directory Id="INSTALLFOLDER" Name="NetDesk">
|
||||
<Component Id="cmpNetDeskExe" Bitness="always64">
|
||||
<File Id="NetDeskExe" Source="$(Payload)\NetDesk.exe" KeyPath="yes" />
|
||||
</Component>
|
||||
<Component Id="cmpGcc" Bitness="always64">
|
||||
<File Source="$(Payload)\libgcc_s_seh-1.dll" KeyPath="yes" />
|
||||
</Component>
|
||||
<Component Id="cmpStdcpp" Bitness="always64">
|
||||
<File Source="$(Payload)\libstdc++-6.dll" KeyPath="yes" />
|
||||
</Component>
|
||||
<Component Id="cmpPthread" Bitness="always64">
|
||||
<File Source="$(Payload)\libwinpthread-1.dll" KeyPath="yes" />
|
||||
</Component>
|
||||
<Component Id="cmpReadme" Bitness="always64">
|
||||
<File Source="$(Payload)\README.txt" KeyPath="yes" />
|
||||
</Component>
|
||||
</Directory>
|
||||
</StandardDirectory>
|
||||
|
||||
<Feature Id="Main" Title="NetDesk" Level="1">
|
||||
<ComponentRef Id="cmpNetDeskExe" />
|
||||
<ComponentRef Id="cmpGcc" />
|
||||
<ComponentRef Id="cmpStdcpp" />
|
||||
<ComponentRef Id="cmpPthread" />
|
||||
<ComponentRef Id="cmpReadme" />
|
||||
</Feature>
|
||||
|
||||
<!-- After the files are laid down, write config.json from the deployment
|
||||
properties (only when a PASSWORD was supplied), then register the host
|
||||
task so it runs unattended. Both run the just-installed NetDesk.exe. -->
|
||||
<CustomAction Id="WriteConfig" FileRef="NetDeskExe" Execute="deferred"
|
||||
Impersonate="no" Return="ignore"
|
||||
ExeCommand="--config --server "[SERVER]" --reg-key "[REGKEY]" --password "[PASSWORD]" --id "[HOSTID]"" />
|
||||
<CustomAction Id="InstallTask" FileRef="NetDeskExe" Execute="deferred"
|
||||
Impersonate="no" Return="ignore" ExeCommand="--install-service" />
|
||||
|
||||
<InstallExecuteSequence>
|
||||
<Custom Action="WriteConfig" After="InstallFiles" Condition="NOT Installed AND PASSWORD" />
|
||||
<Custom Action="InstallTask" After="WriteConfig" Condition="NOT Installed" />
|
||||
</InstallExecuteSequence>
|
||||
</Package>
|
||||
</Wix>
|
||||
+1175
File diff suppressed because it is too large.
Load diff
Loaded 100 of 227 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user