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:
@@ -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
|
||||||
|
|||||||
Vendored
+28
@@ -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);
|
||||||
|
|||||||
Vendored
+3
-3
File diff suppressed because one or more lines are too long
Vendored
+4
-4
File diff suppressed because one or more lines are too long
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 */ });
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user