Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions deploy/docker/webclient/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM --platform=linux/amd64 ubuntu:22.04 AS build

Check warning on line 1 in deploy/docker/webclient/Dockerfile

View workflow job for this annotation

GitHub Actions / build

FROM --platform flag should not use a constant value

FromPlatformFlagConstDisallowed: FROM --platform flag should not use constant value "linux/amd64" More info: https://docs.docker.com/go/dockerfile/rule/from-platform-flag-const-disallowed/

ARG FLUTTER_VERSION=3.24.5
ARG NODE_MAJOR=22
Expand Down Expand Up @@ -53,16 +53,13 @@
#!/bin/sh
set -e
CONFIG=/usr/share/nginx/html/config.json
if [ -n "$RUSTDESK_HOST" ]; then
cat > "$CONFIG" <<CONF
cat > "$CONFIG" <<CONF
{
"host": "${RUSTDESK_HOST}",
"relay": "${RUSTDESK_RELAY:-$RUSTDESK_HOST}",
"key": "${RUSTDESK_KEY:-}",
"api": "${RUSTDESK_API:-}"
"host": "${RUSTDESK_HOST:-}",
"relay": "${RUSTDESK_RELAY:-}",
"key": "${RUSTDESK_KEY:?RUSTDESK_KEY is required}"
}
CONF
echo "config.json written: host=$RUSTDESK_HOST relay=${RUSTDESK_RELAY:-$RUSTDESK_HOST}"
fi
echo "config.json written"
EOF
RUN chmod +x /docker-entrypoint.d/90-rustdesk-config.sh
73 changes: 55 additions & 18 deletions deploy/docker/webclient/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,75 @@ OSS-compatible web client for [RustDesk](https://rustdesk.com) remote desktop, b

## Quick Start

Behind a reverse proxy with path-based routing (recommended):

```bash
docker run -d -p 8080:80 \
-e RUSTDESK_HOST=wss://your-server.com/hbbs \
-e RUSTDESK_RELAY=wss://your-server.com/hbbr \
-e RUSTDESK_KEY=your-public-key \
rophy/rustdesk-webclient
```

Then open http://localhost:8080 in a browser.

## Environment Variables

| Variable | Required | Description |
|----------|----------|-------------|
| `RUSTDESK_HOST` | Yes | WebSocket URL of the rendezvous server (hbbs) |
| `RUSTDESK_RELAY` | No | WebSocket URL of the relay server (hbbr). Defaults to `RUSTDESK_HOST` |
| `RUSTDESK_KEY` | No | Server public key |
| `RUSTDESK_API` | No | API server URL (only for Server Pro) |

## With Reverse Proxy
The web client defaults to same-origin WebSocket paths `/hbbs` and `/hbbr`, with `ws://` or `wss://` selected automatically based on the page protocol. No host configuration needed.

When running behind a reverse proxy (nginx, Istio, etc.) that terminates TLS:
For split-domain deployments (hbbs/hbbr on a different host):

```bash
# Plain WebSocket (development)
docker run -d -p 8080:80 \
-e RUSTDESK_HOST=wss://rustdesk.example.com/hbbs \
-e RUSTDESK_RELAY=wss://rustdesk.example.com/hbbr \
-e RUSTDESK_HOST=ws://hbbs.example.com:21118 \
-e RUSTDESK_RELAY=ws://hbbr.example.com:21119 \
-e RUSTDESK_KEY=your-public-key \
rophy/rustdesk-webclient

# Secure WebSocket via TLS-terminating proxy
docker run -d -p 8080:80 \
-e RUSTDESK_HOST=wss://hbbs.example.com/hbbs \
-e RUSTDESK_RELAY=wss://hbbr.example.com/hbbr \
-e RUSTDESK_KEY=your-public-key \
rophy/rustdesk-webclient
```

The proxy should route `/hbbs` to hbbs port 21118 and `/hbbr` to hbbr port 21119 with WebSocket upgrade support.
Then open http://localhost:8080 in a browser.

## Environment Variables

| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `RUSTDESK_KEY` | **Yes** | | Server public key |
| `RUSTDESK_HOST` | No | `/hbbs` | Rendezvous server (hbbs). Path (e.g. `/hbbs`) or full URI (e.g. `ws://host:21118`) |
| `RUSTDESK_RELAY` | No | `/hbbr` | Relay server (hbbr). Path (e.g. `/hbbr`) or full URI (e.g. `ws://host:21119`) |

When `RUSTDESK_HOST` or `RUSTDESK_RELAY` is a path (starts with `/`), the WebSocket scheme is derived from the page: `wss://` on HTTPS, `ws://` on HTTP. Full URIs are used as-is.

## Reverse Proxy Setup

The proxy must route WebSocket connections to hbbs/hbbr:

| Path | Backend | Protocol |
|------|---------|----------|
| `/hbbs` | hbbs:21118 | WebSocket |
| `/hbbr` | hbbr:21119 | WebSocket |
| `/` | webclient:80 | HTTP |

The proxy should terminate TLS and support WebSocket upgrade. Example with nginx:

```nginx
location /hbbs {
proxy_pass http://hbbs:21118;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}

location /hbbr {
proxy_pass http://hbbr:21119;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
```

## Source

Expand Down
48 changes: 29 additions & 19 deletions flutter/web/js/src/codec.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,33 @@
*/
import { simd } from "wasm-feature-detect";

export async function loadVp9(callback) {
// Multithreading is used only if `options.threading` is true.
// This requires browser support for the new `SharedArrayBuffer` and `Atomics` APIs,
// currently available in Firefox and Chrome with experimental flags enabled.
// 所有主流浏览器均默认于2018年1月5日禁用SharedArrayBuffer
const isSIMD = await simd();
console.log('isSIMD: ' + isSIMD);
window.OGVLoader.loadClass(
isSIMD ? "OGVDecoderVideoVP9SIMDW" : "OGVDecoderVideoVP9W",
(videoCodecClass) => {
window.videoCodecClass = videoCodecClass;
videoCodecClass({ videoFormat: {} }).then((decoder) => {
decoder.init(() => {
callback(decoder);
})
})
},
{ worker: true, threading: true }
);
export async function loadVp9(callback, onError) {
if (!window.OGVLoader) {
const err = new Error("OGVLoader not available");
if (onError) onError(err);
else console.error(err);
return;
}
try {
const isSIMD = await simd();
console.log('isSIMD: ' + isSIMD);
window.OGVLoader.loadClass(
isSIMD ? "OGVDecoderVideoVP9SIMDW" : "OGVDecoderVideoVP9W",
(videoCodecClass) => {
window.videoCodecClass = videoCodecClass;
videoCodecClass({ videoFormat: {} }).then((decoder) => {
decoder.init(() => {
callback(decoder);
})
}).catch((err) => {
if (onError) onError(err);
else console.error("VP9 decoder init failed:", err);
});
},
{ worker: true, threading: true }
);
} catch (err) {
if (onError) onError(err);
else console.error("VP9 load failed:", err);
}
}
23 changes: 23 additions & 0 deletions flutter/web/js/src/codec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,27 @@ describe("loadVp9", () => {
await loadVp9(vi.fn());
expect((window as any).videoCodecClass).toBeDefined();
});

it("calls onError when OGVLoader is missing", async () => {
delete (window as any).OGVLoader;
const callback = vi.fn();
const onError = vi.fn();
await loadVp9(callback, onError);
expect(callback).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalledWith(expect.any(Error));
});

it("calls onError when decoder init rejects", async () => {
vi.mocked(simd).mockResolvedValue(false);
const initError = new Error("init failed");
mockLoadClass.mockImplementation((cls: string, cb: Function) => {
cb(vi.fn().mockRejectedValue(initError));
});
const callback = vi.fn();
const onError = vi.fn();
await loadVp9(callback, onError);
await new Promise(r => setTimeout(r, 0));
expect(callback).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalledWith(initError);
});
});
35 changes: 33 additions & 2 deletions flutter/web/js/src/connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ vi.mock("./globals", () => ({
draw: vi.fn(),
pushEvent: vi.fn(),
getPeers: vi.fn(() => ({})),
initSodium: vi.fn().mockResolvedValue(undefined),
isDesktop: vi.fn(() => true),
verify: vi.fn().mockResolvedValue(new Uint8Array(32)),
genBoxKeyPair: vi.fn(() => [new Uint8Array(32), new Uint8Array(32)]),
Expand Down Expand Up @@ -657,6 +658,30 @@ describe("Connection", () => {

expect(mockWs.sendMessage).toHaveBeenCalled();
});

it("acks frames without crashing when decoder is not ready", () => {
(conn as any)._videoDecoder = undefined;
(conn as any)._firstFrame = true;
(conn as any)._ws = mockWs;

conn.handleVideoFrame({
vp9s: { frames: [{ data: new Uint8Array([1]) }] },
} as any);

expect(mockWs.sendMessage).toHaveBeenCalled();
});
});

describe("decoder lifecycle", () => {
it("close() clears decoder and increments generation", () => {
const mockDecoder = { close: vi.fn() };
(conn as any)._videoDecoder = mockDecoder;
const genBefore = (conn as any)._decoderGeneration;
conn.close();
expect(mockDecoder.close).toHaveBeenCalled();
expect((conn as any)._videoDecoder).toBeUndefined();
expect((conn as any)._decoderGeneration).toBeGreaterThan(genBefore);
});
});

describe("_start", () => {
Expand All @@ -666,10 +691,10 @@ describe("Connection", () => {
expect(globals.msgbox).toHaveBeenCalledWith("error", "Error", "Server busy");
});

it("handles ID_NOT_EXIST failure", async () => {
it("does not treat default failure value (0) as an error", async () => {
nextWsResponse = { punch_hole_response: { failure: 0 } };
await (conn as any)._start("test-peer");
expect(globals.msgbox).toHaveBeenCalledWith("error", "Error", "ID does not exist");
expect(globals.msgbox).not.toHaveBeenCalledWith("error", expect.anything(), expect.anything());
});

it("handles OFFLINE failure", async () => {
Expand All @@ -690,6 +715,12 @@ describe("Connection", () => {
expect(globals.msgbox).toHaveBeenCalledWith("error", "Error", "Key overuse");
});

it("handles unrecognized nonzero failure with generic message", async () => {
nextWsResponse = { punch_hole_response: { failure: 99 } };
await (conn as any)._start("test-peer");
expect(globals.msgbox).toHaveBeenCalledWith("error", "Error", "Connection failed");
});

it("handles relay_response with no version", async () => {
nextWsResponse = { relay_response: { version: 0, pk: new Uint8Array(32), uuid: "test-uuid" } };
await (conn as any)._start("test-peer");
Expand Down
31 changes: 25 additions & 6 deletions flutter/web/js/src/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export default class Connection {
_peerInfo: message.PeerInfo | undefined;
_firstFrame: Boolean | undefined;
_videoDecoder: any;
_decoderGeneration: number;
_password: Uint8Array | undefined;
_options: any;
_videoTestSpeed: number[];
Expand All @@ -43,6 +44,7 @@ export default class Connection {
this._msgs = [];
this._id = "";
this._videoTestSpeed = [0, 0];
this._decoderGeneration = 0;
//this._cursors = {};
}

Expand All @@ -59,6 +61,7 @@ export default class Connection {
}

async _start(id: string) {
await globals.initSodium();
if (!this._options) {
this._options = globals.getPeers()[id] || {};
}
Expand Down Expand Up @@ -108,11 +111,8 @@ export default class Connection {
this.msgbox("error", "Error", phr?.other_failure);
return;
}
if (phr.failure != rendezvous.PunchHoleResponse_Failure.UNRECOGNIZED) {
switch (phr?.failure) {
case rendezvous.PunchHoleResponse_Failure.ID_NOT_EXIST:
this.msgbox("error", "Error", "ID does not exist");
break;
if (phr.failure) {
switch (phr.failure) {
case rendezvous.PunchHoleResponse_Failure.OFFLINE:
this.msgbox("error", "Error", "Remote desktop is offline");
break;
Expand All @@ -122,7 +122,11 @@ export default class Connection {
case rendezvous.PunchHoleResponse_Failure.LICENSE_OVERUSE:
this.msgbox("error", "Error", "Key overuse");
break;
default:
this.msgbox("error", "Error", "Connection failed");
break;
}
return;
}
} else if (rr) {
if (!rr.version) {
Expand Down Expand Up @@ -329,6 +333,8 @@ export default class Connection {
clearInterval(this._interval);
this._ws?.close();
this._videoDecoder?.close();
this._videoDecoder = undefined;
this._decoderGeneration++;
}

refresh() {
Expand Down Expand Up @@ -424,6 +430,10 @@ export default class Connection {
}
if (vf.vp9s) {
const dec = this._videoDecoder;
if (!dec) {
this.sendVideoReceived();
return;
}
var tm = new Date().getTime();
var i = 0;
const n = vf.vp9s?.frames.length;
Expand Down Expand Up @@ -714,10 +724,19 @@ export default class Connection {

loadVideoDecoder() {
this._videoDecoder?.close();
this._videoDecoder = undefined;
const gen = ++this._decoderGeneration;
loadVp9((decoder: any) => {
if (gen !== this._decoderGeneration) {
decoder.close();
return;
}
this._videoDecoder = decoder;
console.log("vp9 loaded");
console.log(decoder);
}, (err: any) => {
if (gen !== this._decoderGeneration) return;
this.msgbox("error", "Error", "Failed to load video decoder");
console.error("VP9 load failed:", err);
});
}
}
Expand Down
Loading
Loading