d07e98e607
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>
144 lines
4.9 KiB
TypeScript
144 lines
4.9 KiB
TypeScript
// 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();
|
|
}
|
|
}
|
|
}
|