feat: M6 web client — view + control + audio in the dashboard

Adds a TypeScript SPA embedded in hbbs that lets a logged-in admin click
"Connect (web client)" on a Devices row and remote-control the peer from
the browser, no desktop client install required. View, mouse/keyboard
control, and host-audio playback all work end-to-end.

Architecture
------------
Pure browser app, no server-side WS proxy:

  Browser  ──ws://hbbs:21118── rendezvous (PunchHole + RequestRelay)
       │
       └──ws://hbbr:21119──── relay (paired by uuid)
                                  │
                                  └── peer (RustDesk desktop, any platform)

Same wire path the desktop client takes via `Client::request_relay` and
`Client::create_relay`. Browser-relay-only — no NAT punching, so we send
nat_type=SYMMETRIC in PunchHoleRequest to make the peer skip the direct
attempt and go straight to relay (initiate=true with a host-generated uuid
that we then use for our own relay leg).

The 5 wire steps:
  1. PunchHoleRequest → harvests signed peer Ed25519 sign-pk + relay host
     + the peer's session uuid from RelayResponse
  2. Verify the signed pk against /admin/connect's `id_ed25519.pub`
  3. Open WS to hbbr:21119, send RequestRelay with that uuid
  4. Read peer's Message{signed_id}, verify with peer_sign_pk, extract
     Curve25519 box pk; box-seal a fresh secretbox key under it; send
     Message{public_key} unencrypted
  5. Secretbox-encrypted stream from here. Hash → LoginRequest →
     LoginResponse with PeerInfo. Mode = Legacy (Translate silently drops
     ControlKey/Unicode payloads on the host side).

New Rust surface
----------------
- `admin_ui/connect.html` — SPA shell with `{{CUSTOM_CONFIG}}` placeholder
- `src/api/admin/pages/connect.rs` — gates on AuthedUser, injects per-request
  config (rendezvous host, relay host, server pubkey, peer_id, admin name)
  into the `<script id="custom-config">` tag, serves bundle.{js,css} via
  include_bytes!
- 3 routes added: GET /admin/connect/:peer_id and the two assets
- Devices dropdown gains a sky-blue "Connect (web client)" link that opens
  in a new tab

New TypeScript SPA (`web_client/`)
----------------------------------
Stack: pure DOM/TS, no React/Vue. Bundled by esbuild → `dist/bundle.{js,css}`
which is committed (cargo build needs no Node toolchain).

  src/main.ts                 boot + password retry loop + receive dispatch
  src/crypto.ts               tweetnacl wrapper (sign_open, box, secretbox)
                              + @noble/hashes/sha2 (works on plain http;
                              SubtleCrypto requires a secure context)
  src/proto/generated.{js,d.ts}  pbjs static-module from
                              libs/hbb_common/protos/{rendezvous,message}.proto
  src/transport/rendezvous.ts WS to hbbs; PunchHole + RequestRelay
  src/transport/relay.ts      WS to hbbr; duplex frame transport
  src/transport/session.ts    secure-handshake state machine + Hash/Login
                              + 8-byte LE secretbox sequence counter
                              (PRE-increment, send/recv independent —
                              matches libs/hbb_common/src/tcp.rs:317-320)
                              + preloginExtras stash for AudioFormat that
                              arrives before LoginResponse
  src/decode/video.ts         WebCodecs VideoDecoder (vp09.00.10.08 today;
                              h264/h265/av1/vp8 codec strings ready for M6f)
  src/decode/audio.ts         WebCodecs AudioDecoder (opus) → AudioContext;
                              detects f32 vs f32-planar AudioData layout
                              and deinterleaves when needed; gap-less
                              scheduling via a sliding playhead
  src/ui/canvas.ts            <canvas> with object-fit: contain letterbox;
                              auto-resizes on resolution change; FPS counter
  src/input/mouse.ts          MouseEvent → MouseEvent proto. Mask layout:
                              (button << 3) | type (0=move,1=down,2=up,
                              3=wheel). Letterbox-aware viewport→peer
                              coord mapping. Right-click suppresses the
                              browser context menu; left-click does NOT
                              preventDefault (would block focus)
  src/input/keyboard.ts       Window-level keydown/keyup → KeyEvent proto
                              in Legacy mode. Special keys → ControlKey
                              enum; printable → unicode codepoint (down
                              only, host's process_unicode does a single
                              key_click). Browser shortcuts allowlisted
                              (Cmd-T/N/W/R, Tab) so the user keeps tab
                              control. Ctrl+Alt+Del HUD button (host-side
                              `send_sas` is `#[cfg(windows)]`; no-op on
                              Mac/Linux hosts but present for parity)

Bundle size: 529 KB raw / ~74 KB gzipped. Tree-shaken protobufjs +
tweetnacl + @noble/hashes only.

Deployment notes
----------------
- WebCodecs and SubtleCrypto are gated to "secure context" origins —
  HTTPS, or http://localhost. Plain http://lan-ip won't work. Open via
  http://localhost during dev, or terminate TLS in front of hbbs (Caddy
  / nginx / Traefik) for production access.
- `--relay-servers <host>` on hbbs must point at a host where TCP/WS
  21119 is reachable from end-user browsers.

Wire-format gotchas this commit nails (each one was a session of bisecting)
--------------------------------------------------------------------------
- Hash.salt / Hash.challenge are proto `string` fields used as raw UTF-8
  bytes in the SHA-256 chain. NOT base64-decoded. `pwd_hash =
  SHA256(pwd_text || salt_utf8)`, `resp = SHA256(pwd_hash || challenge_utf8)`.
- Translate keyboard mode silently drops Unicode + ControlKey payloads on
  the host (input_service.rs:2022 has `// Do not handle unicode for now.`).
  Only Seq + Chr work in Translate. Use Legacy (mode=0) for everything.
- Browser is forced to relay path by sending nat_type=SYMMETRIC. The peer
  generates its OWN uuid in handle_punch_hole's symmetric branch; use that
  uuid (carried back in RelayResponse) for the relay leg, not a fresh one.
- Misc{audio_format} fires from the host's audio_service first-snapshot
  BETWEEN add_connection and login_response, so it lands on the wire
  before our session.recv() loop is set up. Session.open() captures
  pre-login messages into preloginExtras for the caller to replay.
- protobufjs static-module sets unpopulated oneof fields to JS `null`,
  not `undefined`. A `if (msg.cursor_id !== undefined)` cursor branch
  swallowed every other message type including Misc; switched to loose
  `!= null` comparison.
- WebCodecs AudioDecoder for opus emits `f32` (interleaved) AudioData —
  must deinterleave into separate AudioBuffer channels before playback.
- VideoDecoder/AudioDecoder/SubtleCrypto are SecureContext-only; need
  http://localhost or https:// on the *page origin*, not the WS targets.
- libsodium-wrappers ESM ships a broken relative import (`./libsodium.mjs`
  in a sibling package); switched to tweetnacl which has no such problem.
- WebCrypto's SubtleCrypto.digest() doesn't accept SharedArrayBuffer-backed
  Uint8Arrays in newer TS lib types; doesn't matter — we use @noble/hashes
  for sha256 anyway since Subtle is secure-context-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 13:55:40 +02:00
parent 8b0219a877
commit d07e98e607
28 changed files with 56983 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>RustDesk &mdash; Connect</title>
<link rel="stylesheet" href="/admin/connect/assets/bundle.css" />
<!--
The Rust handler at src/api/admin/pages/connect.rs replaces
{{CUSTOM_CONFIG}} with a JSON object the SPA reads on boot. Same
pattern as the deploy page and rustdesk.com/web/.
-->
<script id="custom-config" type="application/json">{{CUSTOM_CONFIG}}</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/admin/connect/assets/bundle.js"></script>
</body>
</html>
+15
View File
@@ -117,6 +117,21 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
"/admin/pages/deploy/generate",
post(pages::deploy::generate),
)
// Web client (M6) — full-page SPA, NOT an HTMX fragment. Mounted
// outside /admin/pages/ because it's a standalone document the
// operator opens in a new tab from the Devices action menu.
.route(
"/admin/connect/:peer_id",
get(pages::connect::index),
)
.route(
"/admin/connect/assets/bundle.js",
get(pages::connect::bundle_js),
)
.route(
"/admin/connect/assets/bundle.css",
get(pages::connect::bundle_css),
)
.route("/admin/pages/devices", get(pages::devices::index))
.route("/admin/pages/groups", get(pages::groups::index))
.route("/admin/pages/strategies", get(pages::strategies::index))
+136
View File
@@ -0,0 +1,136 @@
//! `/admin/connect/:peer_id` — serves the embedded web client SPA.
//!
//! Architecture: the SPA at web_client/src/main.ts opens WebSockets directly
//! to the existing rendezvous (hbbs:21118) and relay (hbbr:21119) endpoints
//! and speaks the same protocol the desktop client speaks. The role of this
//! handler is to (a) gate access via the AuthedUser cookie middleware,
//! (b) inject per-request config (rendezvous host, relay host, server pubkey,
//! peer id, admin name) into the SPA, and (c) serve the bundled JS/CSS via
//! `include_bytes!` so the binary is self-contained.
//!
//! Same `{{CUSTOM_CONFIG}}` template substitution pattern as deploy.rs.
use super::shared::{html_escape, require_admin};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use axum::extract::Path;
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use serde_json::json;
const CONNECT_HTML: &str = include_str!("../../../../admin_ui/connect.html");
const BUNDLE_JS: &[u8] = include_bytes!("../../../../web_client/dist/bundle.js");
const BUNDLE_CSS: &[u8] = include_bytes!("../../../../web_client/dist/bundle.css");
/// `GET /admin/connect/:peer_id` — render the SPA shell with config injected.
pub async fn index(
admin: AuthedUser,
headers: HeaderMap,
Path(peer_id): Path<String>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
// Derive default rendezvous/relay hosts from the request Host header so
// operators don't need to configure separately for the common case where
// hbbs and hbbr live on the same machine the browser is currently talking
// to. Same approach as the deploy page.
let host = headers
.get(header::HOST)
.and_then(|v| v.to_str().ok())
.map(host_only)
.unwrap_or("")
.to_string();
let pubkey = read_pubkey();
let api_server = format!(
"{}://{}",
if is_https(&headers) { "https" } else { "http" },
headers
.get(header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
);
let cfg = json!({
"api_server": api_server,
"rendezvous_server": host,
"relay_server": host,
"key": pubkey,
"peer_id": peer_id,
"admin_name": admin.name.clone(),
});
let cfg_str = cfg.to_string();
// The placeholder is inside <script id="custom-config" type="application/json">
// so the JSON content is parsed verbatim by JSON.parse — no further escaping
// beyond ensuring no literal "</script>" appears (which a JSON serializer
// never produces) and HTML-escaping any peer_id we substitute elsewhere.
let html = CONNECT_HTML.replace("{{CUSTOM_CONFIG}}", &cfg_str);
// Defensive: if a peer_id ever ends up reflected outside the JSON tag
// (the template doesn't currently do this, but future edits might),
// having html_escape called as part of the page-build flow is a habit
// worth preserving.
let _ = html_escape;
Ok(Html(html))
}
/// `GET /admin/connect/assets/bundle.js` — serve the SPA bundle.
pub async fn bundle_js() -> Response {
asset_response(BUNDLE_JS, "application/javascript; charset=utf-8")
}
/// `GET /admin/connect/assets/bundle.css` — serve the SPA stylesheet.
pub async fn bundle_css() -> Response {
asset_response(BUNDLE_CSS, "text/css; charset=utf-8")
}
fn asset_response(body: &'static [u8], content_type: &'static str) -> Response {
let mut resp = (StatusCode::OK, body).into_response();
let headers = resp.headers_mut();
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
// Bundles are content-addressed by SHA in name? Not yet — until we add
// hashed filenames, force fresh fetches so admin upgrades pick up new JS.
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("no-cache"),
);
resp
}
// ---------- helpers ----------
/// Read the server's Ed25519 public key from `id_ed25519.pub` in CWD —
/// same path `common::gen_sk` writes it to and what the deploy page reads.
fn read_pubkey() -> String {
std::fs::read_to_string("id_ed25519.pub")
.ok()
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
/// Strip `:port` (and IPv6 brackets) from a Host-header value. Borrowed
/// from the deploy page; kept inline here rather than promoting to shared
/// to avoid a cross-module dep on a one-liner.
fn host_only(s: &str) -> &str {
if let Some(rest) = s.strip_prefix('[') {
if let Some(end) = rest.find(']') {
return &rest[..end];
}
}
s.rsplit_once(':').map(|(h, _)| h).unwrap_or(s)
}
/// Heuristic: were we reached via HTTPS? The presence of any
/// `X-Forwarded-Proto: https` from a reverse proxy is the standard signal.
/// Falls back to false; the SPA only uses this to construct the displayed
/// API URL, the actual WebSockets pick `ws://` vs `wss://` based on the
/// page's own protocol.
fn is_https(headers: &HeaderMap) -> bool {
headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.map(|s| s.eq_ignore_ascii_case("https"))
.unwrap_or(false)
}
+5
View File
@@ -169,6 +169,11 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow) {
<details class="text-right relative">
<summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary>
<div class="absolute right-2 mt-1 z-10 w-56 bg-slate-900 border border-slate-700 rounded shadow-lg p-2 space-y-1 text-left">
<a class="block w-full text-left px-2 py-1 text-xs text-sky-300 hover:bg-sky-900/40 rounded"
href="/admin/connect/{id}" target="_blank" rel="noopener">
Connect (web client)
</a>
<hr class="border-slate-700 my-1" />
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/devices/{id}/disconnect"
hx-target="#devices-region" hx-swap="innerHTML"
+1
View File
@@ -3,6 +3,7 @@
pub mod address_books;
pub mod audit;
pub mod connect;
pub mod deploy;
pub mod devices;
pub mod groups;
+7
View File
@@ -0,0 +1,7 @@
node_modules/
*.log
.DS_Store
# dist/ is INTENTIONALLY committed — see web_client/README.md.
# The Rust hbbs binary serves dist/bundle.js via include_bytes!, so
# committing it lets `cargo build` work without a Node toolchain.
+55
View File
@@ -0,0 +1,55 @@
# RustDesk web client
Browser-based RustDesk client embedded in `rustdesk-server`. Surfaced from the
admin dashboard as a "Connect" button on the Devices page.
## Architecture (one-liner)
Plain TypeScript SPA → talks WebSocket directly to `hbbs:21118` (rendezvous)
and `hbbr:21119` (relay) → `protobufjs` for wire format → `libsodium-wrappers`
for crypto → WebCodecs for video/audio decode → `<canvas>` for display.
No frameworks. ~1 MB minified.
## Building
```sh
./build.sh # bundles to dist/bundle.{js,css}
git add dist/
```
`dist/` is committed so `cargo build -p hbbs` doesn't need Node. Anyone
touching code under `src/` should re-run `./build.sh` and commit the new
`dist/` files in the same commit.
## Regenerating proto bindings
Rare — only when `libs/hbb_common` bumps and proto fields change:
```sh
npm run protogen
./build.sh
git add src/proto/generated.* dist/
```
## Layout
```
src/
main.ts boot: read #custom-config, init transport
crypto.ts libsodium wrapper
proto/ generated protobufjs static modules (committed)
transport/ rendezvous WS, relay WS, secure handshake state machine
decode/ video (WebCodecs VideoDecoder), audio (AudioDecoder)
input/ mouse/keyboard capture → protobuf MouseEvent/KeyEvent
ui/ canvas + toolbar + style.css
audit.ts POST /api/audit/conn with admin cookie
dist/
bundle.js + .css committed esbuild output
```
## Wire-protocol references
- `/Users/sn0/Desktop/rustdesk-server/libs/hbb_common/protos/{rendezvous,message}.proto`
- `/Users/sn0/Desktop/rustdesk/src/client.rs` — desktop-client connect/secure/login state machine
- `/Users/sn0/Desktop/rustdesk-server/libs/hbb_common/src/tcp.rs:296-344` — secretbox nonce derivation (8-byte LE counter, separate per direction)
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Build the web client bundle.
#
# Outputs: dist/bundle.js, dist/bundle.js.map, dist/bundle.css
#
# Re-run after editing anything under src/. Commit dist/* alongside source so
# `cargo build` doesn't need a Node toolchain.
#
# To regenerate the protobuf bindings (rare — when libs/hbb_common bumps):
# npm run protogen && npm run build
set -euo pipefail
cd "$(dirname "$0")"
if [ ! -d node_modules ]; then
echo "Installing npm dependencies..."
npm install
fi
mkdir -p dist
echo "Bundling JS..."
npm run --silent build:js
echo "Copying CSS..."
npm run --silent build:css
echo "Done. Bundle:"
ls -lh dist/bundle.js dist/bundle.css 2>/dev/null || true
+154
View File
@@ -0,0 +1,154 @@
/* RustDesk web client — minimal, dark theme to match the admin dashboard. */
html, body {
margin: 0;
padding: 0;
height: 100%;
background: #0f172a;
color: #e2e8f0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
#root {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.placeholder {
background: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
padding: 32px 40px;
max-width: 540px;
text-align: center;
}
.placeholder h1 {
margin: 0 0 16px;
font-size: 20px;
font-weight: 600;
}
.placeholder p {
margin: 8px 0;
font-size: 14px;
color: #cbd5e1;
}
.placeholder code {
background: #0f172a;
padding: 2px 6px;
border-radius: 3px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
color: #7dd3fc;
}
.muted { color: #64748b !important; font-size: 12px !important; }
.pw-form {
display: flex;
gap: 8px;
margin-top: 16px;
align-items: stretch;
}
.pw-form input[type="password"] {
flex: 1;
background: #0f172a;
border: 1px solid #334155;
color: #e2e8f0;
padding: 8px 10px;
border-radius: 4px;
font-size: 14px;
font-family: inherit;
}
.pw-form input[type="password"]:focus {
outline: none;
border-color: #38bdf8;
}
.pw-form button {
background: #0284c7;
border: 0;
color: #f0f9ff;
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.pw-form button:hover { background: #0369a1; }
.error-inline {
background: rgba(220, 38, 38, 0.15);
border: 1px solid rgba(220, 38, 38, 0.4);
color: #fca5a5;
padding: 8px 10px;
border-radius: 4px;
font-size: 13px;
margin-top: 12px;
}
/* ------- Live session ------- */
.session {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
background: #000;
}
.rd-canvas {
flex: 1;
width: 100%;
height: 100%;
/* Letterbox: keep aspect ratio while fitting the browser viewport. */
object-fit: contain;
display: block;
background: #000;
}
.hud {
position: fixed;
top: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.55);
color: #cbd5e1;
font-size: 11px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
padding: 4px 8px;
border-radius: 4px;
z-index: 10;
display: flex;
align-items: center;
gap: 8px;
}
.hud-fps {
pointer-events: none;
}
.hud-btn {
background: #334155;
border: 0;
color: #e2e8f0;
padding: 3px 8px;
border-radius: 3px;
font-size: 11px;
font-family: inherit;
cursor: pointer;
}
.hud-btn:hover { background: #475569; }
.error {
background: rgba(220, 38, 38, 0.15);
border: 1px solid rgba(220, 38, 38, 0.4);
border-radius: 8px;
padding: 24px 32px;
color: #fca5a5;
max-width: 640px;
}
.error h1 { margin: 0 0 12px; font-size: 18px; }
.error pre { white-space: pre-wrap; font-size: 13px; }
+10
View File
File diff suppressed because one or more lines are too long
+7
View File
File diff suppressed because one or more lines are too long
+1376
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "rustdesk-web-client",
"version": "0.1.0",
"private": true,
"description": "Browser-based RustDesk client embedded in rustdesk-server admin dashboard.",
"type": "module",
"scripts": {
"build": "./build.sh",
"build:js": "esbuild src/main.ts --bundle --minify --format=esm --outfile=dist/bundle.js --target=es2022 --sourcemap=external",
"build:css": "cp src/ui/style.css dist/bundle.css",
"protogen": "pbjs --target static-module --wrap es6 --es6 --keep-case -o src/proto/generated.js ../libs/hbb_common/protos/rendezvous.proto ../libs/hbb_common/protos/message.proto && pbts -o src/proto/generated.d.ts src/proto/generated.js"
},
"devDependencies": {
"esbuild": "^0.21.0",
"protobufjs-cli": "^1.1.3",
"typescript": "^5.4.0"
},
"dependencies": {
"@noble/hashes": "^2.2.0",
"protobufjs": "^7.2.6",
"tweetnacl": "^1.0.3"
}
}
+131
View File
@@ -0,0 +1,131 @@
// Crypto wrapper around tweetnacl.
//
// The byte-for-byte references for everything here are:
// /Users/sn0/Desktop/rustdesk/src/common.rs:2005-2031 (decode_id_pk, create_symmetric_key_msg)
// /Users/sn0/Desktop/rustdesk-server/libs/hbb_common/src/tcp.rs:296-344 (Encrypt + nonce derivation)
//
// tweetnacl exposes the same NaCl primitives the desktop client's
// `sodiumoxide` uses:
// - Ed25519: nacl.sign.open (verifies + unwraps a signed message)
// - Curve25519 box: nacl.box (asymmetric one-shot — we use the all-zero
// 24-byte nonce per the desktop client's create_symmetric_key_msg)
// - Secretbox: nacl.secretbox / nacl.secretbox.open (XSalsa20-Poly1305,
// symmetric per-message — nonce derivation is per-direction 8-byte LE
// sequence counter || 16 zero bytes)
//
// Pure JS, ~50 KB minified, no WASM, no module-resolution drama.
import nacl from "tweetnacl";
// Resolved immediately — kept as a Promise so the call site doesn't change
// when we swap implementations later.
export const sodiumReady: Promise<void> = Promise.resolve();
export const SIGN_PUBLICKEYBYTES = nacl.sign.publicKeyLength; // 32
export const BOX_PUBLICKEYBYTES = nacl.box.publicKeyLength; // 32
export const BOX_SECRETKEYBYTES = nacl.box.secretKeyLength; // 32
export const BOX_NONCEBYTES = nacl.box.nonceLength; // 24
export const SECRETBOX_KEYBYTES = nacl.secretbox.keyLength; // 32
export const SECRETBOX_NONCEBYTES = nacl.secretbox.nonceLength; // 24
/** Verify and unwrap an Ed25519-signed message. Throws on auth failure. */
export function signOpen(signed: Uint8Array, publicKey: Uint8Array): Uint8Array {
if (publicKey.length !== SIGN_PUBLICKEYBYTES) {
throw new Error(`signOpen: bad pk length ${publicKey.length}`);
}
const out = nacl.sign.open(signed, publicKey);
if (!out) throw new Error("signOpen: signature verification failed");
return out;
}
/** Generate an ephemeral Curve25519 keypair for the box handshake. */
export function genBoxKeypair(): { publicKey: Uint8Array; secretKey: Uint8Array } {
const kp = nacl.box.keyPair();
return { publicKey: kp.publicKey, secretKey: kp.secretKey };
}
/** Generate a fresh symmetric key for the per-session secretbox stream. */
export function genSecretboxKey(): Uint8Array {
return nacl.randomBytes(SECRETBOX_KEYBYTES);
}
/**
* Seal `msg` under the peer's Curve25519 public key with our secret key.
* All-zero 24-byte nonce — matches `sodiumoxide::crypto::box_::seal` in the
* desktop client's create_symmetric_key_msg.
*/
export function boxSeal(
msg: Uint8Array,
peerPublicKey: Uint8Array,
ourSecretKey: Uint8Array,
): Uint8Array {
const nonce = new Uint8Array(BOX_NONCEBYTES); // all zeros
return nacl.box(msg, nonce, peerPublicKey, ourSecretKey);
}
/**
* Encrypt with secretbox using the per-direction nonce derivation:
* nonce[0..8] = sequence_counter as little-endian u64
* nonce[8..24] = zeros
* Returns ciphertext (which includes the 16-byte Poly1305 tag).
*/
export function secretboxSeal(
msg: Uint8Array,
sequence: bigint,
key: Uint8Array,
): Uint8Array {
return nacl.secretbox(msg, makeNonce(sequence), key);
}
/** Inverse of secretboxSeal — throws on auth failure. */
export function secretboxOpen(
cipher: Uint8Array,
sequence: bigint,
key: Uint8Array,
): Uint8Array {
const out = nacl.secretbox.open(cipher, makeNonce(sequence), key);
if (!out) throw new Error("secretboxOpen: authentication failed");
return out;
}
/** Build a 24-byte nonce: little-endian u64 in [0..8], zeros in [8..24]. */
function makeNonce(sequence: bigint): Uint8Array {
const n = new Uint8Array(SECRETBOX_NONCEBYTES);
const view = new DataView(n.buffer);
view.setBigUint64(0, sequence, /*littleEndian=*/ true);
return n;
}
// Pure-JS SHA-256 from @noble/hashes — works in non-secure contexts
// (browsers gate `crypto.subtle` to HTTPS / localhost only). Audited,
// minimal, no WASM. The dashboard often runs on plain http for self-hosted
// deployments, which would break SubtleCrypto.
import { sha256 as nobleSha256 } from "@noble/hashes/sha2.js";
/** SHA-256 of `data`. Used for the Hash/LoginRequest password challenge. */
export function sha256(data: Uint8Array): Uint8Array {
return nobleSha256(data);
}
/** Concatenate two byte arrays. */
export function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
const out = new Uint8Array(a.length + b.length);
out.set(a, 0);
out.set(b, a.length);
return out;
}
/** Standard base64 (with padding) → bytes. Used to decode the server pk. */
export function base64Decode(s: string): Uint8Array {
const bin = atob(s);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
/** Bytes → standard base64 (with padding). */
export function base64Encode(bytes: Uint8Array): string {
let s = "";
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s);
}
+143
View File
@@ -0,0 +1,143 @@
// Audio pipeline using WebCodecs AudioDecoder.
//
// Wire format (from libs/hbb_common/protos/message.proto):
// Misc{audio_format: {sample_rate, channels}} sent once at session start
// Message{audio_frame: {data: <opus frame>}} periodic
//
// We feed each opus frame into AudioDecoder, get back an AudioData with PCM
// samples, copy into an AudioBufferSourceNode, and schedule it on a single
// AudioContext "playhead" timeline so consecutive packets play seamlessly.
import { hbb } from "../proto/generated.js";
export class AudioPipeline {
private decoder: AudioDecoder | null = null;
private ctx: AudioContext | null = null;
private playhead = 0; // next absolute scheduling time, in ctx seconds
private timestamp = 0; // monotonic counter for EncodedAudioChunk
private muted = false;
private gain: GainNode | null = null;
/** Configure on the first AudioFormat message. Throws if WebCodecs unavailable. */
configure(format: hbb.IAudioFormat): void {
if (typeof AudioDecoder === "undefined") {
throw new Error("WebCodecs AudioDecoder unavailable. Open via http://localhost or https:// — secure-context only.");
}
const sampleRate = format.sample_rate || 48000;
const channels = format.channels || 2;
if (this.decoder) this.decoder.close();
this.ctx = new AudioContext({ sampleRate, latencyHint: "interactive" });
this.gain = this.ctx.createGain();
this.gain.connect(this.ctx.destination);
this.playhead = this.ctx.currentTime + 0.05; // 50ms initial buffer
this.timestamp = 0;
this.decoder = new AudioDecoder({
output: (data) => this.onAudioData(data),
error: (e) => console.error("[rustdesk-web] AudioDecoder error:", e),
});
this.decoder.configure({
codec: "opus",
sampleRate,
numberOfChannels: channels,
});
}
/** Feed one opus packet (raw bytes from AudioFrame.data). */
pushFrame(data: Uint8Array): void {
if (!this.decoder || data.length === 0 || this.muted) return;
try {
const chunk = new EncodedAudioChunk({
type: "key", // opus is all-key per packet
timestamp: this.timestamp,
data,
});
this.timestamp += 20_000; // ~20ms per opus frame in microseconds
this.decoder.decode(chunk);
} catch (e) {
console.error("[rustdesk-web] audio decode failed:", e);
}
}
/** Resume the AudioContext after a user gesture (some browsers gate
* audio playback to the first interaction). */
async resume(): Promise<void> {
if (this.ctx && this.ctx.state === "suspended") {
await this.ctx.resume();
}
}
setMuted(muted: boolean): void {
this.muted = muted;
if (this.gain && this.ctx) {
this.gain.gain.setValueAtTime(muted ? 0 : 1, this.ctx.currentTime);
}
}
isMuted(): boolean {
return this.muted;
}
close(): void {
if (this.decoder) {
try { this.decoder.close(); } catch { /* ignore */ }
this.decoder = null;
}
if (this.ctx) {
try { this.ctx.close(); } catch { /* ignore */ }
this.ctx = null;
}
}
private onAudioData(data: AudioData): void {
if (!this.ctx || !this.gain) {
data.close();
return;
}
try {
const numChannels = data.numberOfChannels;
const numFrames = data.numberOfFrames;
const sampleRate = data.sampleRate;
const buffer = this.ctx.createBuffer(numChannels, numFrames, sampleRate);
// WebCodecs opus output is typically `f32` (interleaved), not
// `f32-planar`. Detect the format and deinterleave when needed.
const format = data.format || "f32";
if (format.endsWith("-planar")) {
// Each plane is one channel — copy directly.
for (let ch = 0; ch < numChannels; ch++) {
data.copyTo(buffer.getChannelData(ch), { planeIndex: ch });
}
} else {
// Interleaved: copy all samples, then deinterleave into the
// AudioBuffer's planar channels.
const interleaved = new Float32Array(numChannels * numFrames);
data.copyTo(interleaved, { planeIndex: 0 });
for (let ch = 0; ch < numChannels; ch++) {
const dest = buffer.getChannelData(ch);
for (let i = 0; i < numFrames; i++) {
dest[i] = interleaved[i * numChannels + ch]!;
}
}
}
const src = this.ctx.createBufferSource();
src.buffer = buffer;
src.connect(this.gain);
// Schedule on a sliding playhead so consecutive packets are gap-less.
// If we've fallen behind real time (network hiccup / decoder stall),
// re-anchor to "now + 50ms" to avoid scheduling a runaway pile-up.
const now = this.ctx.currentTime;
if (this.playhead < now) {
this.playhead = now + 0.05;
}
src.start(this.playhead);
this.playhead += numFrames / sampleRate;
} catch (e) {
console.error("[rustdesk-web] audio render failed:", e);
} finally {
data.close();
}
}
}
+96
View File
@@ -0,0 +1,96 @@
// Video decode pipeline using WebCodecs VideoDecoder.
//
// M6d covers VP9 (the host's default codec — see the desktop client's
// VpxEncoderConfig). VP9 frame data on the wire is the raw VP9 bitstream;
// WebCodecs' VideoDecoder accepts each frame as an EncodedVideoChunk with
// type "key" or "delta" derived from the EncodedVideoFrame.key flag.
//
// The decoder runs asynchronously: we push chunks in via `decode()`, the
// `output` callback fires later with a VideoFrame. We close each frame
// immediately after drawing to release the GPU texture.
import { hbb } from "../proto/generated.js";
export type FrameSink = (frame: VideoFrame) => void;
export class VideoPipeline {
private decoder: VideoDecoder | null = null;
private currentCodec = "";
private onFrame: FrameSink;
constructor(onFrame: FrameSink) {
this.onFrame = onFrame;
if (typeof VideoDecoder === "undefined") {
const insecure = !window.isSecureContext;
const hint = insecure
? "Open this page via http://localhost or https:// — WebCodecs is gated to secure contexts and the LAN IP doesn't qualify."
: "Update to Chrome 94+, Firefox 130+, or Safari 16.4+.";
throw new Error(`WebCodecs VideoDecoder unavailable. ${hint}`);
}
}
/** Push one EncodedVideoFrames container (oneof from a VideoFrame Message). */
pushVideoFrame(vf: hbb.IVideoFrame): void {
const frames = vf.vp9s?.frames || vf.vp8s?.frames || vf.av1s?.frames || vf.h264s?.frames || vf.h265s?.frames;
const codec = this.detectCodec(vf);
if (!frames || frames.length === 0 || !codec) {
return;
}
if (codec !== this.currentCodec) {
this.configureCodec(codec);
}
for (const f of frames) {
this.decode(f, codec);
}
}
private detectCodec(vf: hbb.IVideoFrame): string {
if (vf.vp9s) return "vp09.00.10.08"; // VP9 profile 0, 8-bit
if (vf.vp8s) return "vp8";
if (vf.av1s) return "av01.0.04M.08"; // AV1 main profile, level 4.0, 8-bit
if (vf.h264s) return "avc1.42E01E"; // H.264 baseline (will need SPS-driven config in M6f)
if (vf.h265s) return "hvc1.1.6.L93.B0";
return "";
}
private configureCodec(codec: string): void {
if (this.decoder) {
this.decoder.close();
}
this.currentCodec = codec;
this.decoder = new VideoDecoder({
output: (frame) => this.onFrame(frame),
error: (e) => {
console.error("[rustdesk-web] VideoDecoder error:", e);
},
});
this.decoder.configure({
codec,
// optimizeForLatency hints the decoder to skip frame reordering buffers.
optimizeForLatency: true,
});
}
private decode(f: hbb.IEncodedVideoFrame, _codec: string): void {
if (!this.decoder || !f.data || f.data.length === 0) return;
const data = f.data as Uint8Array;
const pts = typeof f.pts === "number" ? f.pts : Number(f.pts || 0);
try {
const chunk = new EncodedVideoChunk({
type: f.key ? "key" : "delta",
timestamp: pts * 1000, // ms → microseconds
data,
});
this.decoder.decode(chunk);
} catch (e) {
console.error("[rustdesk-web] decode chunk failed:", e);
}
}
close(): void {
if (this.decoder) {
try { this.decoder.close(); } catch { /* ignore */ }
this.decoder = null;
}
}
}
+161
View File
@@ -0,0 +1,161 @@
// Keyboard capture: translate browser KeyboardEvent into protobuf
// Message{key_event: ...}.
//
// We use Legacy mode (KeyboardMode = 0). Critically, Translate mode at the
// host explicitly drops Unicode and ControlKey payloads
// (src/server/input_service.rs:2022-2024 has `// Do not handle unicode`),
// so a Translate-mode submission with our payload shape silently no-ops.
// Legacy mode's legacy_keyboard_mode() at :1862 dispatches all four
// payload types (ControlKey/Chr/Unicode/Seq).
//
// Per-keystroke wire shape for Legacy mode:
// - Special keys (Enter, F1, Ctrl, etc.):
// { control_key: ControlKey, down, modifiers, mode: Legacy }
// Sent on both keydown and keyup (host's process_control_key honors `down`).
// - Printable chars:
// { unicode: codepoint, down: true, modifiers, mode: Legacy }
// Sent ONLY on keydown — host's process_unicode does a single key_click
// so a second event on keyup would re-type the char.
import { hbb } from "../proto/generated.js";
import type { Session } from "../transport/session.js";
import type { CanvasView } from "../ui/canvas.js";
/**
* KeyboardEvent.code → ControlKey enum value. Covers the named keys; any
* not in this map is treated as a printable Unicode key (using event.key).
*/
const CODE_TO_CONTROL: Record<string, hbb.ControlKey> = {
// Modifiers
AltLeft: hbb.ControlKey.Alt,
AltRight: hbb.ControlKey.RAlt,
ControlLeft: hbb.ControlKey.Control,
ControlRight: hbb.ControlKey.RControl,
ShiftLeft: hbb.ControlKey.Shift,
ShiftRight: hbb.ControlKey.RShift,
MetaLeft: hbb.ControlKey.Meta,
MetaRight: hbb.ControlKey.RWin,
OSLeft: hbb.ControlKey.Meta,
OSRight: hbb.ControlKey.RWin,
CapsLock: hbb.ControlKey.CapsLock,
// Editing / navigation
Backspace: hbb.ControlKey.Backspace,
Delete: hbb.ControlKey.Delete,
Tab: hbb.ControlKey.Tab,
Enter: hbb.ControlKey.Return,
NumpadEnter: hbb.ControlKey.NumpadEnter,
Escape: hbb.ControlKey.Escape,
Space: hbb.ControlKey.Space,
Insert: hbb.ControlKey.Insert,
Home: hbb.ControlKey.Home,
End: hbb.ControlKey.End,
PageUp: hbb.ControlKey.PageUp,
PageDown: hbb.ControlKey.PageDown,
ArrowUp: hbb.ControlKey.UpArrow,
ArrowDown: hbb.ControlKey.DownArrow,
ArrowLeft: hbb.ControlKey.LeftArrow,
ArrowRight: hbb.ControlKey.RightArrow,
// Function keys
F1: hbb.ControlKey.F1, F2: hbb.ControlKey.F2, F3: hbb.ControlKey.F3,
F4: hbb.ControlKey.F4, F5: hbb.ControlKey.F5, F6: hbb.ControlKey.F6,
F7: hbb.ControlKey.F7, F8: hbb.ControlKey.F8, F9: hbb.ControlKey.F9,
F10: hbb.ControlKey.F10, F11: hbb.ControlKey.F11, F12: hbb.ControlKey.F12,
// System
PrintScreen: hbb.ControlKey.Snapshot,
ScrollLock: hbb.ControlKey.Scroll,
Pause: hbb.ControlKey.Pause,
NumLock: hbb.ControlKey.NumLock,
ContextMenu: hbb.ControlKey.Apps,
// Numpad
Numpad0: hbb.ControlKey.Numpad0, Numpad1: hbb.ControlKey.Numpad1,
Numpad2: hbb.ControlKey.Numpad2, Numpad3: hbb.ControlKey.Numpad3,
Numpad4: hbb.ControlKey.Numpad4, Numpad5: hbb.ControlKey.Numpad5,
Numpad6: hbb.ControlKey.Numpad6, Numpad7: hbb.ControlKey.Numpad7,
Numpad8: hbb.ControlKey.Numpad8, Numpad9: hbb.ControlKey.Numpad9,
NumpadAdd: hbb.ControlKey.Add,
NumpadSubtract: hbb.ControlKey.Subtract,
NumpadMultiply: hbb.ControlKey.Multiply,
NumpadDivide: hbb.ControlKey.Divide,
NumpadDecimal: hbb.ControlKey.Decimal,
NumpadEqual: hbb.ControlKey.Equals,
};
function modifierList(e: KeyboardEvent): hbb.ControlKey[] {
const mods: hbb.ControlKey[] = [];
if (e.altKey) mods.push(hbb.ControlKey.Alt);
if (e.ctrlKey) mods.push(hbb.ControlKey.Control);
if (e.shiftKey) mods.push(hbb.ControlKey.Shift);
if (e.metaKey) mods.push(hbb.ControlKey.Meta);
return mods;
}
export function bindKeyboard(_canvas: CanvasView, session: Session): () => void {
// Window-level listeners — canvas focus is unreliable across browsers
// and can be lost without warning. Forwarding everything that arrives
// at the window is closer to the desktop client's behavior anyway.
// The trade-off: browser-level shortcuts (Cmd-W, Cmd-T, etc.) need to
// be allowlisted so they keep working.
const sendKey = (e: KeyboardEvent, down: boolean): void => {
// Don't intercept keystrokes when the user is typing into a real form
// input (the password prompt, dev tools fields rendered by extensions,
// etc.). Once we're in session, there are no real inputs in our UI.
const tgt = e.target as HTMLElement | null;
if (tgt && (tgt.tagName === "INPUT" || tgt.tagName === "TEXTAREA" || tgt.isContentEditable)) {
return;
}
const ck = CODE_TO_CONTROL[e.code];
let payload: hbb.IKeyEvent;
if (ck !== undefined) {
// Special key — fire on both keydown and keyup.
payload = { control_key: ck, down };
} else if (e.key.length === 1) {
// Printable char. Host's process_unicode does a single key_click,
// so we only send on keydown — keyup would double-type.
if (!down) return;
payload = { unicode: e.key.codePointAt(0)!, down: true };
} else {
// Unmapped key — silently drop. Rare in normal use.
return;
}
payload.modifiers = modifierList(e);
payload.mode = hbb.KeyboardMode.Legacy;
session.send(hbb.Message.create({ key_event: hbb.KeyEvent.create(payload) }))
.catch(() => { /* relay closed */ });
// Browser-shortcut allowlist: don't preventDefault on combos the user
// needs to keep using to manage their browser tab/window.
const allowBrowserDefault =
(e.metaKey || e.ctrlKey) && ["KeyT", "KeyN", "KeyW", "KeyR", "Tab"].includes(e.code);
if (!allowBrowserDefault) {
e.preventDefault();
}
};
const onDown = (e: KeyboardEvent): void => sendKey(e, true);
const onUp = (e: KeyboardEvent): void => sendKey(e, false);
window.addEventListener("keydown", onDown);
window.addEventListener("keyup", onUp);
return (): void => {
window.removeEventListener("keydown", onDown);
window.removeEventListener("keyup", onUp);
};
}
/** Send Ctrl+Alt+Del — the host has a special handler for this combo at
* src/server/input_service.rs:1788, which only fires on Windows hosts.
* On macOS/Linux this is a no-op server-side (there's no equivalent SAS).
* We use Legacy mode because Translate mode drops ControlKey payloads. */
export function sendCtrlAltDel(session: Session): void {
session.send(hbb.Message.create({
key_event: hbb.KeyEvent.create({
control_key: hbb.ControlKey.CtrlAltDel,
down: true,
mode: hbb.KeyboardMode.Legacy,
}),
})).catch(() => { /* ignore */ });
}
+156
View File
@@ -0,0 +1,156 @@
// Mouse capture: translate browser MouseEvent / WheelEvent into protobuf
// Message{mouse_event: {mask, x, y}}.
//
// Mask layout (from /Users/sn0/Desktop/rustdesk/src/common.rs:69-92):
// mask = (button_bitmask << 3) | event_type
// event_type = 0=move, 1=down, 2=up, 3=wheel, 4=trackpad
// button_bit = 0x01=left, 0x02=right, 0x04=middle/wheel,
// 0x08=back, 0x10=forward
//
// Coordinate translation:
// The host expects coordinates in its own display pixel space. We map
// browser canvas-rect coordinates → peer display coordinates by scaling
// against the displayed CanvasView.size().
import { hbb } from "../proto/generated.js";
import type { Session } from "../transport/session.js";
import type { CanvasView } from "../ui/canvas.js";
const MOUSE_TYPE_MOVE = 0;
const MOUSE_TYPE_DOWN = 1;
const MOUSE_TYPE_UP = 2;
const MOUSE_TYPE_WHEEL = 3;
const MOUSE_BUTTON_LEFT = 0x01;
const MOUSE_BUTTON_RIGHT = 0x02;
const MOUSE_BUTTON_MIDDLE = 0x04;
const MOUSE_BUTTON_BACK = 0x08;
const MOUSE_BUTTON_FORWARD = 0x10;
function buttonBitForEvent(button: number): number {
switch (button) {
case 0: return MOUSE_BUTTON_LEFT;
case 1: return MOUSE_BUTTON_MIDDLE;
case 2: return MOUSE_BUTTON_RIGHT;
case 3: return MOUSE_BUTTON_BACK;
case 4: return MOUSE_BUTTON_FORWARD;
default: return 0;
}
}
export function bindMouse(canvas: CanvasView, session: Session): () => void {
const el = canvas.el();
const sendMouse = (mask: number, x: number, y: number, ev: { altKey: boolean; ctrlKey: boolean; shiftKey: boolean; metaKey: boolean }): void => {
const modifiers: hbb.ControlKey[] = [];
if (ev.altKey) modifiers.push(hbb.ControlKey.Alt);
if (ev.ctrlKey) modifiers.push(hbb.ControlKey.Control);
if (ev.shiftKey) modifiers.push(hbb.ControlKey.Shift);
if (ev.metaKey) modifiers.push(hbb.ControlKey.Meta);
session.send(hbb.Message.create({
mouse_event: hbb.MouseEvent.create({
mask,
x: Math.round(x),
y: Math.round(y),
modifiers,
}),
})).catch(() => { /* relay closed */ });
};
// Translate viewport pixel coordinates → peer display pixels.
const mapCoords = (clientX: number, clientY: number): { x: number; y: number } | null => {
const sz = canvas.size();
if (!sz.width || !sz.height) return null;
const r = canvas.rect();
if (r.width <= 0 || r.height <= 0) return null;
// CanvasView uses object-fit: contain, so the rendered image is
// letterboxed inside `r`. Compute the actual rendered rectangle.
const scale = Math.min(r.width / sz.width, r.height / sz.height);
const renderedW = sz.width * scale;
const renderedH = sz.height * scale;
const offsetX = r.left + (r.width - renderedW) / 2;
const offsetY = r.top + (r.height - renderedH) / 2;
const px = (clientX - offsetX) / scale;
const py = (clientY - offsetY) / scale;
if (px < 0 || py < 0 || px > sz.width || py > sz.height) return null;
return { x: px, y: py };
};
// Track currently-pressed buttons so we can include them in move events.
// (Some hosts ignore button bits during MOVE; doesn't hurt to include.)
let pressed = 0;
const onMove = (e: MouseEvent): void => {
const c = mapCoords(e.clientX, e.clientY);
if (!c) return;
const mask = (pressed << 3) | MOUSE_TYPE_MOVE;
sendMouse(mask, c.x, c.y, e);
};
const onDown = (e: MouseEvent): void => {
const c = mapCoords(e.clientX, e.clientY);
if (!c) return;
const btn = buttonBitForEvent(e.button);
if (!btn) return;
pressed |= btn;
const mask = (btn << 3) | MOUSE_TYPE_DOWN;
sendMouse(mask, c.x, c.y, e);
// Make sure the canvas gets focus so keystrokes flow.
el.focus({ preventScroll: true });
// Suppress the browser context menu for right-click; do NOT
// preventDefault for left-click — that would block focusing the
// canvas in some browsers and silently break keyboard input.
if (e.button === 2) {
e.preventDefault();
}
};
const onUp = (e: MouseEvent): void => {
const c = mapCoords(e.clientX, e.clientY);
if (!c) return;
const btn = buttonBitForEvent(e.button);
if (!btn) return;
pressed &= ~btn;
const mask = (btn << 3) | MOUSE_TYPE_UP;
sendMouse(mask, c.x, c.y, e);
};
const onWheel = (e: WheelEvent): void => {
const c = mapCoords(e.clientX, e.clientY);
if (!c) return;
// Browser wheel deltas are in "lines" (deltaMode=DOM_DELTA_LINE) or
// "pixels" (DOM_DELTA_PIXEL). Quantize to small ints — the host's
// input_service.rs multiplies by WHEEL_DELTA on Windows. We send
// ±1..±5 lines; values larger than that scroll absurdly fast.
const lines = (px: number, mode: number): number => {
if (mode === WheelEvent.DOM_DELTA_PIXEL) {
// ~16px per line is the typical default.
return Math.max(-5, Math.min(5, Math.round(px / 16)));
}
return Math.max(-5, Math.min(5, Math.round(px)));
};
const dx = lines(e.deltaX, e.deltaMode);
const dy = lines(-e.deltaY, e.deltaMode); // browsers use +down; host wants +up
if (dx === 0 && dy === 0) return;
const mask = MOUSE_TYPE_WHEEL;
sendMouse(mask, dx, dy, e);
e.preventDefault();
};
const onContextMenu = (e: Event): void => { e.preventDefault(); };
el.addEventListener("mousemove", onMove);
el.addEventListener("mousedown", onDown);
el.addEventListener("mouseup", onUp);
el.addEventListener("wheel", onWheel, { passive: false });
el.addEventListener("contextmenu", onContextMenu);
// Detach handle.
return (): void => {
el.removeEventListener("mousemove", onMove);
el.removeEventListener("mousedown", onDown);
el.removeEventListener("mouseup", onUp);
el.removeEventListener("wheel", onWheel);
el.removeEventListener("contextmenu", onContextMenu);
};
}
+324
View File
@@ -0,0 +1,324 @@
// Web client entry point.
//
// M6c: full chain — punch hole, harvest signed peer pk, request relay,
// open WS to relay, secretbox handshake, password challenge, login.
// On success: display PeerInfo (hostname, platform, displays, codec set).
// Video lands in M6d.
import { sodiumReady, signOpen, base64Decode } from "./crypto.js";
import { hbb } from "./proto/generated.js";
import {
genUuid,
punchHole,
rendezvousUrl,
relayUrl,
} from "./transport/rendezvous.js";
import { Relay } from "./transport/relay.js";
import { Session } from "./transport/session.js";
import { VideoPipeline } from "./decode/video.js";
import { AudioPipeline } from "./decode/audio.js";
import { CanvasView } from "./ui/canvas.js";
import { bindMouse } from "./input/mouse.js";
import { bindKeyboard, sendCtrlAltDel } from "./input/keyboard.js";
interface CustomConfig {
api_server: string;
rendezvous_server: string;
relay_server: string;
key: string;
peer_id: string;
admin_name: string;
}
const CLIENT_VERSION = "1.4.0";
function readConfig(): CustomConfig {
const tag = document.getElementById("custom-config");
if (!tag) throw new Error("missing <script id='custom-config'> tag");
const raw = (tag.textContent || "").trim();
if (!raw) throw new Error("empty custom-config payload");
return JSON.parse(raw) as CustomConfig;
}
function status(html: string): void {
const root = document.getElementById("root");
if (!root) return;
root.innerHTML = `<div class="placeholder">${html}</div>`;
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
})[c]!);
}
function utf8Bytes(s: string): Uint8Array {
return new TextEncoder().encode(s);
}
/**
* Render a password prompt and return the entered string when the user
* submits. The prompt re-renders with `errMsg` when set (e.g. "Wrong
* Password" from a previous attempt).
*/
function askPassword(cfg: CustomConfig, errMsg?: string): Promise<string> {
return new Promise((resolve) => {
const root = document.getElementById("root");
if (!root) {
resolve("");
return;
}
const errBlock = errMsg
? `<p class="error-inline">${escapeHtml(errMsg)}</p>`
: "";
root.innerHTML = `
<div class="placeholder">
<h1>Connect to <code>${escapeHtml(cfg.peer_id)}</code></h1>
<p class="muted">If the host requires a password, enter it below. Leave blank for "accept without password" hosts.</p>
${errBlock}
<form id="pw-form" class="pw-form">
<input id="pw-input" type="password" autocomplete="current-password" placeholder="Password (optional)" />
<button type="submit">Connect</button>
</form>
</div>`;
const form = document.getElementById("pw-form") as HTMLFormElement | null;
const input = document.getElementById("pw-input") as HTMLInputElement | null;
input?.focus();
form?.addEventListener("submit", (ev) => {
ev.preventDefault();
resolve(input?.value || "");
});
});
}
async function main(): Promise<void> {
const cfg = readConfig();
await sodiumReady;
if (!cfg.key) throw new Error("custom-config.key empty (id_ed25519.pub missing)");
const serverPk = base64Decode(cfg.key);
if (serverPk.length !== 32) throw new Error(`server pk wrong length ${serverPk.length}`);
// Retry loop: prompt for password, attempt connect, if "Wrong Password"
// re-prompt and try again. Other errors break out and surface as fatal.
let lastErr: string | undefined;
while (true) {
const password = await askPassword(cfg, lastErr);
try {
await connectAndRun(cfg, serverPk, password);
return; // success — connectAndRun rendered the success UI
} catch (e) {
const msg = String(e);
if (msg.includes("Wrong Password") || msg.includes("Bad password") || msg.toLowerCase().includes("password")) {
lastErr = "Wrong password — try again.";
continue;
}
// Anything else: surface and stop. The connectAndRun has likely
// already rendered a specific error page, but provide a fallback.
console.error("[rustdesk-web] fatal:", e);
return;
}
}
}
async function connectAndRun(cfg: CustomConfig, serverPk: Uint8Array, password: string): Promise<void> {
// ============ STEP 1 — PunchHoleRequest with nat_type=SYMMETRIC ============
status(`<h1>Connecting to ${escapeHtml(cfg.peer_id)}…</h1><p class="muted">1/3: rendezvous</p>`);
const wsRendezvous = rendezvousUrl(cfg.rendezvous_server);
const ph = await punchHole(wsRendezvous, cfg.peer_id, cfg.key, CLIENT_VERSION);
if (ph.signedIdPk.length === 0) {
throw new Error("Server didn't sign the peer key — update the host's RustDesk to 1.4+.");
}
// ============ STEP 2 — verify peer's Ed25519 SIGN pk ============
let peerSignPk: Uint8Array;
let verifiedId: string;
try {
const idPkBytes = signOpen(ph.signedIdPk, serverPk);
const idPk = hbb.IdPk.decode(idPkBytes);
verifiedId = idPk.id || "";
peerSignPk = (idPk.pk as Uint8Array) || new Uint8Array(0);
} catch (e) {
throw new Error(`Signature verify failed — server pk on file (${cfg.key}) does not match what hbbs signed with. (${e})`);
}
if (verifiedId !== cfg.peer_id) {
throw new Error(`Peer ID mismatch (signed=${verifiedId}, requested=${cfg.peer_id})`);
}
if (!ph.relayServer) {
throw new Error("No relay server. Browser cannot do direct connect; set --relay-servers <host> on hbbs.");
}
const uuid = ph.peerUuid || genUuid();
if (!ph.peerUuid) {
console.warn("[rustdesk-web] no peer uuid in RelayResponse — fallback (relay pairing will likely fail)");
}
// ============ STEP 3 — open relay WS ============
status(`<h1>Connecting to ${escapeHtml(cfg.peer_id)}…</h1><p class="muted">2/3: relay handshake</p>`);
const wsRelay = relayUrl(ph.relayServer);
const relay = await Relay.connect({
wsUrl: wsRelay,
peerId: cfg.peer_id,
uuid,
licenceKey: cfg.key,
});
// ============ STEP 4 — secure handshake + login ============
status(`<h1>Connecting to ${escapeHtml(cfg.peer_id)}…</h1><p class="muted">3/3: secure handshake + login</p>`);
let ready;
try {
ready = await Session.open({
relay,
peerId: cfg.peer_id,
peerSignPk,
myName: cfg.admin_name || "web-admin",
password: utf8Bytes(password),
clientVersion: CLIENT_VERSION,
sessionId: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER),
});
} catch (e) {
relay.close();
throw e;
}
(window as unknown as { __rdw: unknown }).__rdw = { relay, session: ready.session, peerInfo: ready.peerInfo, cfg };
// ============ Post-login: render canvas + start video receive loop ============
await runSession(cfg, ready.session, ready.peerInfo, ready.preloginExtras);
}
async function runSession(
cfg: CustomConfig,
session: Session,
peerInfo: hbb.IPeerInfo,
preloginExtras: hbb.Message[],
): Promise<void> {
const root = document.getElementById("root");
if (!root) return;
// Replace the status placeholder with the canvas + HUD.
root.innerHTML = `<div class="session" id="session"></div><div class="hud" id="hud">connecting…</div>`;
const sessionEl = document.getElementById("session") as HTMLElement;
const hudEl = document.getElementById("hud") as HTMLElement;
const canvas = new CanvasView(sessionEl);
const pipeline = new VideoPipeline((frame) => canvas.draw(frame));
const audio = new AudioPipeline();
// Wire input.
const detachMouse = bindMouse(canvas, session);
const detachKb = bindKeyboard(canvas, session);
// Focus the canvas so keystrokes start flowing immediately.
canvas.el().focus();
// FPS / dims line (left of HUD).
const fpsLine = document.createElement("span");
fpsLine.className = "hud-fps";
hudEl.appendChild(fpsLine);
// Mute toggle.
const muteBtn = document.createElement("button");
muteBtn.className = "hud-btn";
muteBtn.textContent = "🔇 Mute";
muteBtn.addEventListener("click", () => {
const muted = !audio.isMuted();
audio.setMuted(muted);
muteBtn.textContent = muted ? "🔈 Unmute" : "🔇 Mute";
// First user gesture also unblocks the AudioContext on browsers that
// require a click before audio plays.
audio.resume().catch(() => { /* ignore */ });
});
hudEl.appendChild(muteBtn);
// Ctrl+Alt+Del (Windows hosts only — server-side `#[cfg(windows)]`).
const cadBtn = document.createElement("button");
cadBtn.textContent = "Ctrl+Alt+Del";
cadBtn.className = "hud-btn";
cadBtn.addEventListener("click", () => {
sendCtrlAltDel(session);
canvas.el().focus();
});
hudEl.appendChild(cadBtn);
setInterval(() => {
const sz = canvas.size();
const dims = sz.width ? `${sz.width}×${sz.height}` : "—";
fpsLine.textContent = ` ${escapeHtml(peerInfo.hostname || cfg.peer_id)} · ${dims} · ${canvas.fps()} fps `;
}, 1000);
void detachMouse; void detachKb; // kept-alive references for future cleanup
// Replay any pre-login messages (e.g. Misc{audio_format} that arrives
// before LoginResponse — the host's audio_service first-snapshot fires
// between add_connection and login_response) into the post-login
// dispatcher so they get handled.
for (const m of preloginExtras) {
if (m.misc?.audio_format) {
try {
audio.configure(m.misc.audio_format);
} catch (e) {
console.warn("[rustdesk-web] audio init failed:", e);
}
}
}
// Long-lived receive loop. Branches by Message oneof.
while (true) {
let msg: hbb.Message;
try {
msg = await session.recv();
} catch (e) {
console.error("[rustdesk-web] session recv failed:", e);
hudEl.textContent = `disconnected: ${String(e).slice(0, 80)}`;
break;
}
if (msg.video_frame) {
pipeline.pushVideoFrame(msg.video_frame);
continue;
}
if (msg.test_delay) {
// Echo back so the peer can compute round-trip latency.
const td = msg.test_delay;
session.send(hbb.Message.create({
test_delay: hbb.TestDelay.create({
time: td.time,
from_client: true,
last_delay: td.last_delay,
target_bitrate: td.target_bitrate,
}),
})).catch(() => { /* relay closing — drop */ });
continue;
}
if (msg.audio_frame) {
const data = (msg.audio_frame.data as Uint8Array) || new Uint8Array(0);
audio.pushFrame(data);
continue;
}
if (msg.cursor_data || msg.cursor_position || msg.cursor_id != null) {
// Cursor sprite + position — deferred to a QoL milestone. The loose
// `!= null` here is load-bearing: protobufjs static-module sets
// unpopulated oneof fields to `null` (not `undefined`), so a strict
// `!== undefined` check would swallow every other Message type and
// keep e.g. Misc/audio_format from ever reaching its handler.
continue;
}
if (msg.misc) {
if (msg.misc.audio_format) {
try {
audio.configure(msg.misc.audio_format);
} catch (e) {
console.warn("[rustdesk-web] audio init failed:", e);
}
}
continue;
}
}
pipeline.close();
audio.close();
}
main().catch((err) => {
console.error("[rustdesk-web] boot failed:", err);
status(`<div class="error"><h1>Failed to start</h1><pre>${escapeHtml(String(err))}</pre></div>`);
});
+13836
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
// 4-byte little-endian length prefix, used by the desktop client's TCP
// framing. WebSocket binary frames already preserve message boundaries, so
// we don't actually use these helpers on the WS path — they exist for
// testing against TCP captures and for any future TCP fallback.
//
// Reference: /Users/sn0/Desktop/rustdesk-server/libs/hbb_common/src/tcp.rs
// uses `BytesCodec` which doesn't add framing itself, but the
// `LengthDelimitedCodec` wrapping does (4-byte BE length per protobuf
// default). Our WS path skips this entirely.
export function frame(payload: Uint8Array): Uint8Array {
const out = new Uint8Array(4 + payload.length);
const view = new DataView(out.buffer);
view.setUint32(0, payload.length, /*littleEndian=*/ true);
out.set(payload, 4);
return out;
}
/**
* Try to peel one length-prefixed message off `buf`. Returns null if there
* isn't a complete message yet.
*/
export function unframe(
buf: Uint8Array,
): { msg: Uint8Array; rest: Uint8Array } | null {
if (buf.length < 4) return null;
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
const len = view.getUint32(0, /*littleEndian=*/ true);
if (buf.length < 4 + len) return null;
return {
msg: buf.subarray(4, 4 + len),
rest: buf.subarray(4 + len),
};
}
+135
View File
@@ -0,0 +1,135 @@
// WebSocket transport to the relay server (hbbr:21119).
//
// On connect, send RequestRelay with the uuid we agreed with the peer
// during rendezvous. After hbbr pairs us with the peer (matched by uuid)
// every subsequent binary frame is one message between us and the peer
// — first the unencrypted secure handshake, then secretbox-encrypted
// per-session messages.
//
// This module knows nothing about encryption. session.ts wraps it.
import { hbb } from "../proto/generated.js";
type Frame = Uint8Array;
export interface RelayOptions {
wsUrl: string;
peerId: string;
uuid: string;
licenceKey: string;
/** Connect timeout in ms — applies only to the initial RequestRelay roundtrip. */
connectTimeoutMs?: number;
}
/**
* Plain duplex stream wrapping a WebSocket. Outbound = `send(bytes)`;
* inbound = `await next()`. Closed when `close()` is called or the
* underlying WS errors.
*/
export class Relay {
private ws!: WebSocket;
private readBuf: Frame[] = [];
private waiters: Array<(f: Frame | null) => void> = [];
private closed = false;
private closeError: Error | null = null;
/**
* Open the WS, send RequestRelay, resolve once the WS is open and our
* RequestRelay has been queued. The peer's response is delivered via
* subsequent `next()` calls.
*/
static async connect(opts: RelayOptions): Promise<Relay> {
const r = new Relay();
await r._open(opts);
return r;
}
private async _open(opts: RelayOptions): Promise<void> {
const timeoutMs = opts.connectTimeoutMs ?? 10_000;
return new Promise((resolve, reject) => {
this.ws = new WebSocket(opts.wsUrl);
this.ws.binaryType = "arraybuffer";
const timer = setTimeout(() => {
this._fail(new Error(`relay: connect timeout after ${timeoutMs}ms`));
reject(new Error(`relay: connect timeout after ${timeoutMs}ms`));
}, timeoutMs);
this.ws.onopen = () => {
clearTimeout(timer);
const msg = hbb.RendezvousMessage.create({
request_relay: hbb.RequestRelay.create({
id: opts.peerId,
uuid: opts.uuid,
licence_key: opts.licenceKey,
conn_type: hbb.ConnType.DEFAULT_CONN,
// Note: `secure: true` is not set here — secure handshake is
// negotiated AFTER the relay pairs us, via the Message{public_key}
// frame the peer sends/expects. The `secure` flag in the
// *rendezvous-side* RequestRelay was the hint to the peer.
}),
});
const bytes = hbb.RendezvousMessage.encode(msg).finish();
this.ws.send(bytes);
resolve();
};
this.ws.onmessage = (ev: MessageEvent) => {
const buf = new Uint8Array(ev.data as ArrayBuffer);
// Wake one waiter or queue.
const w = this.waiters.shift();
if (w) w(buf);
else this.readBuf.push(buf);
};
this.ws.onerror = () => {
clearTimeout(timer);
this._fail(new Error(`relay: WebSocket error on ${opts.wsUrl}`));
reject(new Error(`relay: WebSocket error on ${opts.wsUrl}`));
};
this.ws.onclose = (ev: CloseEvent) => {
clearTimeout(timer);
this._fail(new Error(`relay: socket closed (code=${ev.code} reason=${ev.reason || "n/a"})`));
};
});
}
/** Read the next frame from the relay. Resolves null after close. */
next(): Promise<Frame | null> {
if (this.readBuf.length > 0) {
return Promise.resolve(this.readBuf.shift()!);
}
if (this.closed) {
return Promise.resolve(null);
}
return new Promise((resolve, reject) => {
this.waiters.push((f) => {
if (this.closeError && f === null) reject(this.closeError);
else resolve(f);
});
});
}
/** Send a binary frame on the relay. */
send(bytes: Uint8Array): void {
if (this.closed) throw new Error("relay: send on closed connection");
this.ws.send(bytes);
}
/** Close the underlying WS and wake any pending readers. */
close(): void {
this._fail(new Error("relay: closed by client"));
}
private _fail(err: Error): void {
if (this.closed) return;
this.closed = true;
this.closeError = err;
try { this.ws.close(); } catch { /* ignore */ }
while (this.waiters.length > 0) {
const w = this.waiters.shift()!;
w(null);
}
}
}
+259
View File
@@ -0,0 +1,259 @@
// WebSocket exchanges with the rendezvous server (hbbs:21118).
//
// Mirrors the desktop client's two-step relay setup:
// 1. PunchHoleRequest -> PunchHoleResponse — harvests the SIGNED peer
// Curve25519 pk and the relay-server address. The peer responds with
// PunchHoleSent which the rendezvous server transforms into
// PunchHoleResponse with `pk` filled in via get_pk()
// (rendezvous_server.rs:707).
// 2. RequestRelay -> RelayResponse — books a relay session under a uuid
// we generate. The peer-forwarded RelayResponse from THIS path has
// empty pk (peer's create_relay(initiate=false) doesn't set id, so
// the rendezvous's set_pk replacement is skipped). That's fine: we
// already have the signed pk from step 1.
//
// Each step is a fresh short-lived WS connection — same pattern the
// desktop client uses (request_relay() in client.rs opens a new TCP).
// Wire frames are raw protobuf bytes inside WS binary frames; no length
// prefix.
import { hbb } from "../proto/generated.js";
export interface PunchHoleResult {
/** Peer's signed Curve25519 sign-pk (verify with the rendezvous server's Ed25519 pk). */
signedIdPk: Uint8Array;
/** Hostname/IP of the relay (hbbr) the peer chose. */
relayServer: string;
/**
* UUID the **peer** generated for this relay session. Empty when the
* response was a PunchHoleResponse (direct path); set when the peer went
* straight to relay via RelayResponse — which is what happens whenever
* we send `nat_type=SYMMETRIC`. Caller MUST use this uuid (not a fresh
* one) when connecting to the relay; it's the only value the peer's own
* relay leg agreed on.
*/
peerUuid: string;
/** Mangled peer socket address — ignored by the browser (no direct connect). */
peerSocketAddr: Uint8Array;
}
/**
* Send PunchHoleRequest, receive PunchHoleResponse. Throws on peer-offline,
* license-mismatch, timeout, etc. Sets `nat_type=SYMMETRIC` so the peer
* relay flow is preferred.
*/
export function punchHole(
wsUrl: string,
peerId: string,
licenceKey: string,
clientVersion: string,
timeoutMs = 10_000,
): Promise<PunchHoleResult> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
const timer = setTimeout(() => {
cleanup();
reject(new Error(`punchHole: timeout after ${timeoutMs}ms`));
}, timeoutMs);
function cleanup(): void {
clearTimeout(timer);
try { ws.close(); } catch { /* ignore */ }
}
ws.onopen = () => {
const msg = hbb.RendezvousMessage.create({
punch_hole_request: hbb.PunchHoleRequest.create({
id: peerId,
// SYMMETRIC NAT — combined with browser-as-controller, the peer
// recognizes "you can't punch through" and prefers the relay
// path. This is also what desktop's --force-relay sets.
nat_type: hbb.NatType.SYMMETRIC,
licence_key: licenceKey,
conn_type: hbb.ConnType.DEFAULT_CONN,
version: clientVersion,
}),
});
const bytes = hbb.RendezvousMessage.encode(msg).finish();
ws.send(bytes);
};
ws.onmessage = (ev: MessageEvent) => {
const buf = new Uint8Array(ev.data as ArrayBuffer);
let parsed: hbb.RendezvousMessage;
try {
parsed = hbb.RendezvousMessage.decode(buf);
} catch (e) {
cleanup();
reject(new Error(`punchHole: decode failure: ${e}`));
return;
}
const ph = parsed.punch_hole_response;
if (ph) {
// Peer-not-reachable / failure path: socket_addr is empty.
if (!ph.socket_addr || ph.socket_addr.length === 0) {
cleanup();
reject(new Error(failureMessage(ph)));
return;
}
cleanup();
resolve({
signedIdPk: (ph.pk as Uint8Array) || new Uint8Array(0),
relayServer: ph.relay_server || "",
peerUuid: "", // direct path — uuid not relevant
peerSocketAddr: (ph.socket_addr as Uint8Array) || new Uint8Array(0),
});
return;
}
// Peer chose relay path immediately (the SYMMETRIC nat_type we send
// forces this). The peer generated its OWN uuid in the create_relay
// initiate=true path — capture it; that's the only uuid the peer
// listens for on the relay side.
const rr = parsed.relay_response;
if (rr) {
cleanup();
resolve({
signedIdPk: (rr.pk as Uint8Array) || new Uint8Array(0),
relayServer: rr.relay_server || "",
peerUuid: rr.uuid || "",
peerSocketAddr: (rr.socket_addr as Uint8Array) || new Uint8Array(0),
});
return;
}
// Other message types (config_update, etc.) — keep waiting.
};
ws.onerror = () => {
cleanup();
reject(new Error(`punchHole: WebSocket error connecting to ${wsUrl}`));
};
ws.onclose = (ev: CloseEvent) => {
clearTimeout(timer);
reject(new Error(`punchHole: socket closed (code=${ev.code} reason=${ev.reason || "n/a"})`));
};
});
}
/**
* Send RequestRelay to rendezvous (after having already harvested the
* signed pk via PunchHole). Receive RelayResponse confirming the peer is
* paired with our uuid on the relay. RelayResponse.pk is empty on this
* path — caller must use the signedIdPk from the prior PunchHole result.
*/
export function requestRelayViaRendezvous(
wsUrl: string,
peerId: string,
uuid: string,
licenceKey: string,
clientVersion: string,
timeoutMs = 10_000,
): Promise<{ uuid: string; relayServer: string }> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
const timer = setTimeout(() => {
cleanup();
reject(new Error(`requestRelay: timeout after ${timeoutMs}ms`));
}, timeoutMs);
function cleanup(): void {
clearTimeout(timer);
try { ws.close(); } catch { /* ignore */ }
}
ws.onopen = () => {
const msg = hbb.RendezvousMessage.create({
request_relay: hbb.RequestRelay.create({
id: peerId,
uuid,
licence_key: licenceKey,
secure: true,
conn_type: hbb.ConnType.DEFAULT_CONN,
}),
});
// RequestRelay has no version field, but adding it via __unknown
// would just be dropped by the proto. The server's get_pk() check
// only matters for the response *from the peer*, which already
// carries the peer's own version.
void clientVersion;
const bytes = hbb.RendezvousMessage.encode(msg).finish();
ws.send(bytes);
};
ws.onmessage = (ev: MessageEvent) => {
const buf = new Uint8Array(ev.data as ArrayBuffer);
let parsed: hbb.RendezvousMessage;
try {
parsed = hbb.RendezvousMessage.decode(buf);
} catch (e) {
cleanup();
reject(new Error(`requestRelay: decode failure: ${e}`));
return;
}
const rr = parsed.relay_response;
if (rr) {
if (rr.refuse_reason) {
cleanup();
reject(new Error(`relay refused: ${rr.refuse_reason}`));
return;
}
cleanup();
resolve({
uuid,
relayServer: rr.relay_server || "",
});
}
// Otherwise keep the WS open; another message might be coming.
// (A 30s onclose timer will fire if not.)
};
ws.onerror = () => {
cleanup();
reject(new Error(`requestRelay: WebSocket error connecting to ${wsUrl}`));
};
ws.onclose = (ev: CloseEvent) => {
clearTimeout(timer);
reject(new Error(`requestRelay: socket closed (code=${ev.code} reason=${ev.reason || "n/a"})`));
};
});
}
function failureMessage(ph: hbb.IPunchHoleResponse): string {
if (ph.other_failure) return ph.other_failure;
switch (ph.failure) {
case hbb.PunchHoleResponse.Failure.ID_NOT_EXIST: return "Peer ID not registered with this server";
case hbb.PunchHoleResponse.Failure.OFFLINE: return "Peer is offline";
case hbb.PunchHoleResponse.Failure.LICENSE_MISMATCH: return "License key mismatch";
case hbb.PunchHoleResponse.Failure.LICENSE_OVERUSE: return "License overuse";
default: return "Rendezvous failed (peer offline or unknown reason)";
}
}
/** RFC 4122 v4 UUID generator (browser crypto.randomUUID where available). */
export function genUuid(): string {
const c = globalThis.crypto;
if (typeof c.randomUUID === "function") {
return c.randomUUID();
}
const b = new Uint8Array(16);
c.getRandomValues(b);
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
const h = Array.from(b, (x) => x.toString(16).padStart(2, "0"));
return `${h.slice(0, 4).join("")}-${h.slice(4, 6).join("")}-${h.slice(6, 8).join("")}-${h.slice(8, 10).join("")}-${h.slice(10, 16).join("")}`;
}
export function rendezvousUrl(host: string, port = 21118): string {
const proto = location.protocol === "https:" ? "wss" : "ws";
return `${proto}://${host}:${port}/`;
}
export function relayUrl(host: string, port = 21119): string {
const proto = location.protocol === "https:" ? "wss" : "ws";
return `${proto}://${host}:${port}/`;
}
+246
View File
@@ -0,0 +1,246 @@
// Encrypted session on top of a Relay.
//
// Mirrors `secure_connection` + login-request handling in
// /Users/sn0/Desktop/rustdesk/src/client.rs:758-834. Wire shape:
//
// Peer -> Us: Message{signed_id: <signed IdPk{id, curve25519_box_pk}>}
// - signed by peer's Ed25519 SIGN sk
// - we verify with peer_sign_pk (extracted from rendezvous's signed_id_pk)
// - inner pk is the peer's CURVE25519 BOX pk
// Us -> Peer: Message{public_key: {asymmetric_value, symmetric_value}}
// - asymmetric_value = our ephemeral Curve25519 box pk
// - symmetric_value = box_seal(secretbox_key, peer_box_pk, our_box_sk)
// - this message is unencrypted (the LAST one before secretbox stream)
// [from now on every Message is secretbox(seqN, secretbox_key)]
// Peer -> Us: Message{hash: {salt, challenge}}
// Us -> Peer: Message{login_request: {username, password, my_id, ...}}
// - password = SHA256(SHA256(pwd_bytes || base64dec(salt)) || base64dec(challenge))
// - empty password = SHA256(SHA256("" || salt) || challenge) on the desktop
// Peer -> Us: Message{login_response: {peer_info or error}}
//
// Sequence-counter rule (the bit that bites every reimplementation —
// see libs/hbb_common/src/tcp.rs:296-344): SEND and RECV each maintain
// their OWN counter starting at 0, only the secretbox-encrypted frames
// are counted, both directions advance independently.
import { Relay } from "./relay.js";
import { hbb } from "../proto/generated.js";
import {
signOpen,
genBoxKeypair,
genSecretboxKey,
boxSeal,
secretboxSeal,
secretboxOpen,
sha256,
concat,
} from "../crypto.js";
/** Parameters for the secure-then-login handshake. */
export interface SessionOptions {
relay: Relay;
peerId: string;
/** The peer's signed Ed25519 SIGN pk extracted from the rendezvous IdPk. */
peerSignPk: Uint8Array;
/** Display name shown on the host's "incoming connection" notice. */
myName: string;
/** Peer password — empty Uint8Array for passwordless / accept-all peers. */
password: Uint8Array;
/** Client version string we advertise (e.g. "1.4.0"). */
clientVersion: string;
/** Random session id (uint64). Number value (≤ 53 bits) — opaque to peer. */
sessionId: number;
}
export interface SessionReady {
session: Session;
peerInfo: hbb.IPeerInfo;
/**
* Messages that arrived during the login phase but weren't a Hash /
* LoginResponse / TestDelay. The most important one in practice is
* `Misc{audio_format}` — the host's audio_service runs its first
* snapshot between add_connection and login_response, so AudioFormat
* lands BEFORE the caller has a chance to set up its receive loop.
* The caller must replay these into its post-login dispatch.
*/
preloginExtras: hbb.Message[];
}
/** A live encrypted session. After login, use `send`/`recv` to swap Messages. */
export class Session {
private constructor(
private relay: Relay,
private secretboxKey: Uint8Array,
private sendSeq: bigint,
private recvSeq: bigint,
) {}
static async open(opts: SessionOptions): Promise<SessionReady> {
// -------- 1. Receive Message{signed_id} --------
const sig1Buf = await opts.relay.next();
if (!sig1Buf) throw new Error("session: relay closed before SignedId");
const sig1 = hbb.Message.decode(sig1Buf);
if (!sig1.signed_id) throw new Error(`session: expected signed_id, got ${describeMessage(sig1)}`);
const signedIdBytes = sig1.signed_id.id as Uint8Array;
if (!signedIdBytes || signedIdBytes.length === 0) {
throw new Error("session: SignedId.id is empty");
}
// -------- 2. Verify with peer's Ed25519 sign pk; extract Curve25519 box pk --------
let peerBoxPk: Uint8Array;
let verifiedId: string;
try {
const idPkBytes = signOpen(signedIdBytes, opts.peerSignPk);
const idPk = hbb.IdPk.decode(idPkBytes);
verifiedId = idPk.id || "";
peerBoxPk = (idPk.pk as Uint8Array) || new Uint8Array(0);
} catch (e) {
throw new Error(`session: SignedId verify failed (peer's Ed25519 sign pk does not match the rendezvous-signed pk): ${e}`);
}
if (verifiedId !== opts.peerId) {
throw new Error(`session: SignedId.id mismatch (expected ${opts.peerId}, got ${verifiedId})`);
}
if (peerBoxPk.length !== 32) {
throw new Error(`session: peer Curve25519 box pk has wrong length ${peerBoxPk.length}`);
}
// -------- 3. Generate ephemeral Curve25519 keypair + symmetric key --------
const ourBox = genBoxKeypair();
const sbKey = genSecretboxKey();
const sealedKey = boxSeal(sbKey, peerBoxPk, ourBox.secretKey);
// -------- 4. Send Message{public_key} unencrypted --------
const pkMsg = hbb.Message.create({
public_key: hbb.PublicKey.create({
asymmetric_value: ourBox.publicKey,
symmetric_value: sealedKey,
}),
});
opts.relay.send(hbb.Message.encode(pkMsg).finish());
// -------- 5. From now on, all messages are secretbox-encrypted --------
const session = new Session(opts.relay, sbKey, /*sendSeq=*/ 0n, /*recvSeq=*/ 0n);
// -------- 6. Receive Message{hash} --------
const hashMsg = await session.recv();
if (!hashMsg.hash) {
throw new Error(`session: expected hash, got ${describeMessage(hashMsg)}`);
}
const salt = hashMsg.hash.salt || "";
const challenge = hashMsg.hash.challenge || "";
// -------- 7. Compute password challenge response --------
// The desktop client at client.rs:3414 / 3477 uses the salt and
// challenge as RAW UTF-8 BYTES of the proto `string` fields (not
// base64-decoded). protobufjs gives us JS strings; encode them back to
// UTF-8 bytes the way the Rust side reads them.
// pwd_hash = SHA256(password_text || salt_utf8_bytes)
// password_response = SHA256(pwd_hash || challenge_utf8_bytes)
const enc = new TextEncoder();
const saltBytes = enc.encode(salt);
const challengeBytes = enc.encode(challenge);
const pwdHash = sha256(concat(opts.password, saltBytes));
const passwordResp = sha256(concat(pwdHash, challengeBytes));
// -------- 8. Send Message{login_request} --------
const loginReq = hbb.Message.create({
login_request: hbb.LoginRequest.create({
username: opts.peerId,
password: passwordResp,
my_id: "web-client",
my_name: opts.myName,
version: opts.clientVersion,
my_platform: "Web",
session_id: opts.sessionId,
video_ack_required: false,
option: hbb.OptionMessage.create({
supported_decoding: hbb.SupportedDecoding.create({
ability_vp9: 1,
ability_h264: 1,
// VP8 / AV1 / H.265 left at 0 for v1; codec branches land in
// M6f and the QoL milestones.
prefer: hbb.SupportedDecoding.PreferCodec.VP9,
}),
// Explicitly opt INTO audio. The host's update_options() at
// server/connection.rs:3974 only re-subscribes audio_service
// when this is a definite Yes/No — `NotSet` (the default) is a
// silent no-op and the host never starts streaming audio.
disable_audio: hbb.OptionMessage.BoolOption.No,
// Also explicitly enable clipboard while we're here — same gate
// pattern, future-proofs M6h+ when we add clipboard sync.
disable_clipboard: hbb.OptionMessage.BoolOption.No,
}),
}),
});
await session.send(loginReq);
// -------- 9. Wait for login_response, echoing test_delay along the way --------
// Peers send periodic TestDelay frames for latency measurement; we
// echo them with from_client=true so the peer can compute RTT.
// CRITICAL: the host's audio_service first-snapshot fires between
// add_connection and login_response — Misc{audio_format} arrives here
// and would be dropped if we just ignored non-login_response. Capture
// every non-trivial message into preloginExtras so the caller can
// replay them into its main receive dispatch.
const preloginExtras: hbb.Message[] = [];
while (true) {
const respMsg = await session.recv();
if (respMsg.test_delay) {
const td = respMsg.test_delay;
await session.send(hbb.Message.create({
test_delay: hbb.TestDelay.create({
time: td.time,
from_client: true,
last_delay: td.last_delay,
target_bitrate: td.target_bitrate,
}),
}));
continue;
}
if (respMsg.login_response) {
const lr = respMsg.login_response;
if (lr.error) throw new Error(`session: login refused: ${lr.error}`);
if (!lr.peer_info) throw new Error("session: login_response missing peer_info");
console.log(`[rustdesk-web] session: login OK, peer=${lr.peer_info.hostname}/${lr.peer_info.platform} v${lr.peer_info.version}`);
return { session, peerInfo: lr.peer_info, preloginExtras };
}
// Stash the message for the caller to replay post-login.
preloginExtras.push(respMsg);
}
}
/** Encrypt + send a Message. The desktop's `Encrypt::enc` PRE-increments
* its counter — the first encrypted message uses nonce sequence = 1,
* not 0. See libs/hbb_common/src/tcp.rs:317-320. */
async send(msg: hbb.IMessage): Promise<void> {
const plain = hbb.Message.encode(msg as hbb.Message).finish();
this.sendSeq += 1n;
const cipher = secretboxSeal(plain, this.sendSeq, this.secretboxKey);
this.relay.send(cipher);
}
/** Receive + decrypt the next Message. Same pre-increment as `send`. */
async recv(): Promise<hbb.Message> {
const buf = await this.relay.next();
if (!buf) throw new Error("session: relay closed");
this.recvSeq += 1n;
const plain = secretboxOpen(buf, this.recvSeq, this.secretboxKey);
return hbb.Message.decode(plain);
}
close(): void {
this.relay.close();
}
}
function describeMessage(m: hbb.IMessage): string {
// Find the first set field for a useful diagnostic.
for (const k of [
"signed_id", "public_key", "test_delay", "video_frame", "login_request",
"login_response", "hash", "mouse_event", "audio_frame", "key_event",
"clipboard", "misc", "peer_info",
] as const) {
if ((m as Record<string, unknown>)[k]) return k;
}
return "unknown";
}
+70
View File
@@ -0,0 +1,70 @@
// Canvas display — draws each decoded VideoFrame into a 2D canvas, then
// closes the frame. The canvas auto-resizes to the host's coded width/height
// on the first frame and on any resolution change (multi-monitor switch).
export class CanvasView {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private width = 0;
private height = 0;
// FPS counter (rolling 1-second window).
private frameTimes: number[] = [];
constructor(parent: HTMLElement) {
this.canvas = document.createElement("canvas");
this.canvas.className = "rd-canvas";
parent.appendChild(this.canvas);
const ctx = this.canvas.getContext("2d", { alpha: false });
if (!ctx) throw new Error("canvas: 2d context not available");
this.ctx = ctx;
}
/** Draw a decoded frame, then close it. Must be called from the
* VideoDecoder.output callback. */
draw(frame: VideoFrame): void {
try {
if (frame.codedWidth !== this.width || frame.codedHeight !== this.height) {
this.width = frame.codedWidth;
this.height = frame.codedHeight;
this.canvas.width = this.width;
this.canvas.height = this.height;
}
this.ctx.drawImage(frame, 0, 0);
this.tickFps();
} catch (e) {
console.error("[rustdesk-web] canvas draw failed:", e);
} finally {
frame.close();
}
}
private tickFps(): void {
const now = performance.now();
this.frameTimes.push(now);
while (this.frameTimes.length > 0 && now - this.frameTimes[0]! > 1000) {
this.frameTimes.shift();
}
}
/** Current frames-per-second over the last second. */
fps(): number {
return this.frameTimes.length;
}
/** Coded dimensions of the most recent frame. Used by input mappers to
* translate browser pixel coords → peer screen coords. */
size(): { width: number; height: number } {
return { width: this.width, height: this.height };
}
/** Bounding rect of the canvas in browser viewport pixels. */
rect(): DOMRect {
return this.canvas.getBoundingClientRect();
}
/** Underlying canvas — used by input modules to attach event listeners. */
el(): HTMLCanvasElement {
return this.canvas;
}
}
+154
View File
@@ -0,0 +1,154 @@
/* RustDesk web client — minimal, dark theme to match the admin dashboard. */
html, body {
margin: 0;
padding: 0;
height: 100%;
background: #0f172a;
color: #e2e8f0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
#root {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.placeholder {
background: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
padding: 32px 40px;
max-width: 540px;
text-align: center;
}
.placeholder h1 {
margin: 0 0 16px;
font-size: 20px;
font-weight: 600;
}
.placeholder p {
margin: 8px 0;
font-size: 14px;
color: #cbd5e1;
}
.placeholder code {
background: #0f172a;
padding: 2px 6px;
border-radius: 3px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
color: #7dd3fc;
}
.muted { color: #64748b !important; font-size: 12px !important; }
.pw-form {
display: flex;
gap: 8px;
margin-top: 16px;
align-items: stretch;
}
.pw-form input[type="password"] {
flex: 1;
background: #0f172a;
border: 1px solid #334155;
color: #e2e8f0;
padding: 8px 10px;
border-radius: 4px;
font-size: 14px;
font-family: inherit;
}
.pw-form input[type="password"]:focus {
outline: none;
border-color: #38bdf8;
}
.pw-form button {
background: #0284c7;
border: 0;
color: #f0f9ff;
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.pw-form button:hover { background: #0369a1; }
.error-inline {
background: rgba(220, 38, 38, 0.15);
border: 1px solid rgba(220, 38, 38, 0.4);
color: #fca5a5;
padding: 8px 10px;
border-radius: 4px;
font-size: 13px;
margin-top: 12px;
}
/* ------- Live session ------- */
.session {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
background: #000;
}
.rd-canvas {
flex: 1;
width: 100%;
height: 100%;
/* Letterbox: keep aspect ratio while fitting the browser viewport. */
object-fit: contain;
display: block;
background: #000;
}
.hud {
position: fixed;
top: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.55);
color: #cbd5e1;
font-size: 11px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
padding: 4px 8px;
border-radius: 4px;
z-index: 10;
display: flex;
align-items: center;
gap: 8px;
}
.hud-fps {
pointer-events: none;
}
.hud-btn {
background: #334155;
border: 0;
color: #e2e8f0;
padding: 3px 8px;
border-radius: 3px;
font-size: 11px;
font-family: inherit;
cursor: pointer;
}
.hud-btn:hover { background: #475569; }
.error {
background: rgba(220, 38, 38, 0.15);
border: 1px solid rgba(220, 38, 38, 0.4);
border-radius: 8px;
padding: 24px 32px;
color: #fca5a5;
max-width: 640px;
}
.error h1 { margin: 0 0 12px; font-size: 18px; }
.error pre { white-space: pre-wrap; font-size: 13px; }
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"esModuleInterop": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"isolatedModules": true,
"noEmit": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "src/proto/generated.js"]
}