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:
@@ -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 — 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>
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
pub mod address_books;
|
||||
pub mod audit;
|
||||
pub mod connect;
|
||||
pub mod deploy;
|
||||
pub mod devices;
|
||||
pub mod groups;
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
Executable
+27
@@ -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
|
||||
Vendored
+154
@@ -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; }
|
||||
Vendored
+10
File diff suppressed because one or more lines are too long
Vendored
+7
File diff suppressed because one or more lines are too long
Generated
+1376
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 */ });
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||
})[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>`);
|
||||
});
|
||||
Vendored
+13836
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}/`;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user