diff --git a/deploy/docker/webclient/Dockerfile b/deploy/docker/webclient/Dockerfile index b055662e316..c941961582b 100644 --- a/deploy/docker/webclient/Dockerfile +++ b/deploy/docker/webclient/Dockerfile @@ -53,16 +53,13 @@ COPY <<'EOF' /docker-entrypoint.d/90-rustdesk-config.sh #!/bin/sh set -e CONFIG=/usr/share/nginx/html/config.json -if [ -n "$RUSTDESK_HOST" ]; then - cat > "$CONFIG" < "$CONFIG" < { - 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); + } } \ No newline at end of file diff --git a/flutter/web/js/src/codec.test.ts b/flutter/web/js/src/codec.test.ts index 8d8604846e8..e9674d5b982 100644 --- a/flutter/web/js/src/codec.test.ts +++ b/flutter/web/js/src/codec.test.ts @@ -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); + }); }); diff --git a/flutter/web/js/src/connection.test.ts b/flutter/web/js/src/connection.test.ts index abb35b95bba..e32e4cf7f59 100644 --- a/flutter/web/js/src/connection.test.ts +++ b/flutter/web/js/src/connection.test.ts @@ -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)]), @@ -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", () => { @@ -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 () => { @@ -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"); diff --git a/flutter/web/js/src/connection.ts b/flutter/web/js/src/connection.ts index 09e55e9f2e7..18b82c95744 100644 --- a/flutter/web/js/src/connection.ts +++ b/flutter/web/js/src/connection.ts @@ -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[]; @@ -43,6 +44,7 @@ export default class Connection { this._msgs = []; this._id = ""; this._videoTestSpeed = [0, 0]; + this._decoderGeneration = 0; //this._cursors = {}; } @@ -59,6 +61,7 @@ export default class Connection { } async _start(id: string) { + await globals.initSodium(); if (!this._options) { this._options = globals.getPeers()[id] || {}; } @@ -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; @@ -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) { @@ -329,6 +333,8 @@ export default class Connection { clearInterval(this._interval); this._ws?.close(); this._videoDecoder?.close(); + this._videoDecoder = undefined; + this._decoderGeneration++; } refresh() { @@ -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; @@ -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); }); } } diff --git a/flutter/web/js/src/globals.js b/flutter/web/js/src/globals.js index 36c24ed1d20..5d427facc82 100644 --- a/flutter/web/js/src/globals.js +++ b/flutter/web/js/src/globals.js @@ -140,11 +140,21 @@ export function newConn() { } let sodium; -export async function verify(signed, pk) { - if (!sodium) { - await _sodium.ready; - sodium = _sodium; +let sodiumReady; +export async function initSodium() { + if (!sodiumReady) { + sodiumReady = _sodium.ready.then(() => { sodium = _sodium; }); } + await sodiumReady; +} + +function requireSodium() { + if (!sodium) throw new Error('libsodium not initialized — call initSodium() first'); + return sodium; +} + +export async function verify(signed, pk) { + await initSodium(); if (typeof pk == 'string') { pk = decodeBase64(pk); } @@ -152,23 +162,24 @@ export async function verify(signed, pk) { } export function decodeBase64(pk) { - return sodium.from_base64(pk, sodium.base64_variants.ORIGINAL); + return requireSodium().from_base64(pk, sodium.base64_variants.ORIGINAL); } export function genBoxKeyPair() { - const pair = sodium.crypto_box_keypair(); + const s = requireSodium(); + const pair = s.crypto_box_keypair(); const sk = pair.privateKey; const pk = pair.publicKey; return [sk, pk]; } export function genSecretKey() { - return sodium.crypto_secretbox_keygen(); + return requireSodium().crypto_secretbox_keygen(); } export function seal(unsigned, theirPk, ourSk) { const nonce = Uint8Array.from(Array(24).fill(0)); - return sodium.crypto_box_easy(unsigned, nonce, theirPk, ourSk); + return requireSodium().crypto_box_easy(unsigned, nonce, theirPk, ourSk); } function makeOnce(value) { @@ -184,11 +195,11 @@ function makeOnce(value) { }; export function encrypt(unsigned, nonce, key) { - return sodium.crypto_secretbox_easy(unsigned, makeOnce(nonce), key); + return requireSodium().crypto_secretbox_easy(unsigned, makeOnce(nonce), key); } export function decrypt(signed, nonce, key) { - return sodium.crypto_secretbox_open_easy(signed, makeOnce(nonce), key); + return requireSodium().crypto_secretbox_open_easy(signed, makeOnce(nonce), key); } window.setByName = (name, value) => { @@ -374,6 +385,7 @@ let opusWorker = new Worker("./libopus.js"); let pcmPlayer; export function initAudio(channels, sampleRate) { + if (pcmPlayer) pcmPlayer.destroy(); pcmPlayer = newAudioPlayer(channels, sampleRate); opusWorker.postMessage({ channels, sampleRate }); } @@ -389,7 +401,7 @@ window.init = async () => { } } opusWorker.onmessage = (e) => { - pcmPlayer.feed(e.data); + if (pcmPlayer) pcmPlayer.feed(e.data); } await loadConfig(); loadVp9(() => { }); diff --git a/flutter/web/js/src/globals.test.ts b/flutter/web/js/src/globals.test.ts index d7fce9633d7..c2bb76a5ee2 100644 --- a/flutter/web/js/src/globals.test.ts +++ b/flutter/web/js/src/globals.test.ts @@ -65,10 +65,14 @@ vi.mock("./common", () => ({ translate: vi.fn((locale: string, text: string) => text), })); +const mockPCMPlayerInstances: Array<{ feed: ReturnType; destroy: ReturnType }> = []; vi.mock("pcm-player", () => { class MockPCMPlayer { feed = vi.fn(); - constructor(_opts: any) {} + destroy = vi.fn(); + constructor(_opts: any) { + mockPCMPlayerInstances.push(this); + } } return { default: MockPCMPlayer }; }); @@ -77,6 +81,7 @@ import { isDesktop, msgbox, pushEvent, setConn, getConn, close, newConn, verify, genBoxKeyPair, genSecretKey, seal, encrypt, decrypt, getPeers, copyToClipboard, draw, sendOffCanvas, initAudio, playAudio, + initSodium, } from "./globals"; describe("isDesktop", () => { @@ -526,12 +531,70 @@ describe("initAudio / playAudio", () => { initAudio(2, 48000); }); + it("destroys previous player on reinit", () => { + const before = mockPCMPlayerInstances.length; + initAudio(2, 48000); + const firstPlayer = mockPCMPlayerInstances[before]; + initAudio(2, 44100); + expect(firstPlayer.destroy).toHaveBeenCalled(); + }); + it("sends audio packet to opus worker", () => { const packet = new Uint8Array([1, 2, 3]); playAudio(packet); }); }); +describe("sodium initialization", () => { + it("initSodium resolves without error", async () => { + await expect(initSodium()).resolves.toBeUndefined(); + }); + + it("initSodium is idempotent", async () => { + await initSodium(); + await initSodium(); + }); + + it("crypto functions work after initSodium", async () => { + await initSodium(); + expect(() => genBoxKeyPair()).not.toThrow(); + expect(() => genSecretKey()).not.toThrow(); + }); + + it("source: all crypto functions use requireSodium() not raw _sodium", async () => { + const fs = await import("fs"); + const path = await import("path"); + const source = fs.readFileSync(path.resolve(__dirname, "globals.js"), "utf-8"); + + const cryptoSection = source.slice(source.indexOf("function requireSodium()")); + const cryptoFunctions = cryptoSection.match(/export (?:async )?function \w+/g) || []; + expect(cryptoFunctions.length).toBeGreaterThan(0); + + const lines = cryptoSection.split("\n"); + for (const line of lines) { + if (line.includes("import _sodium") || line.includes("_sodium.ready")) continue; + if (line.match(/\b_sodium\b/) && !line.includes("{ sodium = _sodium; }")) { + throw new Error(`Direct _sodium usage found: ${line.trim()}`); + } + } + }); + + it("source: connection.ts calls initSodium() before crypto operations", async () => { + const fs = await import("fs"); + const path = await import("path"); + const source = fs.readFileSync(path.resolve(__dirname, "connection.ts"), "utf-8"); + + const startMethod = source.slice(source.indexOf("async _start(")); + const initLine = startMethod.indexOf("initSodium()"); + expect(initLine).toBeGreaterThan(-1); + + const verifyLine = startMethod.indexOf("verify("); + if (verifyLine > -1) { + expect(initLine).toBeLessThan(verifyLine); + } + }); +}); + describe("window.init", () => { it("runs init sequence", async () => { await (window as any).init(); diff --git a/flutter/web/js/src/ui.js b/flutter/web/js/src/ui.js index 4463340228e..608a258f4e4 100644 --- a/flutter/web/js/src/ui.js +++ b/flutter/web/js/src/ui.js @@ -13,8 +13,8 @@ if (app) {