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:
javis-bot
2026-06-10 15:21:44 +09:00
parent 8709f40fd6
commit 4176a68873
9 changed files with 410 additions and 3 deletions

View File

@@ -44,7 +44,13 @@ WHISPER_MODEL=small
# Docker desktop (VNC) — used only by the container image # Docker desktop (VNC) — used only by the container image
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# VNC viewer password (max 8 chars effective). Watch the screen at localhost:5901. # 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_PASSWORD=javis123
# VNC_PASSWD_FILE=/home/claude/.config/tigervnc/passwd
# Auto-opened page in the in-container Chrome. # Auto-opened page in the in-container Chrome.
CHROME_START_URL=about:blank CHROME_START_URL=about:blank

View File

@@ -14,6 +14,7 @@
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14",
"@types/node": "^22.7.0", "@types/node": "^22.7.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"typescript": "^5.6.3", "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=="], "@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/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=="], "@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=="], "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=="], "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=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],

View File

@@ -24,6 +24,7 @@
"discord.js-selfbot-v13": "^3.7.1" "discord.js-selfbot-v13": "^3.7.1"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14",
"@types/node": "^22.7.0", "@types/node": "^22.7.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"typescript": "^5.6.3" "typescript": "^5.6.3"

View File

@@ -32,6 +32,25 @@ bun bot/scripts/stream-test/stream-hold.ts
node bot/scripts/stream-test/scenario.mjs 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 ## A/B framerate/resolution
Lower settings to compare what Discord actually delivers to viewers, e.g.: Lower settings to compare what Discord actually delivers to viewers, e.g.:
``` ```

View 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);

View File

@@ -19,11 +19,13 @@
import { spawn, type ChildProcess } from "node:child_process"; import { spawn, type ChildProcess } from "node:child_process";
import type { AppConfig } from "../config.ts"; import type { AppConfig } from "../config.ts";
import type { ScreenStreamer, StreamContext } from "./index.ts"; import type { ScreenStreamer, StreamContext } from "./index.ts";
import { VncKeepalive, resolveVncPassword, vncPortForDisplay } from "./vnc-keepalive.ts";
export class SelfbotStreamer implements ScreenStreamer { export class SelfbotStreamer implements ScreenStreamer {
readonly kind = "selfbot" as const; readonly kind = "selfbot" as const;
private streamer: any = null; private streamer: any = null;
private capture: ChildProcess | null = null; private capture: ChildProcess | null = null;
private keepalive: VncKeepalive | null = null;
private controller: AbortController | null = null; private controller: AbortController | null = null;
private active = false; private active = false;
@@ -94,6 +96,7 @@ export class SelfbotStreamer implements ScreenStreamer {
const signal = controller.signal; const signal = controller.signal;
let streamer: any = null; let streamer: any = null;
let capture: ChildProcess | null = null; let capture: ChildProcess | null = null;
let keepalive: VncKeepalive | null = null;
try { try {
const { selfbot, vs } = await this.loadLib(); 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()); 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( const { command, output } = prepareStream(
capture.stdout, capture.stdout,
{ {
@@ -182,8 +202,35 @@ export class SelfbotStreamer implements ScreenStreamer {
if (!signal.aborted) console.error("[selfbot] playStream:", err); if (!signal.aborted) console.error("[selfbot] playStream:", err);
}) })
.finally(() => { .finally(() => {
// The stream ended on its own (not via stop()); release the lock. // The stream ended on its own (Discord closed the Go-Live, the voice
if (this.controller === controller) this.active = false; // 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)."; return "🔴 셀프봇으로 VNC 화면을 음성채널에 실시간 송출 중입니다 (Go Live).";
@@ -196,6 +243,11 @@ export class SelfbotStreamer implements ScreenStreamer {
} catch { } catch {
/* ignore */ /* ignore */
} }
try {
keepalive?.stop();
} catch {
/* ignore */
}
try { try {
streamer?.leaveVoice?.(); streamer?.leaveVoice?.();
streamer?.client?.destroy?.(); streamer?.client?.destroy?.();
@@ -208,6 +260,7 @@ export class SelfbotStreamer implements ScreenStreamer {
// unlock it mid-startup and let a third start() race in. // unlock it mid-startup and let a third start() race in.
if (this.controller === controller) { if (this.controller === controller) {
if (this.capture === capture) this.capture = null; if (this.capture === capture) this.capture = null;
if (this.keepalive === keepalive) this.keepalive = null;
if (this.streamer === streamer) this.streamer = null; if (this.streamer === streamer) this.streamer = null;
this.controller = null; this.controller = null;
this.active = false; this.active = false;
@@ -226,6 +279,12 @@ export class SelfbotStreamer implements ScreenStreamer {
/* ignore */ /* ignore */
} }
this.capture = null; this.capture = null;
try {
this.keepalive?.stop();
} catch {
/* ignore */
}
this.keepalive = null;
try { try {
this.streamer?.leaveVoice?.(); this.streamer?.leaveVoice?.();
this.streamer?.client?.destroy?.(); this.streamer?.client?.destroy?.();

View 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();
});

View 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 */
}
}
})();
}
}

View File

@@ -4,7 +4,7 @@
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"lib": ["ES2022"], "lib": ["ES2022"],
"types": ["node"], "types": ["node", "bun"],
"strict": true, "strict": true,
"noEmit": true, "noEmit": true,
"esModuleInterop": true, "esModuleInterop": true,