import { spawn } from "node:child_process"; import { readdirSync, statSync } from "node:fs"; import { createConnection, type Socket } from "node:net"; import { join } from "node:path"; import { checkSignal, wait, type DesktopBackend } from "./backend.ts"; const runtimeDir = process.env.XDG_RUNTIME_DIR ?? `/run/user/${process.getuid?.()}`; function waylandEnv() { const configured = process.env.WAYLAND_DISPLAY; if (configured) { try { if (statSync(join(runtimeDir, configured)).isSocket()) { return { ...process.env, XDG_RUNTIME_DIR: runtimeDir, WAYLAND_DISPLAY: configured }; } } catch {} } const display = readdirSync(runtimeDir) .filter((name) => /^wayland-\d+$/.test(name)) .map((name) => ({ name, modified: statSync(join(runtimeDir, name)).mtimeMs })) .sort((a, b) => b.modified - a.modified)[0]?.name; if (!display) throw new Error(`No Wayland compositor found in ${runtimeDir}`); return { ...process.env, XDG_RUNTIME_DIR: runtimeDir, WAYLAND_DISPLAY: display }; } function run(command: string, args: string[], signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new Error("Operation aborted")); return; } const child = spawn(command, args, { env: waylandEnv(), stdio: ["ignore", "pipe", "pipe"], }); const stdout: Buffer[] = []; const stderr: Buffer[] = []; const abort = () => child.kill(); child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)); child.on("error", reject); child.on("close", (code) => { signal?.removeEventListener("abort", abort); if (signal?.aborted) reject(new Error("Operation aborted")); else if (code === 0) resolve(Buffer.concat(stdout)); else { reject( new Error( Buffer.concat(stderr).toString().trim() || `${command} exited with code ${code}`, ), ); } }); signal?.addEventListener("abort", abort, { once: true }); }); } interface VncClient { socket: Socket; width: number; height: number; } const MODIFIER_KEYSYMS: Record = { shift: 0xffe1, ctrl: 0xffe3, alt: 0xffe8, logo: 0xffeb, }; const NAMED_KEYSYMS: Record = { Return: 0xff0d, Enter: 0xff0d, Escape: 0xff1b, Esc: 0xff1b, Tab: 0xff09, BackSpace: 0xff08, Backspace: 0xff08, Delete: 0xffff, Del: 0xffff, Insert: 0xff63, Ins: 0xff63, space: 0x20, Left: 0xff51, Right: 0xff53, Up: 0xff52, Down: 0xff54, Page_Up: 0xff55, Page_Down: 0xff56, Home: 0xff50, End: 0xff57, F1: 0xffbe, F2: 0xffbf, F3: 0xffc0, F4: 0xffc1, F5: 0xffc2, F6: 0xffc3, F7: 0xffc4, F8: 0xffc5, F9: 0xffc6, F10: 0xffc7, F11: 0xffc8, F12: 0xffc9, }; // Send keysyms literally and never synthesize shift: wayvnc resolves each // keysym to a (keycode, level) pair and temporarily applies the level's own // modifiers itself. Holding shift around a base keysym does the opposite of // what is wanted (shift + "1" would type "1", not "!"). Tool-level modifiers // (ctrl/shift/alt held with another key) still pass through verbatim; they // combine with the resolved level instead of overriding it. function keysymForChar(ch: string): number { if (ch === "\n") return 0xff0d; if (ch === "\t") return 0xff09; const codePoint = ch.codePointAt(0)!; return codePoint <= 0xff ? codePoint : 0x01000000 | codePoint; } function keysymForName(name: string): number { const named = NAMED_KEYSYMS[name]; if (named !== undefined) return named; if (name.length === 1) return keysymForChar(name); throw new Error(`Unsupported key name: ${name}`); } function pointerMessage(mask: number, x: number, y: number): Buffer { const message = Buffer.alloc(6); message[0] = 5; message[1] = mask; message.writeUInt16BE(x, 2); message.writeUInt16BE(y, 4); return message; } function keyMessage(down: boolean, keysym: number): Buffer { const message = Buffer.alloc(8); message[0] = 4; message[1] = down ? 1 : 0; message.writeUInt32BE(keysym >>> 0, 4); return message; } function toFramebuffer(client: VncClient, x: number, y: number): { x: number; y: number } { const scale = (value: number, toolMax: number, fbMax: number) => Math.min(fbMax, Math.max(0, Math.round((value / toolMax) * fbMax))); return { x: scale(x, 1919, client.width - 1), y: scale(y, 1079, client.height - 1), }; } const POINTER_BUTTONS: Record = { left: 1, middle: 2, right: 4 }; function movePointer( client: VncClient, x: number, y: number, buttons: readonly string[] = [], signal?: AbortSignal, ): void { checkSignal(signal); const position = toFramebuffer(client, x, y); client.socket.write(pointerMessage(toMask(buttons), position.x, position.y)); } function pressKey( client: VncClient, keysym: number, modifiers: readonly string[], signal?: AbortSignal, ): void { checkSignal(signal); const held: number[] = []; for (const modifier of modifiers) { const symbol = MODIFIER_KEYSYMS[modifier]; if (symbol === undefined) throw new Error(`Unsupported modifier: ${modifier}`); held.push(symbol); } for (const symbol of held) client.socket.write(keyMessage(true, symbol)); client.socket.write(keyMessage(true, keysym)); client.socket.write(keyMessage(false, keysym)); for (let index = held.length - 1; index >= 0; index--) { client.socket.write(keyMessage(false, held[index]!)); } } function typeText( client: VncClient, text: string, signal?: AbortSignal, ): void { for (const ch of text) { checkSignal(signal); pressKey(client, keysymForChar(ch), [], signal); } } function scrollNotches( client: VncClient, x: number, y: number, bit: number, ticks: number, buttons: readonly string[], signal?: AbortSignal, ): void { movePointer(client, x, y, buttons, signal); checkSignal(signal); const held = toMask(buttons); const at = toFramebuffer(client, x, y); for (let index = 0; index < ticks; index++) { client.socket.write(pointerMessage(bit | held, at.x, at.y)); client.socket.write(pointerMessage(held, at.x, at.y)); } } function toMask(buttons: readonly string[]): number { let mask = 0; for (const button of buttons) { const bit = POINTER_BUTTONS[button]; if (bit === undefined) throw new Error(`Unsupported button: ${button}`); mask |= bit; } return mask; } async function connectVnc(signal?: AbortSignal): Promise { const socket = createConnection({ host: "127.0.0.1", port: 5900, signal }); socket.on("error", () => {}); socket.setTimeout(5_000, () => socket.destroy(new Error("WayVNC handshake timed out"))); const chunks = socket.iterator({ destroyOnReturn: false }); let buffered = Buffer.alloc(0); async function read(length: number): Promise { while (buffered.length < length) { const chunk = await chunks.next(); if (chunk.done) throw new Error("WayVNC disconnected during handshake"); buffered = Buffer.concat([buffered, chunk.value]); } const result = buffered.subarray(0, length); buffered = buffered.subarray(length); return result; } try { if ((await read(12)).toString() !== "RFB 003.008\n") { throw new Error("Expected WayVNC to support RFB 3.8"); } socket.write("RFB 003.008\n"); const securityTypes = await read((await read(1))[0]); if (!securityTypes.includes(1)) { throw new Error("Local WayVNC must allow unauthenticated connections"); } socket.write(Buffer.from([1])); if ((await read(4)).readUInt32BE() !== 0) { throw new Error("WayVNC rejected the connection"); } socket.write(Buffer.from([1])); // Share the desktop with existing VNC clients. const serverInit = await read(24); const width = serverInit.readUInt16BE(0); const height = serverInit.readUInt16BE(2); if (width <= 0 || height <= 0) throw new Error("WayVNC reported an empty framebuffer"); await chunks.return?.(); socket.setTimeout(0); socket.resume(); // Give Firefox time to bind the seat's newly advertised input devices. await wait(100, signal); if (socket.destroyed) throw socket.errored ?? new Error("WayVNC disconnected"); return { socket, width, height }; } catch (error) { socket.destroy(); throw error; } } export function createWaylandBackend(): DesktopBackend { let vnc: VncClient | undefined; return { async execute(action, signal) { checkSignal(signal); if (vnc?.socket.destroyed) { throw vnc.socket.errored ?? new Error("WayVNC disconnected"); } if (action.action === "screenshot") return run("grim", ["-c", "-"], signal); if (action.action === "new_browser_tab") { const child = spawn("firefox", ["--new-tab", action.url], { env: { ...waylandEnv(), MOZ_ENABLE_WAYLAND: "1" }, detached: true, stdio: "ignore", }); await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); }); child.unref(); return; } // One VNC connection carries all pointer and keyboard input. Mouse and // keyboard arrive as RFB messages, so no per-action subprocess is needed. vnc ??= await connectVnc(signal); if (action.action === "pointer") { movePointer(vnc, action.x, action.y, action.buttons ?? [], signal); } else if (action.action === "scroll") { // Buttons 4/5 scroll vertically; 6/7 conventionally scroll horizontally. // wayvnc forwards what libinput understands and ignores the rest. const bits = { up: 8, down: 16, left: 32, right: 64 } as const; scrollNotches( vnc, action.x, action.y, bits[action.direction], action.ticks ?? 1, action.buttons ?? [], signal, ); } else if (action.action === "type") { typeText(vnc, action.text, signal); } else { pressKey(vnc, keysymForName(action.key), action.modifiers ?? [], signal); } if (vnc.socket.destroyed) { throw vnc.socket.errored ?? new Error("WayVNC disconnected"); } }, async close() { if (vnc) { // Let clients process the last input before removing the seat's devices. await wait(100); vnc.socket.destroy(); } }, }; }