feat: M6 web client QoL — clipboard, multi-monitor, quality, reconnect, H.264, docs

Builds on the M6a-g MVP (d07e98e) with five user-facing features that
take the in-browser remote-control client from "demo toy" to "workable
for daily use", plus user-facing documentation.

M6h — Text clipboard sync (both directions)
- Host → browser via Clipboard{format: Text} → navigator.clipboard.writeText.
- Browser → host via Cmd-V/Ctrl-V keydown intercept → navigator.clipboard
  .readText → Message{clipboard}, sent BEFORE the V keystroke so the
  host's paste hotkey lands on the freshly-synced text.
- Handles both single-format Clipboard (older peers) and MultiClipboards
  wrapper (peers ≥ 1.3.0; gated by clipboard.rs:is_support_multi_clipboard).
- Switched printable-with-modifier hotkeys (Ctrl-C etc.) from Unicode to
  Chr payload — host's process_unicode does key_sequence(char) which
  ignores modifiers, breaking copy/cut; process_chr respects them.
- Firefox refuses navigator.clipboard.readText() by default — accepted as
  a known browser limitation, host → browser direction works regardless.

M6i — Multi-monitor switching
- HUD picker shown when peer_info.displays > 1.
- On change: SwitchDisplay + CaptureDisplays{set:[idx]} two-message dance
  — required for clients ≥ 1.2.4 (we send "1.4.0"). Without the follow-
  up, switch_display_to leaves both video services subscribed and
  switching display 0 → 1 → 0 doesn't restore display 0.
- Mouse coords offset by the active display's virtual-desktop origin
  (DisplayInfo.x, .y). Without this, clicks on display 2 landed on
  display 1 because both share canvas (0,0) but only display 1 has
  origin (0,0) in virtual-desktop space.

M6j — Quality / FPS / mute controls
- Image quality preset (Low/Balanced/Best) → Misc{option: {image_quality}}.
- Custom FPS (15/30/60) → Misc{option: {custom_fps}}; host caps at 30
  unless allow_more_fps is advertised.
- Mute toggle additionally sends Misc{option: {disable_audio: Yes/No}}
  so the peer stops encoding audio while muted (saves CPU + bandwidth).

M6k — Auto-reconnect on transient drops
- session.recv() throw → reconnect with exponential backoff: 1s, 2s, 4s,
  8s, 16s, 30s, 30s, capped at 30s, max 10 attempts.
- Dim overlay sits on top of the canvas during retry; canvas keeps
  last-known frame for visual continuity.
- Auth errors (password/signature) bail immediately — no point retrying.
- User options (mute, image_quality, custom_fps, current display)
  re-applied to host on each successful reconnect, since host treats
  every session as fresh and resets to defaults.
- Architecture: `session` is a let-binding mutated on reconnect; HUD
  button closures read it at click-time so they automatically retarget.
  Input modules (mouse/keyboard/clipboard) get a Proxy that forwards
  method calls to whatever session is current — avoids re-binding
  window/canvas listeners on each reconnect.

M6l — H.264 video decode (Annex-B + SPS-derived codec string)
- decode/bitstream.ts: iterate Annex-B NAL units, derive avc1.PPCCLL
  from the keyframe's inline SPS (host's hwcodec defaults to high
  profile; a hardcoded baseline string would make WebCodecs refuse the
  stream).
- Defer H.264 decoder configure until first keyframe arrives.
- VP9 codec string corrected from level 1.0 (vp09.00.10.08) to level
  5.0 (vp09.00.50.08) — wrong level was probably forcing software
  decode in some browsers.
- Default prefer flipped to VP8 (cheapest software encoder; H.264 path
  stays implemented for hosts with hwcodec/nvenc).

M6m — docs/CONFIGURATION.md "Web client" section: routes, browser
matrix, network requirements (relay reachability + reverse-proxy WS
upgrade), feature status table, codec selection rationale, the
recv/dec/draw HUD diagnostic, build commands.

Bundle: 535 KB / ~75 KB gzipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 17:43:23 +02:00
parent d07e98e607
commit 4308a2f112
12 changed files with 893 additions and 138 deletions
+113
View File
@@ -317,6 +317,119 @@ for the dashboard with no separate session model.
--- ---
## Web client
Admins can remote-control any peer directly from the browser — no desktop
RustDesk install required. The Devices page row dropdown surfaces a
**Connect** button that opens `/admin/connect/<peer_id>` in a new tab.
The page is a TypeScript SPA bundled into hbbs (`include_bytes!` from
`web_client/dist/`), so there's no separate process or service to run.
### Routes
| Route | Auth | Purpose |
|---|---|---|
| `/admin/connect/:peer_id` | cookie + admin | Server-rendered HTML wrapper that injects per-request config (rendezvous host, relay host, server pk, peer id, admin display name) into a `<script id="custom-config">` tag, then loads `bundle.js`. |
| `/admin/connect/assets/bundle.js` | cookie + admin | The compiled SPA. ~535 KB, ~75 KB gzipped. |
| `/admin/connect/assets/bundle.css` | cookie + admin | Minimal dark theme + reconnect overlay. |
### How it talks to peers
The browser opens **WebSockets directly** to the existing rendezvous
(`hbbs:21118`, the `ws_port`) and relay (`hbbr:21119`, `port + 2`)
sockets. The wire shape — protobuf `RendezvousMessage` + secretbox-
encrypted `Message` stream — is identical to what the desktop client
uses; the SPA is just a from-scratch reimplementation of the protocol
talking through WS frames instead of TCP.
Browsers can't do UDP NAT-punching, so the SPA always sets `force_relay
= true` in `PunchHoleRequest` and follows the `RelayResponse` branch
unconditionally. **Direct peer-to-peer is never attempted.**
### Browser requirements
- **Secure context** — page must be served over HTTPS or `http://localhost`. WebCodecs `VideoDecoder` / `AudioDecoder` are gated to secure contexts. Plain `http://lan-ip:21114` will fail; either use TLS in front of hbbs or do `ssh -L 21114:localhost:21114 hbbs-host` and connect via `http://localhost:21114`.
- **Browser version** — Chrome 94+, Edge 94+, Firefox 130+, Safari 16.4+. Older browsers lack WebCodecs and will throw "VideoDecoder unavailable" on session start.
- **Clipboard permission** — Firefox refuses `navigator.clipboard.readText()` by default, so admin → host paste is silently no-op there. Host → admin paste works (it uses `writeText`, which only needs a recent click). Chrome/Safari prompt once.
### Network requirements
- The **relay host** advertised to clients (`--relay-servers=<HOSTS>` on hbbs) must resolve and be reachable from the end-user's browser on port 21119. The relay is what carries the actual session bytes — if a user's browser can't open `ws://<relay-host>:21119/`, the session dies after the rendezvous step. A common gotcha: setting `--relay-servers=hbbr-internal.local` works for desktop clients on the LAN but breaks for browsers off-LAN.
- **Reverse proxies** must forward the WebSocket upgrade for both 21118 (rendezvous) and 21119 (relay). Caddy: `reverse_proxy /ws/* hbbs:21118` plus equivalent for 21119; nginx: the standard `proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";` block.
- Audit rows are written under the admin's cookie via the existing `/api/audit/conn` endpoint; no new server endpoint.
### Features
| Feature | Status |
|---|---|
| Video (VP8 / VP9 / H.264) | ✅ — preference is VP8 (lightest software encoder; H.264 path also implemented for hosts with hwcodec) |
| Audio (Opus) | ✅ |
| Mouse + keyboard input | ✅ — Legacy keyboard mode; Translate mode silently drops Unicode/ControlKey payloads on the host |
| Text clipboard sync (both directions) | ✅ — handles both single-format `Clipboard` and `MultiClipboards` (peers ≥ 1.3.0) |
| Multi-monitor switching | ✅ — `SwitchDisplay` + `CaptureDisplays` two-message dance for hosts ≥ 1.2.4; mouse coords offset by display's virtual-desktop origin |
| Image quality presets (Low/Balanced/Best) | ✅ |
| Custom FPS (15/30/60) | ✅ — host caps at 30 unless `allow_more_fps` is advertised |
| Mute toggle | ✅ — also tells host to stop encoding audio (saves CPU + relay bandwidth) |
| Ctrl+Alt+Del | ✅ — Windows hosts only (server-side `#[cfg(windows)]`) |
| Auto-reconnect on transient drops | ✅ — up to 10 attempts, exponential backoff 1s → 30s, dim overlay during retry, user options re-applied on success |
| File transfer | ❌ deferred — separate `FileAction` family, double the surface area |
| AV1 / H.265 decode | ❌ deferred — VP8/VP9/H.264 covers the common cases |
| IME / compose input | ❌ deferred — needs `compositionend` + `KeyEvent.seq` |
| Touch gestures | ❌ deferred |
| Cursor sprite rendering | ❌ deferred — host-side cursor visible in the video; we don't draw a separate one |
### Codec selection
The SPA advertises VP8/VP9/H.264 decode and prefers VP8. The host's
codec picker (`libs/scrap/src/common/codec.rs`) honours the preference
when the host has the matching encoder available, else falls back to
its "auto" path (H.265 → H.264 → AV1/VP9/VP8).
VP8 is the default because it's the cheapest software encoder; on a
host without hwcodec, VP9 software-encode caps screen sharing at single
digits FPS, while VP8 keeps headroom for the screen-capture pipeline.
On a host *with* hwcodec H.264 (nvenc/qsv on most Windows boxes), flip
the preference in `web_client/src/transport/session.ts` to
`PreferCodec.H264` — the SPA's H.264 path parses SPS to derive the
correct `avc1.PPCCLL` codec string for WebCodecs.
### Performance gotchas
The HUD shows three live numbers: `recv` (frames/sec arriving from the
relay), `dec` (frames/sec the browser decoded), `draw` (frames/sec
painted to canvas). Use them to localise FPS issues:
- All three low → host is encoding slowly. Either CPU-bound (no
hwcodec, VP9 chosen) or QoS-throttled by host based on `TestDelay`
RTT measurements.
- `recv` high, `dec` low → browser fell back to software decode. Check
the codec string at the end of the HUD line; mismatched profile/level
(e.g. `avc1.42E01E` for a high-profile stream) forces software.
- `dec` high, `draw` low → main thread is overwhelmed (very rare with
hardware decode).
### Building the bundle
The bundle is committed under `web_client/dist/` so a Cargo build
doesn't need a Node toolchain. To regenerate after editing the SPA:
```sh
cd web_client
npm install # one-time
npm run build # → dist/bundle.{js,css}
git add dist/
cd .. && cargo build --release -p hbbs
```
To regenerate protobuf bindings after a `libs/hbb_common/protos/*.proto`
change:
```sh
cd web_client && npm run protogen
```
---
## Database ## Database
SQLite, file `db_v2.sqlite3` in hbbs's working directory. Tables created SQLite, file `db_v2.sqlite3` in hbbs's working directory. Tables created
+28
View File
@@ -142,6 +142,34 @@ html, body {
} }
.hud-btn:hover { background: #475569; } .hud-btn:hover { background: #475569; }
.hud-select {
background: #334155;
border: 0;
color: #e2e8f0;
padding: 2px 4px;
border-radius: 3px;
font-size: 11px;
font-family: inherit;
cursor: pointer;
}
.hud-select:hover { background: #475569; }
.reconnect-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(15, 23, 42, 0.65);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
color: #e2e8f0;
font-family: inherit;
font-size: 15px;
z-index: 20;
pointer-events: none;
}
.error { .error {
background: rgba(220, 38, 38, 0.15); background: rgba(220, 38, 38, 0.15);
border: 1px solid rgba(220, 38, 38, 0.4); border: 1px solid rgba(220, 38, 38, 0.4);
+3 -3
View File
File diff suppressed because one or more lines are too long
+4 -4
View File
File diff suppressed because one or more lines are too long
+67
View File
@@ -0,0 +1,67 @@
// Annex-B helpers for H.264 / H.265 video framing.
//
// The host encodes H.264/H.265 via ffmpeg (hwcodec crate) and the default
// muxer-less output is Annex-B: NAL units separated by 3- or 4-byte start
// codes (`00 00 01` or `00 00 00 01`). SPS/PPS are inlined before each IDR.
//
// WebCodecs' VideoDecoder accepts Annex-B chunks directly when configured
// without a `description` field — in that mode the browser scans for start
// codes itself. We just need to:
// 1. Find the first SPS NAL inside a keyframe to derive the avc1/hvc1
// codec string the decoder requires.
// 2. Pass each frame's bytes into the decoder unmodified.
const NAL_UNIT_TYPE_MASK_H264 = 0x1f;
const SPS_NAL_TYPE_H264 = 7;
/** Iterate NAL units in an Annex-B stream. Yields `payload` excluding the
* start code; first byte is the NAL header. */
export function* iterAnnexBNalu(data: Uint8Array): Generator<Uint8Array> {
// Locate every start code (00 00 01 or 00 00 00 01), then yield the
// bytes between consecutive start codes.
const starts: number[] = [];
for (let i = 0; i + 2 < data.length; i++) {
if (data[i] === 0 && data[i + 1] === 0) {
if (data[i + 2] === 1) {
starts.push(i + 3);
i += 2;
} else if (i + 3 < data.length && data[i + 2] === 0 && data[i + 3] === 1) {
starts.push(i + 4);
i += 3;
}
}
}
for (let k = 0; k < starts.length; k++) {
const start = starts[k]!;
const end = k + 1 < starts.length
? starts[k + 1]! - (data[starts[k + 1]! - 4] === 0 ? 4 : 3)
: data.length;
if (end > start) {
yield data.subarray(start, end);
}
}
}
/**
* Build an `avc1.PPCCLL` codec string from a keyframe's SPS NAL unit.
*
* SPS layout (after NAL header byte):
* byte 0: profile_idc
* byte 1: constraint_set_flags (low 6 bits) | reserved_zero_2bits
* byte 2: level_idc
*
* Returns null if no SPS found in the buffer.
*/
export function deriveH264CodecString(keyframe: Uint8Array): string | null {
for (const nalu of iterAnnexBNalu(keyframe)) {
if (nalu.length < 4) continue;
const naluType = nalu[0]! & NAL_UNIT_TYPE_MASK_H264;
if (naluType !== SPS_NAL_TYPE_H264) continue;
const profile = nalu[1]!;
const constraints = nalu[2]!;
const level = nalu[3]!;
const hex = (n: number): string => n.toString(16).padStart(2, "0").toUpperCase();
return `avc1.${hex(profile)}${hex(constraints)}${hex(level)}`;
}
return null;
}
+106 -30
View File
@@ -1,15 +1,19 @@
// Video decode pipeline using WebCodecs VideoDecoder. // Video decode pipeline using WebCodecs VideoDecoder.
// //
// M6d covers VP9 (the host's default codec — see the desktop client's // Wire format per codec (all from the host's hwcodec/vpx encoders):
// VpxEncoderConfig). VP9 frame data on the wire is the raw VP9 bitstream; // - VP9 / VP8 / AV1: raw bitstream per frame, no container framing.
// WebCodecs' VideoDecoder accepts each frame as an EncodedVideoChunk with // - H.264 / H.265: Annex-B (start-code-prefixed NAL units) with inline
// type "key" or "delta" derived from the EncodedVideoFrame.key flag. // SPS/PPS at every IDR. WebCodecs accepts this mode
// when `description` is omitted from the configure().
// //
// The decoder runs asynchronously: we push chunks in via `decode()`, the // Codec string for H.264 must encode the actual profile/level the stream
// `output` callback fires later with a VideoFrame. We close each frame // uses (e.g. `avc1.640028` for high@4.0). Hardcoding `avc1.42E01E` (the
// immediately after drawing to release the GPU texture. // baseline default) means the browser refuses streams encoded by nvenc
// or qsv at high profile. We parse the SPS on the first keyframe and
// build the right string from profile_idc/constraint_set_flags/level_idc.
import { hbb } from "../proto/generated.js"; import { hbb } from "../proto/generated.js";
import { deriveH264CodecString } from "./bitstream.js";
export type FrameSink = (frame: VideoFrame) => void; export type FrameSink = (frame: VideoFrame) => void;
@@ -17,6 +21,12 @@ export class VideoPipeline {
private decoder: VideoDecoder | null = null; private decoder: VideoDecoder | null = null;
private currentCodec = ""; private currentCodec = "";
private onFrame: FrameSink; private onFrame: FrameSink;
// Diagnostic counters — log per-second so we can see whether a low FPS
// is host-encode-bound (low frames-in) or browser-decode-bound (high
// frames-in, low frames-out). Cleared each tick.
private framesIn = 0;
private framesOut = 0;
private lastDiagAt = performance.now();
constructor(onFrame: FrameSink) { constructor(onFrame: FrameSink) {
this.onFrame = onFrame; this.onFrame = onFrame;
@@ -29,55 +39,116 @@ export class VideoPipeline {
} }
} }
/** Snapshot of receive/decode rates (frames per second over the last
* second). Used by the HUD diag line. */
diagStats(): { recvFps: number; decodeFps: number; codec: string } {
const now = performance.now();
const dt = (now - this.lastDiagAt) / 1000 || 1;
const recvFps = Math.round(this.framesIn / dt);
const decodeFps = Math.round(this.framesOut / dt);
this.framesIn = 0;
this.framesOut = 0;
this.lastDiagAt = now;
return { recvFps, decodeFps, codec: this.currentCodec };
}
/** Push one EncodedVideoFrames container (oneof from a VideoFrame Message). */ /** Push one EncodedVideoFrames container (oneof from a VideoFrame Message). */
pushVideoFrame(vf: hbb.IVideoFrame): void { pushVideoFrame(vf: hbb.IVideoFrame): void {
const frames = vf.vp9s?.frames || vf.vp8s?.frames || vf.av1s?.frames || vf.h264s?.frames || vf.h265s?.frames; const family = this.detectFamily(vf);
const codec = this.detectCodec(vf); if (!family) return;
if (!frames || frames.length === 0 || !codec) { const frames =
return; vf.vp9s?.frames || vf.vp8s?.frames || vf.av1s?.frames ||
} vf.h264s?.frames || vf.h265s?.frames;
if (codec !== this.currentCodec) { if (!frames || frames.length === 0) return;
this.configureCodec(codec);
}
for (const f of frames) { for (const f of frames) {
this.decode(f, codec); this.handleFrame(family, f);
} }
} }
private detectCodec(vf: hbb.IVideoFrame): string { private detectFamily(vf: hbb.IVideoFrame): "vp9" | "vp8" | "av1" | "h264" | "h265" | "" {
if (vf.vp9s) return "vp09.00.10.08"; // VP9 profile 0, 8-bit if (vf.vp9s) return "vp9";
if (vf.vp8s) return "vp8"; if (vf.vp8s) return "vp8";
if (vf.av1s) return "av01.0.04M.08"; // AV1 main profile, level 4.0, 8-bit if (vf.av1s) return "av1";
if (vf.h264s) return "avc1.42E01E"; // H.264 baseline (will need SPS-driven config in M6f) if (vf.h264s) return "h264";
if (vf.h265s) return "hvc1.1.6.L93.B0"; if (vf.h265s) return "h265";
return ""; return "";
} }
private configureCodec(codec: string): void { /** Build the WebCodecs codec string for this frame's stream. For VP9
* we use level 5.0 (covers up to 4K@30) so the browser's hardware
* path engages — `vp09.00.10.08` (level 1.0) had been telling Chrome
* the stream was tiny, which can force the software fallback. */
private codecStringFor(family: string, frameData?: Uint8Array): string | null {
switch (family) {
case "vp9": return "vp09.00.50.08";
case "vp8": return "vp8";
case "av1": return "av01.0.04M.08";
case "h264": return frameData ? deriveH264CodecString(frameData) : null;
case "h265": return "hvc1.1.6.L93.B0"; // best-effort; SPS parsing for HEVC is more involved
default: return null;
}
}
private handleFrame(
family: "vp9" | "vp8" | "av1" | "h264" | "h265",
f: hbb.IEncodedVideoFrame,
): void {
if (!f.data || f.data.length === 0) return;
const data = f.data as Uint8Array;
const isKey = !!f.key;
this.framesIn++;
// For H.264 we MUST see an SPS before we can configure the decoder
// (the codec string depends on its profile/level bytes). The host
// sends SPS+PPS inline on every keyframe, so the first keyframe is
// sufficient. Drop deltas that arrive before the first key.
if (family === "h264" && !this.decoder) {
if (!isKey) return;
const codec = this.codecStringFor("h264", data);
if (!codec) {
console.warn("[rustdesk-web] H.264 keyframe missing SPS — dropping");
return;
}
this.configureDecoder(codec);
} else if (family !== "h264" && (!this.decoder || this.currentCodec !== this.codecStringFor(family))) {
const codec = this.codecStringFor(family);
if (!codec) return;
this.configureDecoder(codec);
}
this.decode(data, isKey, f.pts);
}
private configureDecoder(codec: string): void {
if (this.decoder) { if (this.decoder) {
this.decoder.close(); try { this.decoder.close(); } catch { /* ignore */ }
} }
this.currentCodec = codec; this.currentCodec = codec;
this.decoder = new VideoDecoder({ this.decoder = new VideoDecoder({
output: (frame) => this.onFrame(frame), output: (frame) => {
this.framesOut++;
this.onFrame(frame);
},
error: (e) => { error: (e) => {
console.error("[rustdesk-web] VideoDecoder error:", e); console.error("[rustdesk-web] VideoDecoder error:", e);
}, },
}); });
// optimizeForLatency hints the decoder to skip frame reordering
// buffers — important for screen sharing where each frame matters
// and we don't have B-frames to reorder anyway.
this.decoder.configure({ this.decoder.configure({
codec, codec,
// optimizeForLatency hints the decoder to skip frame reordering buffers.
optimizeForLatency: true, optimizeForLatency: true,
}); });
} }
private decode(f: hbb.IEncodedVideoFrame, _codec: string): void { private decode(data: Uint8Array, isKey: boolean, ptsField: number | Long | null | undefined): void {
if (!this.decoder || !f.data || f.data.length === 0) return; if (!this.decoder) return;
const data = f.data as Uint8Array; const pts = typeof ptsField === "number" ? ptsField : Number(ptsField || 0);
const pts = typeof f.pts === "number" ? f.pts : Number(f.pts || 0);
try { try {
const chunk = new EncodedVideoChunk({ const chunk = new EncodedVideoChunk({
type: f.key ? "key" : "delta", type: isKey ? "key" : "delta",
timestamp: pts * 1000, // ms → microseconds timestamp: pts * 1000, // ms → microseconds
data, data,
}); });
@@ -94,3 +165,8 @@ export class VideoPipeline {
} }
} }
} }
// protobufjs static-module emits `pts: number | Long | null` for int64
// fields. We don't import the Long shim, so use a structural type for the
// signature.
type Long = { low: number; high: number; unsigned: boolean; toNumber(): number };
+99
View File
@@ -0,0 +1,99 @@
// Text clipboard sync, both directions.
//
// Host -> browser: incoming Message{clipboard: {format: Text, content}}
// → navigator.clipboard.writeText (Async Clipboard API).
// Browsers gate this on a "user gesture" — silently
// fails the first time until the user has clicked
// somewhere on the page. We swallow the rejection.
//
// Browser -> host: global `paste` event → read clipboardData → send
// Message{clipboard: {format: Text, content: utf8 bytes,
// compress: false}}.
//
// Image / RTF / HTML formats are deferred to a follow-up; only Text in v1.
// The wire shape is documented at libs/hbb_common/protos/message.proto:
// ClipboardFormat: Text=0 / Rtf=1 / Html=2 / Image*=21..23 / Special=31
import { hbb } from "../proto/generated.js";
import type { Session } from "../transport/session.js";
/** Track the last value we wrote so we don't echo our own paste back to the host. */
let lastWrittenText = "";
/** Receive an incoming Clipboard (single, format-typed) and apply it locally. */
export async function applyIncoming(c: hbb.IClipboard): Promise<void> {
if (c.format !== hbb.ClipboardFormat.Text) return;
if (!c.content) return;
const text = new TextDecoder().decode(c.content as Uint8Array);
if (!text) return;
try {
await navigator.clipboard.writeText(text);
lastWrittenText = text;
} catch {
// Browsers refuse writeText() without a recent user gesture. The
// user can press any key once to "grant" gesture state. Until then
// we silently drop. Visible failure UX is more annoying than silent.
}
}
/**
* Receive an incoming MultiClipboards (newer wire format used by RustDesk
* peers >= 1.3.0 against non-iOS controllers — see
* /Users/sn0/Desktop/rustdesk/src/clipboard.rs:is_support_multi_clipboard).
* Find the first Text-format entry and apply it.
*/
export async function applyIncomingMulti(mc: hbb.IMultiClipboards): Promise<void> {
const list = mc.clipboards || [];
for (const c of list) {
if (c.format === hbb.ClipboardFormat.Text) {
await applyIncoming(c);
return;
}
}
}
/**
* Read the browser's local clipboard text and send it to the host as
* Message{clipboard: {format: Text, content: utf8 bytes}}. Used by the
* keyboard handler on Cmd+V / Ctrl+V — `paste` events don't reliably fire
* on `window` in Firefox without a focused input element, so we drive
* clipboard sync from the keystroke instead.
*/
export async function pushClipboardText(session: Session): Promise<void> {
let text = "";
try {
text = await navigator.clipboard.readText();
} catch {
// User hasn't granted clipboard permission, or no recent gesture.
return;
}
if (!text) return;
if (text === lastWrittenText) return; // host pushed this to us already; no echo
const content = new TextEncoder().encode(text);
await session.send(hbb.Message.create({
clipboard: hbb.Clipboard.create({
compress: false,
format: hbb.ClipboardFormat.Text,
content,
}),
})).catch(() => { /* relay closed */ });
}
/** Defensive: also hook the standard `paste` event in case the browser
* fires it (Chrome does, Firefox often doesn't). */
export function bindClipboard(session: Session): () => void {
const onPaste = (e: ClipboardEvent): void => {
const text = e.clipboardData?.getData("text/plain");
if (!text || text === lastWrittenText) return;
const content = new TextEncoder().encode(text);
session.send(hbb.Message.create({
clipboard: hbb.Clipboard.create({
compress: false,
format: hbb.ClipboardFormat.Text,
content,
}),
})).catch(() => { /* relay closed */ });
};
window.addEventListener("paste", onPaste);
return () => window.removeEventListener("paste", onPaste);
}
+24 -4
View File
@@ -20,6 +20,7 @@
import { hbb } from "../proto/generated.js"; import { hbb } from "../proto/generated.js";
import type { Session } from "../transport/session.js"; import type { Session } from "../transport/session.js";
import type { CanvasView } from "../ui/canvas.js"; import type { CanvasView } from "../ui/canvas.js";
import { pushClipboardText } from "./clipboard.js";
/** /**
* KeyboardEvent.code → ControlKey enum value. Covers the named keys; any * KeyboardEvent.code → ControlKey enum value. Covers the named keys; any
@@ -106,15 +107,25 @@ export function bindKeyboard(_canvas: CanvasView, session: Session): () => void
} }
const ck = CODE_TO_CONTROL[e.code]; const ck = CODE_TO_CONTROL[e.code];
const hotkeyModifier = e.ctrlKey || e.altKey || e.metaKey;
let payload: hbb.IKeyEvent; let payload: hbb.IKeyEvent;
if (ck !== undefined) { if (ck !== undefined) {
// Special key — fire on both keydown and keyup. // Special key — fire on both keydown and keyup.
payload = { control_key: ck, down }; payload = { control_key: ck, down };
} else if (e.key.length === 1) { } else if (e.key.length === 1) {
// Printable char. Host's process_unicode does a single key_click, const codepoint = e.key.codePointAt(0)!;
// so we only send on keydown — keyup would double-type. if (hotkeyModifier) {
if (!down) return; // Printable + Ctrl/Alt/Cmd → must use Chr (scancode-style) so the
payload = { unicode: e.key.codePointAt(0)!, down: true }; // host's process_chr respects the held modifiers. Unicode payloads
// go through process_unicode which ignores modifiers (just types
// the char), breaking Ctrl+C, Ctrl+V, etc. Fire on both edges.
payload = { chr: codepoint, down };
} else {
// Plain typing — Unicode keydown only (host does a single
// key_click; a keyup would double-type the char).
if (!down) return;
payload = { unicode: codepoint, down: true };
}
} else { } else {
// Unmapped key — silently drop. Rare in normal use. // Unmapped key — silently drop. Rare in normal use.
return; return;
@@ -122,6 +133,15 @@ export function bindKeyboard(_canvas: CanvasView, session: Session): () => void
payload.modifiers = modifierList(e); payload.modifiers = modifierList(e);
payload.mode = hbb.KeyboardMode.Legacy; payload.mode = hbb.KeyboardMode.Legacy;
// Cmd+V / Ctrl+V → read the local clipboard and push it to the host
// BEFORE the V keystroke so the host's paste hotkey lands on the
// freshly-synced text. Non-blocking — the keystroke goes through
// either way; if the clipboard read fails (permission, empty), the
// host pastes whatever it had.
if (down && hotkeyModifier && e.code === "KeyV") {
pushClipboardText(session).catch(() => { /* swallowed */ });
}
session.send(hbb.Message.create({ key_event: hbb.KeyEvent.create(payload) })) session.send(hbb.Message.create({ key_event: hbb.KeyEvent.create(payload) }))
.catch(() => { /* relay closed */ }); .catch(() => { /* relay closed */ });
+22 -6
View File
@@ -8,9 +8,13 @@
// 0x08=back, 0x10=forward // 0x08=back, 0x10=forward
// //
// Coordinate translation: // Coordinate translation:
// The host expects coordinates in its own display pixel space. We map // The host expects coordinates in *virtual desktop space* — i.e.
// browser canvas-rect coordinates → peer display coordinates by scaling // relative to the union of all monitors, not relative to the active
// against the displayed CanvasView.size(). // display. So we map browser canvas-rect coords → active-display
// pixel coords (via canvas.size scaling), then add the active
// display's origin (DisplayInfo.x / .y). Without the offset, clicks
// on display 2 land on display 1 because both share canvas (0,0)
// but only display 1's origin is (0,0) in virtual desktop space.
import { hbb } from "../proto/generated.js"; import { hbb } from "../proto/generated.js";
import type { Session } from "../transport/session.js"; import type { Session } from "../transport/session.js";
@@ -38,7 +42,13 @@ function buttonBitForEvent(button: number): number {
} }
} }
export function bindMouse(canvas: CanvasView, session: Session): () => void { export function bindMouse(
canvas: CanvasView,
session: Session,
/** Returns the current display's virtual-desktop origin (DisplayInfo.x, .y).
* Polled per-event so display switches take effect immediately. */
getOrigin: () => { x: number; y: number },
): () => void {
const el = canvas.el(); const el = canvas.el();
const sendMouse = (mask: number, x: number, y: number, ev: { altKey: boolean; ctrlKey: boolean; shiftKey: boolean; metaKey: boolean }): void => { const sendMouse = (mask: number, x: number, y: number, ev: { altKey: boolean; ctrlKey: boolean; shiftKey: boolean; metaKey: boolean }): void => {
@@ -57,7 +67,8 @@ export function bindMouse(canvas: CanvasView, session: Session): () => void {
})).catch(() => { /* relay closed */ }); })).catch(() => { /* relay closed */ });
}; };
// Translate viewport pixel coordinates → peer display pixels. // Translate viewport pixel coordinates → virtual-desktop pixels
// (active display origin + position within display).
const mapCoords = (clientX: number, clientY: number): { x: number; y: number } | null => { const mapCoords = (clientX: number, clientY: number): { x: number; y: number } | null => {
const sz = canvas.size(); const sz = canvas.size();
if (!sz.width || !sz.height) return null; if (!sz.width || !sz.height) return null;
@@ -73,7 +84,12 @@ export function bindMouse(canvas: CanvasView, session: Session): () => void {
const px = (clientX - offsetX) / scale; const px = (clientX - offsetX) / scale;
const py = (clientY - offsetY) / scale; const py = (clientY - offsetY) / scale;
if (px < 0 || py < 0 || px > sz.width || py > sz.height) return null; if (px < 0 || py < 0 || px > sz.width || py > sz.height) return null;
return { x: px, y: py }; // Offset by the active display's virtual-desktop origin. For a
// single-display host this is (0,0). For multi-display, display 2
// typically starts at x = display1.width (or wherever Windows put
// it in Display Settings).
const origin = getOrigin();
return { x: px + origin.x, y: py + origin.y };
}; };
// Track currently-pressed buttons so we can include them in move events. // Track currently-pressed buttons so we can include them in move events.
+391 -88
View File
@@ -20,6 +20,11 @@ import { AudioPipeline } from "./decode/audio.js";
import { CanvasView } from "./ui/canvas.js"; import { CanvasView } from "./ui/canvas.js";
import { bindMouse } from "./input/mouse.js"; import { bindMouse } from "./input/mouse.js";
import { bindKeyboard, sendCtrlAltDel } from "./input/keyboard.js"; import { bindKeyboard, sendCtrlAltDel } from "./input/keyboard.js";
import {
applyIncoming as applyClipboard,
applyIncomingMulti as applyClipboardMulti,
bindClipboard,
} from "./input/clipboard.js";
interface CustomConfig { interface CustomConfig {
api_server: string; api_server: string;
@@ -105,32 +110,54 @@ async function main(): Promise<void> {
while (true) { while (true) {
const password = await askPassword(cfg, lastErr); const password = await askPassword(cfg, lastErr);
try { try {
await connectAndRun(cfg, serverPk, password); const ready = await connectOnce(cfg, serverPk, password, /*statusUI=*/true);
return; // success — connectAndRun rendered the success UI // First connect succeeded — hand off to the session loop, which
// owns reconnect.
await runSession(cfg, serverPk, password, ready);
return;
} catch (e) { } catch (e) {
const msg = String(e); const msg = String(e);
if (msg.includes("Wrong Password") || msg.includes("Bad password") || msg.toLowerCase().includes("password")) { if (msg.includes("Wrong Password") || msg.includes("Bad password") || msg.toLowerCase().includes("password")) {
lastErr = "Wrong password — try again."; lastErr = "Wrong password — try again.";
continue; 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); console.error("[rustdesk-web] fatal:", e);
status(`<div class="error"><h1>Connection failed</h1><pre>${escapeHtml(msg)}</pre></div>`);
return; return;
} }
} }
} }
async function connectAndRun(cfg: CustomConfig, serverPk: Uint8Array, password: string): Promise<void> { interface ConnectResult {
// ============ STEP 1 — PunchHoleRequest with nat_type=SYMMETRIC ============ session: Session;
status(`<h1>Connecting to ${escapeHtml(cfg.peer_id)}…</h1><p class="muted">1/3: rendezvous</p>`); peerInfo: hbb.IPeerInfo;
preloginExtras: hbb.Message[];
}
/**
* Single connect attempt: rendezvous → verify peer pk → relay WS →
* secure handshake → login. Returns a live, logged-in Session.
*
* Used both for the initial connect (with statusUI=true to show
* step-by-step progress) and for each reconnect attempt (statusUI=false
* — the reconnect overlay is its own UI element, the placeholder is
* already gone by then).
*/
async function connectOnce(
cfg: CustomConfig,
serverPk: Uint8Array,
password: string,
statusUI: boolean,
): Promise<ConnectResult> {
const setStatus = (html: string): void => { if (statusUI) status(html); };
setStatus(`<h1>Connecting to ${escapeHtml(cfg.peer_id)}…</h1><p class="muted">1/3: rendezvous</p>`);
const wsRendezvous = rendezvousUrl(cfg.rendezvous_server); const wsRendezvous = rendezvousUrl(cfg.rendezvous_server);
const ph = await punchHole(wsRendezvous, cfg.peer_id, cfg.key, CLIENT_VERSION); const ph = await punchHole(wsRendezvous, cfg.peer_id, cfg.key, CLIENT_VERSION);
if (ph.signedIdPk.length === 0) { if (ph.signedIdPk.length === 0) {
throw new Error("Server didn't sign the peer key — update the host's RustDesk to 1.4+."); 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 peerSignPk: Uint8Array;
let verifiedId: string; let verifiedId: string;
try { try {
@@ -154,8 +181,7 @@ async function connectAndRun(cfg: CustomConfig, serverPk: Uint8Array, password:
console.warn("[rustdesk-web] no peer uuid in RelayResponse — fallback (relay pairing will likely fail)"); console.warn("[rustdesk-web] no peer uuid in RelayResponse — fallback (relay pairing will likely fail)");
} }
// ============ STEP 3 — open relay WS ============ setStatus(`<h1>Connecting to ${escapeHtml(cfg.peer_id)}…</h1><p class="muted">2/3: relay handshake</p>`);
status(`<h1>Connecting to ${escapeHtml(cfg.peer_id)}…</h1><p class="muted">2/3: relay handshake</p>`);
const wsRelay = relayUrl(ph.relayServer); const wsRelay = relayUrl(ph.relayServer);
const relay = await Relay.connect({ const relay = await Relay.connect({
wsUrl: wsRelay, wsUrl: wsRelay,
@@ -164,11 +190,9 @@ async function connectAndRun(cfg: CustomConfig, serverPk: Uint8Array, password:
licenceKey: cfg.key, licenceKey: cfg.key,
}); });
// ============ STEP 4 — secure handshake + login ============ setStatus(`<h1>Connecting to ${escapeHtml(cfg.peer_id)}…</h1><p class="muted">3/3: secure handshake + login</p>`);
status(`<h1>Connecting to ${escapeHtml(cfg.peer_id)}…</h1><p class="muted">3/3: secure handshake + login</p>`);
let ready;
try { try {
ready = await Session.open({ const ready = await Session.open({
relay, relay,
peerId: cfg.peer_id, peerId: cfg.peer_id,
peerSignPk, peerSignPk,
@@ -177,22 +201,21 @@ async function connectAndRun(cfg: CustomConfig, serverPk: Uint8Array, password:
clientVersion: CLIENT_VERSION, clientVersion: CLIENT_VERSION,
sessionId: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER), sessionId: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER),
}); });
(window as unknown as { __rdw: unknown }).__rdw = { relay, session: ready.session, peerInfo: ready.peerInfo, cfg };
return ready;
} catch (e) { } catch (e) {
relay.close(); relay.close();
throw e; 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);
} }
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
async function runSession( async function runSession(
cfg: CustomConfig, cfg: CustomConfig,
session: Session, serverPk: Uint8Array,
peerInfo: hbb.IPeerInfo, password: string,
preloginExtras: hbb.Message[], initial: ConnectResult,
): Promise<void> { ): Promise<void> {
const root = document.getElementById("root"); const root = document.getElementById("root");
if (!root) return; if (!root) return;
@@ -205,18 +228,64 @@ async function runSession(
const pipeline = new VideoPipeline((frame) => canvas.draw(frame)); const pipeline = new VideoPipeline((frame) => canvas.draw(frame));
const audio = new AudioPipeline(); const audio = new AudioPipeline();
// Wire input. // Mutable session — closures below read the current value at call
const detachMouse = bindMouse(canvas, session); // time (let-binding capture, not value capture), so reassigning on
const detachKb = bindKeyboard(canvas, session); // reconnect makes every HUD button automatically forward to the new
// session without re-binding handlers.
let session = initial.session;
let peerInfo = initial.peerInfo;
let preloginExtras = initial.preloginExtras;
// Track the active display. Mouse coords need the display's virtual-
// desktop origin (DisplayInfo.{x, y}) added to them so clicks on
// display 2 don't land on display 1.
let displays = peerInfo.displays || [];
let currentDisplay = peerInfo.current_display || 0;
if (currentDisplay < 0 || currentDisplay >= displays.length) currentDisplay = 0;
const getOrigin = (): { x: number; y: number } => {
const d = displays[currentDisplay];
return { x: d?.x || 0, y: d?.y || 0 };
};
// User-set option state. The host doesn't remember these across
// sessions, so we re-send them on every reconnect.
let userQuality: hbb.ImageQuality | null = null;
let userFps: number | null = null;
// Wire input. Handlers close over the `session` let, so reassigning
// it on reconnect transparently retargets them.
let detachMouse = bindMouse(canvas, getSessionProxy(), getOrigin);
let detachKb = bindKeyboard(canvas, getSessionProxy());
let detachClip = bindClipboard(getSessionProxy());
function getSessionProxy(): Session {
// The input modules expect a Session; give them a Proxy that
// forwards method calls to whatever session is current at call
// time. Avoids re-binding window/canvas listeners on each reconnect.
return new Proxy({} as Session, {
get(_t, prop) {
const target = session as unknown as Record<string | symbol, unknown>;
const value = target[prop];
if (typeof value === "function") {
return (value as (...args: unknown[]) => unknown).bind(session);
}
return value;
},
});
}
// Focus the canvas so keystrokes start flowing immediately. // Focus the canvas so keystrokes start flowing immediately.
canvas.el().focus(); canvas.el().focus();
// Wipe the initial "connecting…" text so it doesn't sit forever
// *next to* the controls we're about to append.
hudEl.textContent = "";
// FPS / dims line (left of HUD). // FPS / dims line (left of HUD).
const fpsLine = document.createElement("span"); const fpsLine = document.createElement("span");
fpsLine.className = "hud-fps"; fpsLine.className = "hud-fps";
hudEl.appendChild(fpsLine); hudEl.appendChild(fpsLine);
// Mute toggle. // Mute toggle. Also tells the peer to stop encoding audio while muted
// (saves their CPU + relay bandwidth — re-enabled on unmute).
const muteBtn = document.createElement("button"); const muteBtn = document.createElement("button");
muteBtn.className = "hud-btn"; muteBtn.className = "hud-btn";
muteBtn.textContent = "🔇 Mute"; muteBtn.textContent = "🔇 Mute";
@@ -224,12 +293,120 @@ async function runSession(
const muted = !audio.isMuted(); const muted = !audio.isMuted();
audio.setMuted(muted); audio.setMuted(muted);
muteBtn.textContent = muted ? "🔈 Unmute" : "🔇 Mute"; muteBtn.textContent = muted ? "🔈 Unmute" : "🔇 Mute";
// BoolOption: NotSet=0, No=1, Yes=2 — explicit Yes/No; NotSet would
// leave the host's existing setting alone.
session.send(hbb.Message.create({
misc: hbb.Misc.create({
option: hbb.OptionMessage.create({
disable_audio: muted
? hbb.OptionMessage.BoolOption.Yes
: hbb.OptionMessage.BoolOption.No,
}),
}),
})).catch(() => { /* relay closed */ });
// First user gesture also unblocks the AudioContext on browsers that // First user gesture also unblocks the AudioContext on browsers that
// require a click before audio plays. // require a click before audio plays.
audio.resume().catch(() => { /* ignore */ }); audio.resume().catch(() => { /* ignore */ });
}); });
hudEl.appendChild(muteBtn); hudEl.appendChild(muteBtn);
// Image quality picker. Wire format per src/server/video_qos.rs:218-238:
// - Preset enum (image_quality field): Low=2, Balanced=3, Best=4
// - Custom (custom_image_quality field): bitrate-percent << 8 — host
// does `(q >> 8 & 0xFFF) * 2 / 100` to recover the float ratio.
// We expose the three presets only; the desktop client also has a
// free slider that maps to the Custom branch, but presets cover the
// common cases (Low for slow links, Best for screen sharing demos).
const qualitySel = document.createElement("select");
qualitySel.className = "hud-select";
qualitySel.title = "Image quality";
for (const [label, value] of [
["Balanced", hbb.ImageQuality.Balanced],
["Best", hbb.ImageQuality.Best],
["Low", hbb.ImageQuality.Low],
] as const) {
const opt = document.createElement("option");
opt.value = String(value);
opt.textContent = label;
qualitySel.appendChild(opt);
}
qualitySel.addEventListener("change", () => {
const q = parseInt(qualitySel.value, 10) as hbb.ImageQuality;
userQuality = q;
session.send(hbb.Message.create({
misc: hbb.Misc.create({
option: hbb.OptionMessage.create({ image_quality: q }),
}),
})).catch(() => { /* relay closed */ });
canvas.el().focus();
});
hudEl.appendChild(qualitySel);
// Custom FPS picker. Host clamps via VIDEO_QOS.user_custom_fps —
// any value > 0 takes effect immediately, but the host caps at 30
// for clients that don't advertise allow_more_fps (we don't, so 30
// is the practical max even if we send 60).
const fpsSel = document.createElement("select");
fpsSel.className = "hud-select";
fpsSel.title = "Target FPS";
for (const fps of [15, 30, 60]) {
const opt = document.createElement("option");
opt.value = String(fps);
opt.textContent = `${fps} fps`;
if (fps === 30) opt.selected = true;
fpsSel.appendChild(opt);
}
fpsSel.addEventListener("change", () => {
const fps = parseInt(fpsSel.value, 10);
userFps = fps;
session.send(hbb.Message.create({
misc: hbb.Misc.create({
option: hbb.OptionMessage.create({ custom_fps: fps }),
}),
})).catch(() => { /* relay closed */ });
canvas.el().focus();
});
hudEl.appendChild(fpsSel);
// Display picker — only shown when the host has more than one screen.
if (displays.length > 1) {
const sel = document.createElement("select");
sel.className = "hud-select";
displays.forEach((d, i) => {
const opt = document.createElement("option");
opt.value = String(i);
opt.textContent = `Display ${i + 1} · ${d.width}×${d.height}`;
if (i === currentDisplay) opt.selected = true;
sel.appendChild(opt);
});
sel.addEventListener("change", () => {
const idx = parseInt(sel.value, 10);
// Two-message dance — required for clients >= 1.2.4 (we send
// version "1.4.0"). The host's switch_display_to skips
// unsubscribing the previous service for these clients and waits
// for an explicit CaptureDisplays{set:[idx]} to clean up. Without
// that follow-up, switching display 0 → 1 → 0 doesn't actually
// restore display 0 because both video services stay subscribed
// and the host's display_idx tracker can desync. See
// src/server/connection.rs:3679-3699 (switch_display_to) and
// src/ui_session_interface.rs:849-869 (mirrors this dance in the
// desktop client).
session.send(hbb.Message.create({
misc: hbb.Misc.create({
switch_display: hbb.SwitchDisplay.create({ display: idx }),
}),
})).catch(() => { /* relay closed */ });
session.send(hbb.Message.create({
misc: hbb.Misc.create({
capture_displays: hbb.CaptureDisplays.create({ set: [idx] }),
}),
})).catch(() => { /* relay closed */ });
currentDisplay = idx;
canvas.el().focus();
});
hudEl.appendChild(sel);
}
// Ctrl+Alt+Del (Windows hosts only — server-side `#[cfg(windows)]`). // Ctrl+Alt+Del (Windows hosts only — server-side `#[cfg(windows)]`).
const cadBtn = document.createElement("button"); const cadBtn = document.createElement("button");
cadBtn.textContent = "Ctrl+Alt+Del"; cadBtn.textContent = "Ctrl+Alt+Del";
@@ -243,79 +420,205 @@ async function runSession(
setInterval(() => { setInterval(() => {
const sz = canvas.size(); const sz = canvas.size();
const dims = sz.width ? `${sz.width}×${sz.height}` : "—"; const dims = sz.width ? `${sz.width}×${sz.height}` : "—";
fpsLine.textContent = ` ${escapeHtml(peerInfo.hostname || cfg.peer_id)} · ${dims} · ${canvas.fps()} fps `; const d = pipeline.diagStats();
// recv = wire frames/sec from the host, decode = browser-decoded
// frames/sec, draw = canvas paints/sec. Three numbers narrow down
// where any FPS gap is sitting.
fpsLine.textContent = ` ${escapeHtml(peerInfo.hostname || cfg.peer_id)} · ${dims} · recv ${d.recvFps} / dec ${d.decodeFps} / draw ${canvas.fps()} fps · ${d.codec || "—"} `;
}, 1000); }, 1000);
void detachMouse; void detachKb; // kept-alive references for future cleanup void detachMouse; void detachKb; void detachClip; // kept-alive refs for future cleanup
// Replay any pre-login messages (e.g. Misc{audio_format} that arrives // Reconnect overlay — a centred banner that appears on top of the
// before LoginResponse — the host's audio_service first-snapshot fires // canvas while we're trying to re-establish the session. The canvas
// between add_connection and login_response) into the post-login // keeps its last-known frame underneath so the user has visual
// dispatcher so they get handled. // continuity across blips.
for (const m of preloginExtras) { const overlay = document.createElement("div");
if (m.misc?.audio_format) { overlay.className = "reconnect-overlay";
try { overlay.style.display = "none";
audio.configure(m.misc.audio_format); sessionEl.appendChild(overlay);
} catch (e) { const showOverlay = (text: string): void => {
console.warn("[rustdesk-web] audio init failed:", e); overlay.textContent = text;
} overlay.style.display = "flex";
} };
} const hideOverlay = (): void => {
overlay.style.display = "none";
};
// Long-lived receive loop. Branches by Message oneof. // Outer loop: each iteration is one connected session. After the
// first iteration's recv loop exits, we attempt reconnect with
// backoff and either continue here (success) or render a fatal
// overlay and return (give up).
let attempt = 0;
const MAX_ATTEMPTS = 10;
while (true) { while (true) {
let msg: hbb.Message; // Replay any pre-login messages from the just-completed login.
try { // The most important one is Misc{audio_format} — the host's
msg = await session.recv(); // audio_service first-snapshot fires between add_connection and
} catch (e) { // login_response, so it arrives BEFORE LoginResponse and we
console.error("[rustdesk-web] session recv failed:", e); // stash it during login.
hudEl.textContent = `disconnected: ${String(e).slice(0, 80)}`; for (const m of preloginExtras) {
break; if (m.misc?.audio_format) {
}
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 { try {
audio.configure(msg.misc.audio_format); audio.configure(m.misc.audio_format);
// After (re)configure the gain defaults to 1; re-apply mute
// if the user had toggled it before the disconnect.
if (audio.isMuted()) audio.setMuted(true);
} catch (e) { } catch (e) {
console.warn("[rustdesk-web] audio init failed:", e); console.warn("[rustdesk-web] audio init failed:", e);
} }
} }
continue;
} }
} preloginExtras = [];
pipeline.close(); // On reconnect, re-apply user-set options. The host treats each
audio.close(); // session as fresh and resets these to defaults.
if (attempt > 0) {
if (audio.isMuted()) {
session.send(hbb.Message.create({
misc: hbb.Misc.create({
option: hbb.OptionMessage.create({
disable_audio: hbb.OptionMessage.BoolOption.Yes,
}),
}),
})).catch(() => { /* swallow */ });
}
if (userQuality != null) {
session.send(hbb.Message.create({
misc: hbb.Misc.create({
option: hbb.OptionMessage.create({ image_quality: userQuality }),
}),
})).catch(() => { /* swallow */ });
}
if (userFps != null) {
session.send(hbb.Message.create({
misc: hbb.Misc.create({
option: hbb.OptionMessage.create({ custom_fps: userFps }),
}),
})).catch(() => { /* swallow */ });
}
if (currentDisplay > 0) {
session.send(hbb.Message.create({
misc: hbb.Misc.create({
switch_display: hbb.SwitchDisplay.create({ display: currentDisplay }),
}),
})).catch(() => { /* swallow */ });
session.send(hbb.Message.create({
misc: hbb.Misc.create({
capture_displays: hbb.CaptureDisplays.create({ set: [currentDisplay] }),
}),
})).catch(() => { /* swallow */ });
}
}
// Inner receive loop. Branches by Message oneof. Returns when the
// session breaks (relay close, decrypt fail, etc.).
let disconnectReason = "";
try {
while (true) {
const msg = await session.recv();
if (msg.video_frame) {
pipeline.pushVideoFrame(msg.video_frame);
continue;
}
if (msg.test_delay) {
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.
continue;
}
if (msg.clipboard) {
applyClipboard(msg.clipboard).catch(() => { /* swallowed */ });
continue;
}
if (msg.multi_clipboards) {
applyClipboardMulti(msg.multi_clipboards).catch(() => { /* swallowed */ });
continue;
}
if (msg.misc) {
if (msg.misc.audio_format) {
try {
audio.configure(msg.misc.audio_format);
if (audio.isMuted()) audio.setMuted(true);
} catch (e) {
console.warn("[rustdesk-web] audio init failed:", e);
}
}
continue;
}
}
} catch (e) {
disconnectReason = String(e);
console.warn("[rustdesk-web] session disconnected:", disconnectReason);
}
// Disconnected. Reset the codec pipeline so the new session's
// first keyframe reconfigures from scratch (codec or resolution
// may have changed if the peer was restarted).
pipeline.close();
// Try to reconnect with exponential backoff. The first attempt
// is immediate; subsequent attempts back off 1s, 2s, 4s, ...
// capped at 30s.
let reconnected: ConnectResult | null = null;
for (attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
showOverlay(`Reconnecting (${attempt}/${MAX_ATTEMPTS})…`);
try {
reconnected = await connectOnce(cfg, serverPk, password, /*statusUI=*/false);
break;
} catch (e) {
const msg = String(e);
// Auth errors won't fix on retry — the peer changed something
// (password rotated, key changed). Bail with a clear message.
if (msg.toLowerCase().includes("password") ||
msg.toLowerCase().includes("signature verify")) {
showOverlay(`Cannot reconnect: ${msg.slice(0, 100)}`);
audio.close();
return;
}
if (attempt === MAX_ATTEMPTS) break;
// 1s, 2s, 4s, 8s, 16s, 30s, 30s, ...
const delay = Math.min(1000 * 2 ** (attempt - 1), 30_000);
showOverlay(`Reconnecting (${attempt}/${MAX_ATTEMPTS})… retry in ${Math.round(delay / 1000)}s`);
await sleep(delay);
}
}
if (!reconnected) {
showOverlay(`Disconnected. ${escapeHtml(disconnectReason).slice(0, 120)}`);
audio.close();
return;
}
// Reconnected — swap in the new session and loop back. The let-
// binding capture means HUD click handlers and the input-handler
// proxy automatically retarget without rebinding.
session = reconnected.session;
peerInfo = reconnected.peerInfo;
preloginExtras = reconnected.preloginExtras;
displays = peerInfo.displays || [];
if (currentDisplay >= displays.length) currentDisplay = 0;
hideOverlay();
canvas.el().focus();
}
} }
main().catch((err) => { main().catch((err) => {
+8 -3
View File
@@ -157,9 +157,14 @@ export class Session {
supported_decoding: hbb.SupportedDecoding.create({ supported_decoding: hbb.SupportedDecoding.create({
ability_vp9: 1, ability_vp9: 1,
ability_h264: 1, ability_h264: 1,
// VP8 / AV1 / H.265 left at 0 for v1; codec branches land in ability_vp8: 1,
// M6f and the QoL milestones. // AV1 / H.265 left at 0 for v1.
prefer: hbb.SupportedDecoding.PreferCodec.VP9, // Prefer VP8 — it's the cheapest software encoder of the
// lot. Hosts without hwcodec/nvenc can keep up at 15-30 fps
// for screen sharing, where VP9 caps at single digits and
// H.264 falls back to "auto" (= VP9 in practice). VP8 is
// universally supported in WebCodecs.
prefer: hbb.SupportedDecoding.PreferCodec.VP8,
}), }),
// Explicitly opt INTO audio. The host's update_options() at // Explicitly opt INTO audio. The host's update_options() at
// server/connection.rs:3974 only re-subscribes audio_service // server/connection.rs:3974 only re-subscribes audio_service
+28
View File
@@ -142,6 +142,34 @@ html, body {
} }
.hud-btn:hover { background: #475569; } .hud-btn:hover { background: #475569; }
.hud-select {
background: #334155;
border: 0;
color: #e2e8f0;
padding: 2px 4px;
border-radius: 3px;
font-size: 11px;
font-family: inherit;
cursor: pointer;
}
.hud-select:hover { background: #475569; }
.reconnect-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(15, 23, 42, 0.65);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
color: #e2e8f0;
font-family: inherit;
font-size: 15px;
z-index: 20;
pointer-events: none;
}
.error { .error {
background: rgba(220, 38, 38, 0.15); background: rgba(220, 38, 38, 0.15);
border: 1px solid rgba(220, 38, 38, 0.4); border: 1px solid rgba(220, 38, 38, 0.4);