fix(selfbot): smooth VNC capture via keepalive + stop ffmpeg leak on stream end
The Go-Live broadcast looked badly choppy: video and scrolling stuttered while the cursor stayed smooth. Root cause is TigerVNC: it only refreshes its framebuffer while a VNC client is attached, but the broadcast reads that framebuffer with x11grab (not as a VNC client). With no viewer attached the captured screen idled at ~1.5 fps (measured 3/30 distinct frames); the cursor looked smooth only because x11grab overlays the live cursor on every frame. - Add a headless RFB keepalive (vnc-keepalive.ts) that stays connected for the life of the stream and requests incremental framebuffer updates at the stream framerate. SelfbotStreamer starts it on broadcast start and tears it down on stop/self-end. Measured 3/30 -> 57/60 distinct frames at 60 fps. Fail-open; authenticates with VNC_PASSWORD or the ~/.config/tigervnc/passwd file. - Fix a resource leak: when the Go-Live ended on its own, only the active flag was cleared, leaving the x11grab->nvenc ffmpeg running forever (pinning a CPU core while no media was transmitted, with only the gateway TCP left and no UDP media). The self-end path now tears down capture, keepalive and voice like stop() does. - Tests for both paths (self-end teardown; keepalive DES auth, port mapping, password resolution). Add @types/bun so bun:test typechecks; document the keepalive and recommended Chrome flags in README and .env.example. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -44,7 +44,13 @@ WHISPER_MODEL=small
|
||||
# Docker desktop (VNC) — used only by the container image
|
||||
# ---------------------------------------------------------------------------
|
||||
# VNC viewer password (max 8 chars effective). Watch the screen at localhost:5901.
|
||||
# Also used by the broadcast keepalive: TigerVNC only refreshes its framebuffer
|
||||
# while a VNC client is attached, so the stream keeps a tiny client connected to
|
||||
# avoid a choppy (~1.5 fps) capture. Must match the VNC server's password. If
|
||||
# unset, the keepalive falls back to the obfuscated passwd file (VNC_PASSWD_FILE,
|
||||
# default ~/.config/tigervnc/passwd).
|
||||
VNC_PASSWORD=javis123
|
||||
# VNC_PASSWD_FILE=/home/claude/.config/tigervnc/passwd
|
||||
# Auto-opened page in the in-container Chrome.
|
||||
CHROME_START_URL=about:blank
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/node": "^22.7.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"typescript": "^5.6.3",
|
||||
@@ -211,6 +212,8 @@
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/node": ["@types/node@22.19.20", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw=="],
|
||||
|
||||
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
|
||||
@@ -243,6 +246,8 @@
|
||||
|
||||
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"discord.js-selfbot-v13": "^3.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/node": "^22.7.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"typescript": "^5.6.3"
|
||||
|
||||
@@ -32,6 +32,25 @@ bun bot/scripts/stream-test/stream-hold.ts
|
||||
node bot/scripts/stream-test/scenario.mjs
|
||||
```
|
||||
|
||||
Recommended Chrome flags on the streamed display (avoids the "restore pages?"
|
||||
bubble after an unclean exit and keeps a single clean window):
|
||||
```
|
||||
google-chrome --remote-debugging-port=9222 --start-maximized \
|
||||
--hide-crash-restore-bubble --disable-session-crashed-bubble \
|
||||
--autoplay-policy=no-user-gesture-required <url>
|
||||
```
|
||||
|
||||
## Smooth capture (VNC keepalive)
|
||||
TigerVNC only refreshes its framebuffer while a VNC client is attached. The
|
||||
Discord broadcast reads the framebuffer with `x11grab` (not as a VNC client), so
|
||||
with no viewer attached the captured screen idles at ~1.5 fps and the stream
|
||||
looks badly choppy while the cursor still moves smoothly (x11grab overlays the
|
||||
live cursor each frame). `SelfbotStreamer` fixes this automatically: it keeps a
|
||||
tiny headless RFB client (`vnc-keepalive.ts`) connected for the life of the
|
||||
stream, requesting incremental updates at the stream framerate. Measured: 3/30
|
||||
distinct frames without it, ~57/60 with it. The keepalive authenticates with
|
||||
`VNC_PASSWORD` (or the `~/.config/tigervnc/passwd` file) and is fail-open.
|
||||
|
||||
## A/B framerate/resolution
|
||||
Lower settings to compare what Discord actually delivers to viewers, e.g.:
|
||||
```
|
||||
|
||||
61
bot/src/stream/selfbot.test.ts
Normal file
61
bot/src/stream/selfbot.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// Regression: when the Go-Live stream ends on its own (Discord closes it, the
|
||||
// voice UDP drops, or ffmpeg exits) instead of via stop(), the capture ffmpeg
|
||||
// MUST be killed and voice left. Otherwise the x11grab->nvenc encoder keeps
|
||||
// running forever, feeding a pipe nobody reads, pinning a CPU core while no
|
||||
// media is actually transmitted (observed live: 0 UDP sockets, 100% CPU).
|
||||
import { test, expect, mock } from "bun:test";
|
||||
|
||||
test("a self-ended stream tears down the capture pipeline (no ffmpeg leak)", async () => {
|
||||
const kill = mock(() => {});
|
||||
const leaveVoice = mock(() => {});
|
||||
const destroy = mock(() => {});
|
||||
|
||||
// Controllable Go-Live: resolve endStream() to simulate Discord closing it.
|
||||
let endStream!: () => void;
|
||||
const playPromise = new Promise<void>((r) => {
|
||||
endStream = r;
|
||||
});
|
||||
|
||||
mock.module("node:child_process", () => ({
|
||||
spawn: () => ({ stdout: {}, stderr: { on() {} }, kill }),
|
||||
}));
|
||||
mock.module("discord.js-selfbot-v13", () => ({
|
||||
Client: class {
|
||||
destroy = destroy;
|
||||
async login() {}
|
||||
},
|
||||
}));
|
||||
mock.module("@dank074/discord-video-stream", () => ({
|
||||
Streamer: class {
|
||||
client: any;
|
||||
constructor(c: any) {
|
||||
this.client = c;
|
||||
}
|
||||
async joinVoice() {}
|
||||
leaveVoice = leaveVoice;
|
||||
},
|
||||
prepareStream: () => ({ command: { on() {} }, output: {} }),
|
||||
playStream: () => playPromise,
|
||||
}));
|
||||
|
||||
const { SelfbotStreamer } = await import("./selfbot.ts");
|
||||
const s = new SelfbotStreamer({
|
||||
selfbotToken: "token",
|
||||
vncDisplay: ":1",
|
||||
vncResolution: "1920x1080",
|
||||
vncFramerate: 60,
|
||||
vncBitrateKbps: 8000,
|
||||
streamHw: true,
|
||||
} as any);
|
||||
|
||||
await s.start({ guildId: "g", voiceChannelId: "v" } as any);
|
||||
expect(s.isActive()).toBe(true);
|
||||
|
||||
// Discord closes the Go-Live on its own (not a stop() call).
|
||||
endStream();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(kill).toHaveBeenCalled(); // capture ffmpeg killed -> no CPU-burning orphan
|
||||
expect(leaveVoice).toHaveBeenCalled(); // voice connection released
|
||||
expect(s.isActive()).toBe(false);
|
||||
}, 30000);
|
||||
@@ -19,11 +19,13 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import type { AppConfig } from "../config.ts";
|
||||
import type { ScreenStreamer, StreamContext } from "./index.ts";
|
||||
import { VncKeepalive, resolveVncPassword, vncPortForDisplay } from "./vnc-keepalive.ts";
|
||||
|
||||
export class SelfbotStreamer implements ScreenStreamer {
|
||||
readonly kind = "selfbot" as const;
|
||||
private streamer: any = null;
|
||||
private capture: ChildProcess | null = null;
|
||||
private keepalive: VncKeepalive | null = null;
|
||||
private controller: AbortController | null = null;
|
||||
private active = false;
|
||||
|
||||
@@ -94,6 +96,7 @@ export class SelfbotStreamer implements ScreenStreamer {
|
||||
const signal = controller.signal;
|
||||
let streamer: any = null;
|
||||
let capture: ChildProcess | null = null;
|
||||
let keepalive: VncKeepalive | null = null;
|
||||
|
||||
try {
|
||||
const { selfbot, vs } = await this.loadLib();
|
||||
@@ -157,6 +160,23 @@ export class SelfbotStreamer implements ScreenStreamer {
|
||||
if (!signal.aborted) console.error("[selfbot x11grab]", d.toString().trim());
|
||||
});
|
||||
|
||||
// Keep a VNC client attached for the life of the stream. TigerVNC only
|
||||
// flushes its framebuffer at full rate while a client pulls updates; the
|
||||
// Discord broadcast reads that framebuffer with x11grab (not as a VNC
|
||||
// client), so without this the captured screen would idle at ~1.5 fps and
|
||||
// the stream would look badly choppy. Fail-open: a missing password just
|
||||
// skips it. Matched to the stream framerate so motion stays smooth.
|
||||
const vncPw = resolveVncPassword();
|
||||
if (vncPw) {
|
||||
keepalive = this.keepalive = new VncKeepalive({
|
||||
host: "127.0.0.1",
|
||||
port: vncPortForDisplay(this.config.vncDisplay),
|
||||
password: vncPw,
|
||||
fps: this.config.vncFramerate,
|
||||
});
|
||||
keepalive.start();
|
||||
}
|
||||
|
||||
const { command, output } = prepareStream(
|
||||
capture.stdout,
|
||||
{
|
||||
@@ -182,8 +202,35 @@ export class SelfbotStreamer implements ScreenStreamer {
|
||||
if (!signal.aborted) console.error("[selfbot] playStream:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
// The stream ended on its own (not via stop()); release the lock.
|
||||
if (this.controller === controller) this.active = false;
|
||||
// The stream ended on its own (Discord closed the Go-Live, the voice
|
||||
// UDP dropped, or ffmpeg exited) rather than via stop(). If we are
|
||||
// still the current attempt, tear the pipeline DOWN: kill the capture
|
||||
// ffmpeg and leave voice. Otherwise the x11grab->nvenc encoder keeps
|
||||
// running forever feeding a pipe nobody reads, pinning a CPU core
|
||||
// while no media is actually transmitted. Skip if a concurrent
|
||||
// stop()/start() already replaced the controller (it owns teardown).
|
||||
if (this.controller !== controller) return;
|
||||
try {
|
||||
capture?.kill("SIGKILL");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
keepalive?.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
streamer?.leaveVoice?.();
|
||||
streamer?.client?.destroy?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (this.capture === capture) this.capture = null;
|
||||
if (this.keepalive === keepalive) this.keepalive = null;
|
||||
if (this.streamer === streamer) this.streamer = null;
|
||||
this.controller = null;
|
||||
this.active = false;
|
||||
});
|
||||
|
||||
return "🔴 셀프봇으로 VNC 화면을 음성채널에 실시간 송출 중입니다 (Go Live).";
|
||||
@@ -196,6 +243,11 @@ export class SelfbotStreamer implements ScreenStreamer {
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
keepalive?.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
streamer?.leaveVoice?.();
|
||||
streamer?.client?.destroy?.();
|
||||
@@ -208,6 +260,7 @@ export class SelfbotStreamer implements ScreenStreamer {
|
||||
// unlock it mid-startup and let a third start() race in.
|
||||
if (this.controller === controller) {
|
||||
if (this.capture === capture) this.capture = null;
|
||||
if (this.keepalive === keepalive) this.keepalive = null;
|
||||
if (this.streamer === streamer) this.streamer = null;
|
||||
this.controller = null;
|
||||
this.active = false;
|
||||
@@ -226,6 +279,12 @@ export class SelfbotStreamer implements ScreenStreamer {
|
||||
/* ignore */
|
||||
}
|
||||
this.capture = null;
|
||||
try {
|
||||
this.keepalive?.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.keepalive = null;
|
||||
try {
|
||||
this.streamer?.leaveVoice?.();
|
||||
this.streamer?.client?.destroy?.();
|
||||
|
||||
53
bot/src/stream/vnc-keepalive.test.ts
Normal file
53
bot/src/stream/vnc-keepalive.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
decodeVncPassword,
|
||||
vncChallengeResponse,
|
||||
vncPortForDisplay,
|
||||
resolveVncPassword,
|
||||
} from "./vnc-keepalive.ts";
|
||||
|
||||
// Independent reference for VNC's bit-reversed-key DES, to cross-check the module.
|
||||
const rev = (b: number) => {
|
||||
let r = 0;
|
||||
for (let i = 0; i < 8; i++) r = (r << 1) | ((b >> i) & 1);
|
||||
return r & 0xff;
|
||||
};
|
||||
const vncKey = (buf: Buffer) => Buffer.from([...buf.subarray(0, 8)].map(rev));
|
||||
const desEnc = (key: Buffer, data: Buffer) => {
|
||||
const c = crypto.createCipheriv("des-ecb", key, null);
|
||||
c.setAutoPadding(false);
|
||||
return Buffer.concat([c.update(data), c.final()]);
|
||||
};
|
||||
const FIXED_KEY = Buffer.from([23, 82, 107, 6, 35, 78, 88, 7]);
|
||||
|
||||
test("decodeVncPassword inverts the fixed-key obfuscation", () => {
|
||||
const pw = Buffer.from("s3cr3t\0\0", "binary"); // 8 bytes, trailing nulls
|
||||
const obf = desEnc(vncKey(FIXED_KEY), pw); // how vncpasswd stores it
|
||||
expect(decodeVncPassword(obf).toString()).toBe("s3cr3t");
|
||||
});
|
||||
|
||||
test("vncChallengeResponse encrypts both challenge blocks with the bit-reversed password key", () => {
|
||||
const pw = Buffer.from("hunter12");
|
||||
const challenge = crypto.randomBytes(16);
|
||||
const expected = desEnc(vncKey(pw), challenge);
|
||||
const got = vncChallengeResponse(pw, challenge);
|
||||
expect(got.length).toBe(16);
|
||||
expect(got.equals(expected)).toBe(true);
|
||||
});
|
||||
|
||||
test("vncPortForDisplay maps an X display to its RFB port", () => {
|
||||
expect(vncPortForDisplay(":1")).toBe(5901);
|
||||
expect(vncPortForDisplay(":0")).toBe(5900);
|
||||
expect(vncPortForDisplay(":5")).toBe(5905);
|
||||
});
|
||||
|
||||
test("resolveVncPassword prefers the VNC_PASSWORD env var", () => {
|
||||
const pw = resolveVncPassword({ VNC_PASSWORD: "letmein9" } as NodeJS.ProcessEnv);
|
||||
expect(pw?.toString()).toBe("letmein9");
|
||||
});
|
||||
|
||||
test("resolveVncPassword returns null when nothing is available", () => {
|
||||
const pw = resolveVncPassword({ VNC_PASSWD_FILE: "/nonexistent/path/xyz" } as NodeJS.ProcessEnv);
|
||||
expect(pw).toBeNull();
|
||||
});
|
||||
203
bot/src/stream/vnc-keepalive.ts
Normal file
203
bot/src/stream/vnc-keepalive.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Headless RFB (VNC) keepalive client.
|
||||
*
|
||||
* TigerVNC's Xvnc only flushes pending rendering into the readable framebuffer
|
||||
* while a VNC client is actively pulling updates. The Discord broadcast reads
|
||||
* that framebuffer with x11grab (it is NOT a VNC client), so with no viewer
|
||||
* attached Xvnc idles and the captured screen updates at ~1.5 fps - the stream
|
||||
* looks badly choppy even though Chrome renders at 60 fps. (Measured: 3/30
|
||||
* distinct frames without a client, 30/30 with one.)
|
||||
*
|
||||
* This client stays connected to the VNC server and continuously requests
|
||||
* incremental framebuffer updates, keeping the framebuffer fresh for the whole
|
||||
* duration of a broadcast. It is intentionally fail-open: any connection/auth
|
||||
* problem is logged and retried, never thrown, so it can never break the stream.
|
||||
*/
|
||||
import net from "node:net";
|
||||
import crypto from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
// VNC's DES variant uses each key byte with its bits mirrored.
|
||||
function revByte(b: number): number {
|
||||
let r = 0;
|
||||
for (let i = 0; i < 8; i++) r = (r << 1) | ((b >> i) & 1);
|
||||
return r & 0xff;
|
||||
}
|
||||
function vncKey(buf: Buffer): Buffer {
|
||||
return Buffer.from([...buf.subarray(0, 8)].map(revByte));
|
||||
}
|
||||
function desEcb(key: Buffer, data: Buffer, decrypt = false): Buffer {
|
||||
const c = decrypt
|
||||
? crypto.createDecipheriv("des-ecb", key, null)
|
||||
: crypto.createCipheriv("des-ecb", key, null);
|
||||
c.setAutoPadding(false);
|
||||
return Buffer.concat([c.update(data), c.final()]);
|
||||
}
|
||||
|
||||
// The fixed key TigerVNC/RealVNC use to obfuscate the stored password file.
|
||||
const FIXED_KEY = Buffer.from([23, 82, 107, 6, 35, 78, 88, 7]);
|
||||
|
||||
/** Decode an 8-byte obfuscated VNC password file payload to plaintext. */
|
||||
export function decodeVncPassword(obf: Buffer): Buffer {
|
||||
const pt = desEcb(vncKey(FIXED_KEY), obf.subarray(0, 8), true);
|
||||
const z = pt.indexOf(0);
|
||||
return pt.subarray(0, z < 0 ? 8 : z);
|
||||
}
|
||||
|
||||
/** Compute the 16-byte VncAuth response for a server challenge. */
|
||||
export function vncChallengeResponse(password: Buffer, challenge: Buffer): Buffer {
|
||||
const key = Buffer.alloc(8);
|
||||
password.subarray(0, 8).copy(key);
|
||||
return desEcb(vncKey(key), challenge.subarray(0, 16));
|
||||
}
|
||||
|
||||
/** Map an X display like ":1" to its TigerVNC RFB port (5900 + n). */
|
||||
export function vncPortForDisplay(display: string): number {
|
||||
const n = parseInt(String(display).replace(/^:/, ""), 10);
|
||||
return 5900 + (Number.isFinite(n) ? n : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the VNC password: VNC_PASSWORD (plaintext) wins, otherwise decode the
|
||||
* obfuscated passwd file (VNC_PASSWD_FILE, default ~/.config/tigervnc/passwd).
|
||||
* Returns null when nothing is available (caller then skips the keepalive).
|
||||
*/
|
||||
export function resolveVncPassword(env: NodeJS.ProcessEnv = process.env): Buffer | null {
|
||||
if (env.VNC_PASSWORD) return Buffer.from(env.VNC_PASSWORD, "utf8").subarray(0, 8);
|
||||
const file = env.VNC_PASSWD_FILE || `${homedir()}/.config/tigervnc/passwd`;
|
||||
try {
|
||||
const obf = readFileSync(file);
|
||||
if (obf.length >= 8) return decodeVncPassword(obf);
|
||||
} catch {
|
||||
/* no file - fall through */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class VncKeepalive {
|
||||
private sock: net.Socket | null = null;
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private retry: ReturnType<typeof setTimeout> | null = null;
|
||||
private stopped = false;
|
||||
|
||||
constructor(
|
||||
private opts: { host: string; port: number; password: Buffer; fps?: number },
|
||||
) {}
|
||||
|
||||
start(): void {
|
||||
this.stopped = false;
|
||||
this.connect();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
if (this.retry) clearTimeout(this.retry);
|
||||
this.timer = this.retry = null;
|
||||
try {
|
||||
this.sock?.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.sock = null;
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.stopped || this.retry) return;
|
||||
this.retry = setTimeout(() => {
|
||||
this.retry = null;
|
||||
if (!this.stopped) this.connect();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
private connect(): void {
|
||||
const sock = net.connect(this.opts.port, this.opts.host);
|
||||
this.sock = sock;
|
||||
sock.setNoDelay(true);
|
||||
|
||||
let buf = Buffer.alloc(0);
|
||||
const waiters: { n: number; res: (b: Buffer) => void }[] = [];
|
||||
const pump = () => {
|
||||
while (waiters.length && buf.length >= waiters[0].n) {
|
||||
const w = waiters.shift()!;
|
||||
const d = buf.subarray(0, w.n);
|
||||
buf = buf.subarray(w.n);
|
||||
w.res(d);
|
||||
}
|
||||
};
|
||||
const onData = (d: Buffer) => {
|
||||
buf = Buffer.concat([buf, d]);
|
||||
pump();
|
||||
};
|
||||
sock.on("data", onData);
|
||||
sock.on("error", () => {
|
||||
/* handled by close */
|
||||
});
|
||||
sock.on("close", () => {
|
||||
if (this.sock === sock) {
|
||||
this.sock = null;
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
});
|
||||
const read = (n: number) =>
|
||||
new Promise<Buffer>((res) => {
|
||||
waiters.push({ n, res });
|
||||
pump();
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await read(12); // ProtocolVersion
|
||||
sock.write("RFB 003.008\n");
|
||||
const nTypes = (await read(1))[0];
|
||||
const types = await read(nTypes);
|
||||
if (types.includes(2)) {
|
||||
sock.write(Buffer.from([2])); // VNC Auth
|
||||
const challenge = await read(16);
|
||||
sock.write(vncChallengeResponse(this.opts.password, challenge));
|
||||
if ((await read(4)).readUInt32BE(0) !== 0) return sock.destroy();
|
||||
} else if (types.includes(1)) {
|
||||
sock.write(Buffer.from([1])); // None
|
||||
if ((await read(4)).readUInt32BE(0) !== 0) return sock.destroy();
|
||||
} else {
|
||||
return sock.destroy();
|
||||
}
|
||||
sock.write(Buffer.from([1])); // ClientInit (shared)
|
||||
const si = await read(24);
|
||||
const w = si.readUInt16BE(0);
|
||||
const h = si.readUInt16BE(2);
|
||||
await read(si.readUInt32BE(20)); // desktop name
|
||||
sock.write(Buffer.from([2, 0, 0, 1, 0, 0, 0, 0])); // SetEncodings: Raw
|
||||
|
||||
// Past the handshake: stop buffering and just drain whatever the server
|
||||
// sends so its send buffer never blocks (we never decode the pixels).
|
||||
sock.removeListener("data", onData);
|
||||
sock.on("data", () => {});
|
||||
buf = Buffer.alloc(0);
|
||||
|
||||
const req = Buffer.from([3, 1, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
req.writeUInt16BE(w, 6);
|
||||
req.writeUInt16BE(h, 8);
|
||||
const interval = Math.max(1, Math.round(1000 / (this.opts.fps ?? 60)));
|
||||
this.timer = setInterval(() => {
|
||||
try {
|
||||
if (this.sock === sock && sock.writable) sock.write(req);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, interval);
|
||||
} catch {
|
||||
try {
|
||||
sock.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"types": ["node", "bun"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
Reference in New Issue
Block a user