char-slop/ai-dots

ai dotfiles

git clone https://git.t4t.associates/char-slop/ai-dots

Charlotte Somcomputer use for windowsdc2c12a

main
10.3 KiB335 linesraw
1import { spawn } from "node:child_process";
2import { readdirSync, statSync } from "node:fs";
3import { createConnection, type Socket } from "node:net";
4import { join } from "node:path";
5import { checkSignal, wait, type DesktopBackend } from "./backend.ts";
6
7const runtimeDir = process.env.XDG_RUNTIME_DIR ?? `/run/user/${process.getuid?.()}`;
8
9function waylandEnv() {
10  const configured = process.env.WAYLAND_DISPLAY;
11  if (configured) {
12    try {
13      if (statSync(join(runtimeDir, configured)).isSocket()) {
14        return { ...process.env, XDG_RUNTIME_DIR: runtimeDir, WAYLAND_DISPLAY: configured };
15      }
16    } catch {}
17  }
18
19  const display = readdirSync(runtimeDir)
20    .filter((name) => /^wayland-\d+$/.test(name))
21    .map((name) => ({ name, modified: statSync(join(runtimeDir, name)).mtimeMs }))
22    .sort((a, b) => b.modified - a.modified)[0]?.name;
23
24  if (!display) throw new Error(`No Wayland compositor found in ${runtimeDir}`);
25  return { ...process.env, XDG_RUNTIME_DIR: runtimeDir, WAYLAND_DISPLAY: display };
26}
27
28function run(command: string, args: string[], signal?: AbortSignal): Promise<Buffer> {
29  return new Promise((resolve, reject) => {
30    if (signal?.aborted) {
31      reject(new Error("Operation aborted"));
32      return;
33    }
34
35    const child = spawn(command, args, {
36      env: waylandEnv(),
37      stdio: ["ignore", "pipe", "pipe"],
38    });
39    const stdout: Buffer[] = [];
40    const stderr: Buffer[] = [];
41    const abort = () => child.kill();
42
43    child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
44    child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
45    child.on("error", reject);
46    child.on("close", (code) => {
47      signal?.removeEventListener("abort", abort);
48      if (signal?.aborted) reject(new Error("Operation aborted"));
49      else if (code === 0) resolve(Buffer.concat(stdout));
50      else {
51        reject(
52          new Error(
53            Buffer.concat(stderr).toString().trim() || `${command} exited with code ${code}`,
54          ),
55        );
56      }
57    });
58
59    signal?.addEventListener("abort", abort, { once: true });
60  });
61}
62
63interface VncClient {
64  socket: Socket;
65  width: number;
66  height: number;
67}
68
69const MODIFIER_KEYSYMS: Record<string, number> = {
70  shift: 0xffe1,
71  ctrl: 0xffe3,
72  alt: 0xffe8,
73  logo: 0xffeb,
74};
75
76const NAMED_KEYSYMS: Record<string, number> = {
77  Return: 0xff0d,
78  Enter: 0xff0d,
79  Escape: 0xff1b,
80  Esc: 0xff1b,
81  Tab: 0xff09,
82  BackSpace: 0xff08,
83  Backspace: 0xff08,
84  Delete: 0xffff,
85  Del: 0xffff,
86  Insert: 0xff63,
87  Ins: 0xff63,
88  space: 0x20,
89  Left: 0xff51,
90  Right: 0xff53,
91  Up: 0xff52,
92  Down: 0xff54,
93  Page_Up: 0xff55,
94  Page_Down: 0xff56,
95  Home: 0xff50,
96  End: 0xff57,
97  F1: 0xffbe,
98  F2: 0xffbf,
99  F3: 0xffc0,
100  F4: 0xffc1,
101  F5: 0xffc2,
102  F6: 0xffc3,
103  F7: 0xffc4,
104  F8: 0xffc5,
105  F9: 0xffc6,
106  F10: 0xffc7,
107  F11: 0xffc8,
108  F12: 0xffc9,
109};
110
111// Send keysyms literally and never synthesize shift: wayvnc resolves each
112// keysym to a (keycode, level) pair and temporarily applies the level's own
113// modifiers itself. Holding shift around a base keysym does the opposite of
114// what is wanted (shift + "1" would type "1", not "!"). Tool-level modifiers
115// (ctrl/shift/alt held with another key) still pass through verbatim; they
116// combine with the resolved level instead of overriding it.
117function keysymForChar(ch: string): number {
118  if (ch === "\n") return 0xff0d;
119  if (ch === "\t") return 0xff09;
120  const codePoint = ch.codePointAt(0)!;
121  return codePoint <= 0xff ? codePoint : 0x01000000 | codePoint;
122}
123
124function keysymForName(name: string): number {
125  const named = NAMED_KEYSYMS[name];
126  if (named !== undefined) return named;
127  if (name.length === 1) return keysymForChar(name);
128  throw new Error(`Unsupported key name: ${name}`);
129}
130
131function pointerMessage(mask: number, x: number, y: number): Buffer {
132  const message = Buffer.alloc(6);
133  message[0] = 5;
134  message[1] = mask;
135  message.writeUInt16BE(x, 2);
136  message.writeUInt16BE(y, 4);
137  return message;
138}
139
140function keyMessage(down: boolean, keysym: number): Buffer {
141  const message = Buffer.alloc(8);
142  message[0] = 4;
143  message[1] = down ? 1 : 0;
144  message.writeUInt32BE(keysym >>> 0, 4);
145  return message;
146}
147
148function toFramebuffer(client: VncClient, x: number, y: number): { x: number; y: number } {
149  const scale = (value: number, toolMax: number, fbMax: number) =>
150    Math.min(fbMax, Math.max(0, Math.round((value / toolMax) * fbMax)));
151  return {
152    x: scale(x, 1919, client.width - 1),
153    y: scale(y, 1079, client.height - 1),
154  };
155}
156
157const POINTER_BUTTONS: Record<string, number> = { left: 1, middle: 2, right: 4 };
158
159function movePointer(
160  client: VncClient,
161  x: number,
162  y: number,
163  buttons: readonly string[] = [],
164  signal?: AbortSignal,
165): void {
166  checkSignal(signal);
167  const position = toFramebuffer(client, x, y);
168  client.socket.write(pointerMessage(toMask(buttons), position.x, position.y));
169}
170
171function pressKey(
172  client: VncClient,
173  keysym: number,
174  modifiers: readonly string[],
175  signal?: AbortSignal,
176): void {
177  checkSignal(signal);
178  const held: number[] = [];
179  for (const modifier of modifiers) {
180    const symbol = MODIFIER_KEYSYMS[modifier];
181    if (symbol === undefined) throw new Error(`Unsupported modifier: ${modifier}`);
182    held.push(symbol);
183  }
184  for (const symbol of held) client.socket.write(keyMessage(true, symbol));
185  client.socket.write(keyMessage(true, keysym));
186  client.socket.write(keyMessage(false, keysym));
187  for (let index = held.length - 1; index >= 0; index--) {
188    client.socket.write(keyMessage(false, held[index]!));
189  }
190}
191
192function typeText(
193  client: VncClient,
194  text: string,
195  signal?: AbortSignal,
196): void {
197  for (const ch of text) {
198    checkSignal(signal);
199    pressKey(client, keysymForChar(ch), [], signal);
200  }
201}
202
203function scrollNotches(
204  client: VncClient,
205  x: number,
206  y: number,
207  bit: number,
208  ticks: number,
209  buttons: readonly string[],
210  signal?: AbortSignal,
211): void {
212  movePointer(client, x, y, buttons, signal);
213  checkSignal(signal);
214  const held = toMask(buttons);
215  const at = toFramebuffer(client, x, y);
216  for (let index = 0; index < ticks; index++) {
217    client.socket.write(pointerMessage(bit | held, at.x, at.y));
218    client.socket.write(pointerMessage(held, at.x, at.y));
219  }
220}
221
222function toMask(buttons: readonly string[]): number {
223  let mask = 0;
224  for (const button of buttons) {
225    const bit = POINTER_BUTTONS[button];
226    if (bit === undefined) throw new Error(`Unsupported button: ${button}`);
227    mask |= bit;
228  }
229  return mask;
230}
231
232async function connectVnc(signal?: AbortSignal): Promise<VncClient> {
233  const socket = createConnection({ host: "127.0.0.1", port: 5900, signal });
234  socket.on("error", () => {});
235  socket.setTimeout(5_000, () => socket.destroy(new Error("WayVNC handshake timed out")));
236  const chunks = socket.iterator({ destroyOnReturn: false });
237  let buffered = Buffer.alloc(0);
238
239  async function read(length: number): Promise<Buffer> {
240    while (buffered.length < length) {
241      const chunk = await chunks.next();
242      if (chunk.done) throw new Error("WayVNC disconnected during handshake");
243      buffered = Buffer.concat([buffered, chunk.value]);
244    }
245    const result = buffered.subarray(0, length);
246    buffered = buffered.subarray(length);
247    return result;
248  }
249
250  try {
251    if ((await read(12)).toString() !== "RFB 003.008\n") {
252      throw new Error("Expected WayVNC to support RFB 3.8");
253    }
254    socket.write("RFB 003.008\n");
255    const securityTypes = await read((await read(1))[0]);
256    if (!securityTypes.includes(1)) {
257      throw new Error("Local WayVNC must allow unauthenticated connections");
258    }
259    socket.write(Buffer.from([1]));
260    if ((await read(4)).readUInt32BE() !== 0) {
261      throw new Error("WayVNC rejected the connection");
262    }
263    socket.write(Buffer.from([1])); // Share the desktop with existing VNC clients.
264    const serverInit = await read(24);
265    const width = serverInit.readUInt16BE(0);
266    const height = serverInit.readUInt16BE(2);
267    if (width <= 0 || height <= 0) throw new Error("WayVNC reported an empty framebuffer");
268    await chunks.return?.();
269    socket.setTimeout(0);
270    socket.resume();
271
272    // Give Firefox time to bind the seat's newly advertised input devices.
273    await wait(100, signal);
274    if (socket.destroyed) throw socket.errored ?? new Error("WayVNC disconnected");
275    return { socket, width, height };
276  } catch (error) {
277    socket.destroy();
278    throw error;
279  }
280}
281
282export function createWaylandBackend(): DesktopBackend {
283  let vnc: VncClient | undefined;
284  return {
285    async execute(action, signal) {
286      checkSignal(signal);
287      if (vnc?.socket.destroyed) {
288        throw vnc.socket.errored ?? new Error("WayVNC disconnected");
289      }
290      if (action.action === "screenshot") return run("grim", ["-c", "-"], signal);
291      if (action.action === "new_browser_tab") {
292        const child = spawn("firefox", ["--new-tab", action.url], {
293          env: { ...waylandEnv(), MOZ_ENABLE_WAYLAND: "1" },
294          detached: true,
295          stdio: "ignore",
296        });
297        await new Promise<void>((resolve, reject) => {
298          child.once("spawn", resolve);
299          child.once("error", reject);
300        });
301        child.unref();
302        return;
303      }
304
305      // One VNC connection carries all pointer and keyboard input. Mouse and
306      // keyboard arrive as RFB messages, so no per-action subprocess is needed.
307      vnc ??= await connectVnc(signal);
308      if (action.action === "pointer") {
309        movePointer(vnc, action.x, action.y, action.buttons ?? [], signal);
310      } else if (action.action === "scroll") {
311        // Buttons 4/5 scroll vertically; 6/7 conventionally scroll horizontally.
312        // wayvnc forwards what libinput understands and ignores the rest.
313        const bits = { up: 8, down: 16, left: 32, right: 64 } as const;
314        scrollNotches(
315          vnc, action.x, action.y, bits[action.direction], action.ticks ?? 1,
316          action.buttons ?? [], signal,
317        );
318      } else if (action.action === "type") {
319        typeText(vnc, action.text, signal);
320      } else {
321        pressKey(vnc, keysymForName(action.key), action.modifiers ?? [], signal);
322      }
323      if (vnc.socket.destroyed) {
324        throw vnc.socket.errored ?? new Error("WayVNC disconnected");
325      }
326    },
327    async close() {
328      if (vnc) {
329        // Let clients process the last input before removing the seat's devices.
330        await wait(100);
331        vnc.socket.destroy();
332      }
333    },
334  };
335}