Monorepo for Aesthetic.Computer
aesthetic.computer
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;