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",
|
"/admin/pages/deploy/generate",
|
||||||
post(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/devices", get(pages::devices::index))
|
||||||
.route("/admin/pages/groups", get(pages::groups::index))
|
.route("/admin/pages/groups", get(pages::groups::index))
|
||||||
.route("/admin/pages/strategies", get(pages::strategies::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">
|
<details class="text-right relative">
|
||||||
<summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary>
|
<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">
|
<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"
|
<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-post="/admin/pages/devices/{id}/disconnect"
|
||||||
hx-target="#devices-region" hx-swap="innerHTML"
|
hx-target="#devices-region" hx-swap="innerHTML"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
pub mod address_books;
|
pub mod address_books;
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
|
pub mod connect;
|
||||||
pub mod deploy;
|
pub mod deploy;
|
||||||
pub mod devices;
|
pub mod devices;
|
||||||
pub mod groups;
|
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