Monorepo for Aesthetic.Computer aesthetic.computer
at main 379 lines 12 kB view raw
1/** 2 * Offscreen Rendering Manager 3 * 4 * Creates two invisible offscreen BrowserWindows: 5 * - Front: AC webview (localhost:8888 or aesthetic.computer) 6 * - Back: Terminal with xterm.js + emacs 7 * 8 * Captures frames via paint events and sends to main renderer as textures. 9 */ 10 11const { BrowserWindow, ipcMain } = require('electron'); 12const path = require('path'); 13const { spawn } = require('child_process'); 14const fs = require('fs'); 15 16// Find docker binary path 17function getDockerPath() { 18 if (process.platform === 'darwin') { 19 const dockerLocations = [ 20 '/opt/homebrew/bin/docker', 21 '/usr/local/bin/docker', 22 '/Applications/Docker.app/Contents/Resources/bin/docker' 23 ]; 24 for (const loc of dockerLocations) { 25 if (fs.existsSync(loc)) return loc; 26 } 27 } 28 return 'docker'; 29} 30 31let pty; 32try { 33 pty = require('node-pty'); 34} catch (e) { 35 console.warn('[offscreen] node-pty not available'); 36} 37 38// Try to load native OSR GPU addon for shared texture support 39let osrGpu; 40try { 41 osrGpu = require('./native/osr-gpu'); 42 osrGpu.initContext(); 43 console.log('[offscreen] Native OSR GPU addon loaded - using shared textures'); 44} catch (e) { 45 console.warn('[offscreen] Native OSR GPU addon not available:', e.message); 46 osrGpu = null; 47} 48 49class OffscreenManager { 50 constructor() { 51 this.frontWindow = null; 52 this.backWindow = null; 53 this.mainWindow = null; 54 this.frameRate = 30; 55 this.ptyProcess = null; 56 } 57 58 /** 59 * Initialize offscreen rendering with the main 3D window 60 */ 61 init(mainWindow, options = {}) { 62 this.mainWindow = mainWindow; 63 this.frameRate = options.frameRate || 30; 64 65 // Listen for start signal from renderer 66 ipcMain.on('start-offscreen-rendering', () => { 67 console.log('[offscreen] Starting offscreen rendering...'); 68 this.createOffscreenWindows(options); 69 }); 70 71 // Handle PTY connection for offscreen terminal 72 ipcMain.on('connect-offscreen-pty', (event) => { 73 console.log('[offscreen] Connecting PTY for terminal...'); 74 this.connectPTY(event.sender); 75 }); 76 77 // Handle PTY input from offscreen terminal 78 ipcMain.on('offscreen-pty-input', (event, data) => { 79 if (this.ptyProcess) { 80 this.ptyProcess.write(data); 81 } 82 }); 83 84 // ========== Input Event Forwarding ========== 85 // Forward mouse events to front/back windows 86 ipcMain.on('forward-mouse', (event, { target, type, x, y, button, clickCount }) => { 87 const targetWindow = target === 'front' ? this.frontWindow : this.backWindow; 88 if (!targetWindow || targetWindow.isDestroyed()) return; 89 90 const wc = targetWindow.webContents; 91 const modifiers = []; 92 93 if (type === 'mouseDown') { 94 wc.sendInputEvent({ type: 'mouseDown', x, y, button, clickCount, modifiers }); 95 } else if (type === 'mouseUp') { 96 wc.sendInputEvent({ type: 'mouseUp', x, y, button, clickCount, modifiers }); 97 } else if (type === 'mouseMove') { 98 wc.sendInputEvent({ type: 'mouseMove', x, y, modifiers }); 99 } else if (type === 'mouseWheel') { 100 wc.sendInputEvent({ type: 'mouseWheel', x, y, deltaX: 0, deltaY: button, modifiers }); 101 } 102 }); 103 104 // Forward keyboard events to front/back windows 105 ipcMain.on('forward-key', (event, { target, type, keyCode, modifiers }) => { 106 const targetWindow = target === 'front' ? this.frontWindow : this.backWindow; 107 if (!targetWindow || targetWindow.isDestroyed()) return; 108 109 const wc = targetWindow.webContents; 110 111 if (type === 'keyDown') { 112 wc.sendInputEvent({ type: 'keyDown', keyCode, modifiers: modifiers || [] }); 113 } else if (type === 'keyUp') { 114 wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers: modifiers || [] }); 115 } else if (type === 'char') { 116 wc.sendInputEvent({ type: 'char', keyCode, modifiers: modifiers || [] }); 117 } 118 }); 119 120 // Forward text input directly to PTY for terminal 121 ipcMain.on('forward-pty-input', (event, data) => { 122 if (this.ptyProcess) { 123 this.ptyProcess.write(data); 124 } 125 }); 126 } 127 128 connectPTY(webContents) { 129 if (!pty) { 130 webContents.send('pty-data', '\r\n\x1b[31mError: node-pty not available\x1b[0m\r\n'); 131 return; 132 } 133 134 const dockerPath = getDockerPath(); 135 136 // First check if container is running, start it if needed 137 this.ensureContainerRunning(dockerPath).then(() => { 138 // Spawn docker exec into the aesthetic container with emacsclient 139 this.ptyProcess = pty.spawn(dockerPath, [ 140 'exec', '-it', 'aesthetic', 141 'fish', '-c', 'emacsclient -nw -a "" || fish' 142 ], { 143 name: 'xterm-256color', 144 cols: 120, 145 rows: 40, 146 cwd: process.env.HOME, 147 env: { ...process.env, TERM: 'xterm-256color' } 148 }); 149 150 this.ptyProcess.onData((data) => { 151 if (!webContents.isDestroyed()) { 152 webContents.send('pty-data', data); 153 } 154 }); 155 156 this.ptyProcess.onExit(() => { 157 if (!webContents.isDestroyed()) { 158 webContents.send('pty-data', '\r\n\x1b[33m[Session ended]\x1b[0m\r\n'); 159 } 160 }); 161 162 console.log('[offscreen] PTY connected'); 163 }).catch((err) => { 164 webContents.send('pty-data', `\r\n\x1b[31mError starting container: ${err.message}\x1b[0m\r\n`); 165 }); 166 } 167 168 async ensureContainerRunning(dockerPath) { 169 const { spawn } = require('child_process'); 170 171 // Check if container is running 172 const isRunning = await new Promise((resolve) => { 173 const check = spawn(dockerPath, ['ps', '--filter', 'name=aesthetic', '--format', '{{.Names}}'], { stdio: 'pipe' }); 174 let output = ''; 175 check.stdout.on('data', (data) => output += data.toString()); 176 check.on('close', () => resolve(output.includes('aesthetic'))); 177 check.on('error', () => resolve(false)); 178 }); 179 180 if (isRunning) { 181 console.log('[offscreen] Container already running'); 182 return; 183 } 184 185 // Check if container exists but is stopped 186 const exists = await new Promise((resolve) => { 187 const check = spawn(dockerPath, ['ps', '-a', '--filter', 'name=aesthetic', '--format', '{{.Names}}'], { stdio: 'pipe' }); 188 let output = ''; 189 check.stdout.on('data', (data) => output += data.toString()); 190 check.on('close', () => resolve(output.includes('aesthetic'))); 191 check.on('error', () => resolve(false)); 192 }); 193 194 if (exists) { 195 console.log('[offscreen] Starting stopped container...'); 196 await new Promise((resolve, reject) => { 197 const start = spawn(dockerPath, ['start', 'aesthetic'], { stdio: 'pipe' }); 198 start.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Failed to start container: code ${code}`))); 199 start.on('error', reject); 200 }); 201 202 // Wait a moment for container to be ready 203 await new Promise(r => setTimeout(r, 1000)); 204 205 // Start emacs daemon 206 console.log('[offscreen] Starting emacs daemon...'); 207 await new Promise((resolve) => { 208 const emacs = spawn(dockerPath, ['exec', 'aesthetic', 'emacs', '--daemon'], { stdio: 'pipe' }); 209 emacs.on('close', () => resolve()); 210 emacs.on('error', () => resolve()); // Don't fail if emacs daemon already running 211 }); 212 213 await new Promise(r => setTimeout(r, 500)); 214 return; 215 } 216 217 throw new Error('Container "aesthetic" does not exist. Run devcontainer first.'); 218 } 219 220 createOffscreenWindows(options) { 221 // Always use production for the front view (starfield) 222 const url = 'https://aesthetic.computer/starfield'; 223 224 // Use shared texture mode if native addon is available 225 const useSharedTexture = osrGpu !== null; 226 227 // ========== Front Window (AC App) ========== 228 this.frontWindow = new BrowserWindow({ 229 width: 1280, 230 height: 800, 231 show: false, // Offscreen - never shown 232 webPreferences: { 233 offscreen: { 234 useSharedTexture: useSharedTexture 235 }, 236 nodeIntegration: false, 237 contextIsolation: true 238 } 239 }); 240 241 this.frontWindow.webContents.setFrameRate(this.frameRate); 242 243 this.frontWindow.webContents.on('paint', (event, dirty, image) => { 244 if (this.mainWindow && !this.mainWindow.isDestroyed()) { 245 // Check if we have shared texture (GPU zero-copy path) 246 if (event.texture && osrGpu) { 247 try { 248 const { data, width, height } = osrGpu.copyTextureToBuffer(event.texture.textureInfo); 249 this.mainWindow.webContents.send('front-frame', { 250 width, 251 height, 252 data: Buffer.from(data) // Convert to Buffer for IPC 253 }); 254 // CRITICAL: Release the texture back to the pool 255 event.texture.release(); 256 } catch (e) { 257 console.error('[offscreen] Failed to copy shared texture:', e); 258 } 259 } else { 260 // Fallback: Use bitmap (slower but works without native addon) 261 const size = image.getSize(); 262 const bitmap = image.toBitmap(); 263 this.mainWindow.webContents.send('front-frame', { 264 width: size.width, 265 height: size.height, 266 data: bitmap // Raw RGBA Uint8Array 267 }); 268 } 269 } 270 }); 271 272 // Force periodic repainting 273 this.frontWindow.webContents.on('did-finish-load', () => { 274 console.log('[offscreen] Front window loaded'); 275 // Invalidate to trigger paint 276 this.frontWindow.webContents.invalidate(); 277 }); 278 279 this.frontWindow.loadURL(url); 280 console.log(`[offscreen] Front window loading: ${url}`); 281 282 // ========== Back Window (Terminal) ========== 283 this.backWindow = new BrowserWindow({ 284 width: 1280, 285 height: 800, 286 show: false, // Offscreen - never shown 287 webPreferences: { 288 offscreen: { 289 useSharedTexture: useSharedTexture 290 }, 291 nodeIntegration: true, 292 contextIsolation: false 293 } 294 }); 295 296 this.backWindow.webContents.setFrameRate(this.frameRate); 297 298 this.backWindow.webContents.on('paint', (event, dirty, image) => { 299 if (this.mainWindow && !this.mainWindow.isDestroyed()) { 300 // Check if we have shared texture (GPU zero-copy path) 301 if (event.texture && osrGpu) { 302 try { 303 const { data, width, height } = osrGpu.copyTextureToBuffer(event.texture.textureInfo); 304 this.mainWindow.webContents.send('back-frame', { 305 width, 306 height, 307 data: Buffer.from(data) 308 }); 309 event.texture.release(); 310 } catch (e) { 311 console.error('[offscreen] Failed to copy shared texture:', e); 312 } 313 } else { 314 // Fallback: Use bitmap 315 const size = image.getSize(); 316 const bitmap = image.toBitmap(); 317 this.mainWindow.webContents.send('back-frame', { 318 width: size.width, 319 height: size.height, 320 data: bitmap 321 }); 322 } 323 } 324 }); 325 326 this.backWindow.webContents.on('did-finish-load', () => { 327 console.log('[offscreen] Back window loaded'); 328 this.backWindow.webContents.invalidate(); 329 }); 330 331 // Load the terminal renderer 332 this.backWindow.loadFile(path.join(__dirname, 'renderer', 'terminal-offscreen.html')); 333 console.log('[offscreen] Back window loading terminal...'); 334 } 335 336 /** 337 * Navigate front window to a different piece 338 */ 339 navigateFront(url) { 340 if (this.frontWindow && !this.frontWindow.isDestroyed()) { 341 this.frontWindow.loadURL(url); 342 } 343 } 344 345 /** 346 * Send command to terminal 347 */ 348 sendToTerminal(data) { 349 if (this.backWindow && !this.backWindow.isDestroyed()) { 350 this.backWindow.webContents.send('terminal-input', data); 351 } 352 } 353 354 /** 355 * Clean up 356 */ 357 destroy() { 358 if (this.frontWindow && !this.frontWindow.isDestroyed()) { 359 this.frontWindow.destroy(); 360 } 361 if (this.backWindow && !this.backWindow.isDestroyed()) { 362 this.backWindow.destroy(); 363 } 364 if (this.ptyProcess) { 365 this.ptyProcess.kill(); 366 this.ptyProcess = null; 367 } 368 // Clean up native OSR GPU context 369 if (osrGpu) { 370 try { 371 osrGpu.destroyContext(); 372 } catch (e) { 373 console.warn('[offscreen] Failed to destroy OSR GPU context:', e); 374 } 375 } 376 } 377} 378 379module.exports = OffscreenManager;