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