Monorepo for Aesthetic.Computer
aesthetic.computer
1/**
2 * Aesthetic Computer - Electron Main Process
3 *
4 * Single window type:
5 * - AC Pane (3D flip view with front webview + back terminal)
6 */
7
8const { app, BrowserWindow, ipcMain, globalShortcut, Menu, Tray, dialog, shell, nativeImage, screen, Notification, net } = require('electron');
9const path = require('path');
10const fs = require('fs');
11const { spawn, execSync } = require('child_process');
12
13// FF1 Bridge - local server for kidlisp.com to communicate with FF1 devices
14const ff1Bridge = require('./ff1-bridge');
15
16// =============================================================================
17// DEV MODE - Load files from repo instead of app bundle
18// =============================================================================
19// To enable: touch ~/.ac-electron-dev
20// To disable: rm ~/.ac-electron-dev
21// When enabled, renderer files load from ~/aesthetic-computer/ac-electron/
22// This lets you iterate on the Electron app without rebuilding!
23// =============================================================================
24const DEV_FLAG_PATH = path.join(require('os').homedir(), '.ac-electron-dev');
25const DEV_REPO_PATH = path.join(require('os').homedir(), 'aesthetic-computer', 'ac-electron');
26const isDevMode = fs.existsSync(DEV_FLAG_PATH) && fs.existsSync(DEV_REPO_PATH);
27
28if (isDevMode) {
29 console.log('[main] 🔧 DEV MODE ENABLED - loading files from:', DEV_REPO_PATH);
30}
31
32// Detect PaperWM tiling window manager (GNOME extension)
33let isPaperWM = false;
34if (process.platform === 'linux') {
35 try {
36 const extensions = execSync('gnome-extensions list --enabled 2>/dev/null', { encoding: 'utf8', timeout: 3000 });
37 isPaperWM = extensions.includes('paperwm');
38 if (isPaperWM) console.log('[main] PaperWM detected - using tiling-friendly window mode');
39 } catch (e) {
40 // Not GNOME or gnome-extensions not available
41 }
42}
43
44// Helper to get file path (repo in dev mode, bundle in production)
45function getAppPath(relativePath) {
46 if (isDevMode) {
47 return path.join(DEV_REPO_PATH, relativePath);
48 }
49 return path.join(__dirname, relativePath);
50}
51
52// macOS Tahoe + Chromium fontations workaround (testing with Electron 39 / Chromium M142)
53// Disable problematic font features that may trigger fontations_ffi crash
54app.commandLine.appendSwitch('disable-features', 'FontationsFontBackend,Fontations');
55if (process.platform === 'darwin') {
56 app.commandLine.appendSwitch('use-angle', 'metal');
57} else if (process.platform === 'linux') {
58 app.commandLine.appendSwitch('enable-gpu-rasterization');
59 app.commandLine.appendSwitch('enable-zero-copy');
60 app.commandLine.appendSwitch('ignore-gpu-blocklist');
61 app.commandLine.appendSwitch('enable-native-gpu-memory-buffers');
62 app.commandLine.appendSwitch('enable-accelerated-video-decode');
63 app.commandLine.appendSwitch('enable-webgl2-compute-context');
64 app.commandLine.appendSwitch('canvas-oop-rasterization');
65}
66// Performance: no throttling, more memory, high-priority audio
67app.commandLine.appendSwitch('disable-renderer-backgrounding');
68app.commandLine.appendSwitch('disable-background-timer-throttling');
69app.commandLine.appendSwitch('disable-backgrounding-occluded-windows');
70app.commandLine.appendSwitch('js-flags', '--max-old-space-size=4096');
71app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
72
73// Preferences storage
74const PREFS_PATH = path.join(app.getPath('userData'), 'preferences.json');
75let preferences = {
76 showTrayTitle: true,
77 trayTitleText: 'AC', // Short text next to tray icon
78 launchAtLogin: false,
79 defaultMode: 'ac-pane'
80};
81
82function loadPreferences() {
83 try {
84 if (fs.existsSync(PREFS_PATH)) {
85 const data = fs.readFileSync(PREFS_PATH, 'utf8');
86 preferences = { ...preferences, ...JSON.parse(data) };
87 }
88 } catch (e) {
89 console.warn('[prefs] Failed to load preferences:', e.message);
90 }
91}
92
93function savePreferences() {
94 try {
95 fs.writeFileSync(PREFS_PATH, JSON.stringify(preferences, null, 2));
96 } catch (e) {
97 console.warn('[prefs] Failed to save preferences:', e.message);
98 }
99}
100
101// Auto-updater (only in production builds)
102let autoUpdater;
103let autoUpdaterError = null;
104let updateDownloaded = false;
105let updateAvailable = null; // { version, url, releaseNotes }
106let trayBlinkInterval = null;
107let trayIconState = 'normal'; // 'normal', 'update', 'blink'
108let originalTrayIcon = null;
109let updateTrayIcon = null;
110const UPDATE_CHECK_INTERVAL = 60 * 60 * 1000; // Check every hour
111const SILO_RELEASE_URL = 'https://silo.aesthetic.computer/desktop/latest';
112
113try {
114 autoUpdater = require('electron-updater').autoUpdater;
115
116 // Configure auto-updater for silent background downloads
117 autoUpdater.autoDownload = true; // Download automatically in background
118 autoUpdater.autoInstallOnAppQuit = true; // Install on next launch
119
120 autoUpdater.on('checking-for-update', () => {
121 console.log('[updater] Checking for updates...');
122 });
123
124 autoUpdater.on('update-available', (info) => {
125 console.log('[updater] Update available:', info.version, '- downloading in background...');
126 });
127
128 autoUpdater.on('update-not-available', () => {
129 console.log('[updater] App is up to date');
130 });
131
132 autoUpdater.on('download-progress', (progress) => {
133 console.log(`[updater] Downloading: ${Math.round(progress.percent)}%`);
134 });
135
136 autoUpdater.on('update-downloaded', (info) => {
137 console.log('[updater] Update downloaded:', info.version);
138 updateDownloaded = true;
139
140 // Show subtle notification - update will install on next launch
141 if (Notification.isSupported()) {
142 const notification = new Notification({
143 title: 'Update Ready',
144 body: `v${info.version} will install on next launch. Click to restart now.`,
145 icon: path.join(__dirname, 'build', 'icon.png'),
146 silent: true
147 });
148 notification.on('click', () => {
149 autoUpdater.quitAndInstall(false, true);
150 });
151 notification.show();
152 }
153 });
154
155 autoUpdater.on('error', (err) => {
156 console.error('[updater] Error:', err.message);
157 });
158
159} catch (e) {
160 autoUpdaterError = e.message;
161 console.log('[updater] Auto-updater not available:', e.message);
162}
163
164// Start periodic update checks
165function startUpdateChecks() {
166 console.log('[updater] startUpdateChecks called, autoUpdater:', !!autoUpdater, 'isPackaged:', app.isPackaged);
167 if (!autoUpdater) {
168 console.log('[updater] Skipping - autoUpdater not loaded, error:', autoUpdaterError);
169 return;
170 }
171 if (!app.isPackaged) {
172 console.log('[updater] Skipping - not a packaged build');
173 return;
174 }
175
176 const checkForUpdates = () => {
177 if (!updateDownloaded) {
178 console.log('[updater] Running update check...');
179 autoUpdater.checkForUpdates().catch(err => {
180 console.log('[updater] Check failed:', err.message);
181 });
182 }
183 };
184
185 // Initial check after 5 seconds
186 setTimeout(checkForUpdates, 5000);
187
188 // Then check every hour
189 setInterval(checkForUpdates, UPDATE_CHECK_INTERVAL);
190}
191
192// Silo release checking (works in dev mode too)
193function checkSiloForUpdates() {
194 const currentVersion = app.getVersion();
195 console.log('[silo] Checking for updates, current version:', currentVersion);
196
197 const request = net.request(SILO_RELEASE_URL);
198 request.setHeader('User-Agent', 'Aesthetic-Computer-Electron');
199
200 let responseData = '';
201
202 request.on('response', (response) => {
203 response.on('data', (chunk) => {
204 responseData += chunk.toString();
205 });
206
207 response.on('end', () => {
208 try {
209 const release = JSON.parse(responseData);
210 const latestVersion = release.version;
211
212 if (latestVersion && isNewerVersion(latestVersion, currentVersion)) {
213 console.log('[silo] Update available:', latestVersion);
214 const platformKey = process.platform === 'darwin' ? 'mac'
215 : process.platform === 'win32' ? 'win' : 'linux';
216 updateAvailable = {
217 version: latestVersion,
218 url: release.platforms?.[platformKey]?.url || 'https://aesthetic.computer/desktop',
219 releaseNotes: release.releaseNotes,
220 publishedAt: release.publishedAt
221 };
222 startTrayBlink();
223 rebuildTrayMenu();
224 } else {
225 console.log('[silo] App is up to date');
226 }
227 } catch (e) {
228 console.warn('[silo] Failed to parse release info:', e.message);
229 }
230 });
231 });
232
233 request.on('error', (err) => {
234 console.warn('[silo] Failed to check for updates:', err.message);
235 });
236
237 request.end();
238}
239
240// Compare semantic versions
241function isNewerVersion(latest, current) {
242 const parseVersion = (v) => v.split('.').map(n => parseInt(n, 10) || 0);
243 const latestParts = parseVersion(latest);
244 const currentParts = parseVersion(current);
245
246 for (let i = 0; i < 3; i++) {
247 const l = latestParts[i] || 0;
248 const c = currentParts[i] || 0;
249 if (l > c) return true;
250 if (l < c) return false;
251 }
252 return false;
253}
254
255// Start blinking tray icon
256function startTrayBlink() {
257 if (trayBlinkInterval || !tray) return;
258
259 console.log('[tray] Starting update blink indicator');
260 let blinkOn = true;
261
262 trayBlinkInterval = setInterval(() => {
263 if (!tray) {
264 stopTrayBlink();
265 return;
266 }
267
268 if (blinkOn) {
269 // Show update indicator (colored dot or different icon)
270 if (updateTrayIcon) {
271 tray.setImage(updateTrayIcon);
272 }
273 // Also update title to show update available
274 if (process.platform === 'darwin') {
275 tray.setTitle('⬆️ Update');
276 }
277 } else {
278 // Show normal icon
279 if (originalTrayIcon) {
280 tray.setImage(originalTrayIcon);
281 }
282 if (process.platform === 'darwin') {
283 updateTrayTitle();
284 }
285 }
286 blinkOn = !blinkOn;
287 }, 1500); // Blink every 1.5 seconds
288}
289
290// Stop blinking
291function stopTrayBlink() {
292 if (trayBlinkInterval) {
293 clearInterval(trayBlinkInterval);
294 trayBlinkInterval = null;
295 }
296 if (tray && originalTrayIcon) {
297 tray.setImage(originalTrayIcon);
298 updateTrayTitle();
299 }
300}
301
302// Create update indicator icon (adds a colored badge)
303function createUpdateIcon(baseIcon) {
304 if (process.platform === 'darwin') {
305 // On macOS, we can't easily modify template images, so we'll use title instead
306 return baseIcon;
307 }
308
309 // For Windows/Linux, create a modified icon with a badge
310 try {
311 const size = baseIcon.getSize();
312 const canvas = nativeImage.createEmpty();
313 // For now, just return the base icon - could enhance with badge overlay later
314 return baseIcon;
315 } catch (e) {
316 return baseIcon;
317 }
318}
319
320// Start silo update checks (works in both dev and production)
321function startSiloUpdateChecks() {
322 // Initial check after 10 seconds
323 setTimeout(checkSiloForUpdates, 10000);
324
325 // Then check every hour
326 setInterval(checkSiloForUpdates, UPDATE_CHECK_INTERVAL);
327}
328
329// Set app name before anything else
330app.setName('Aesthetic Computer');
331process.title = 'Aesthetic Computer';
332
333// Set dock icon on macOS
334if (process.platform === 'darwin') {
335 const iconPath = path.join(__dirname, 'build', 'icon.png');
336 try {
337 const icon = nativeImage.createFromPath(iconPath);
338 if (!icon.isEmpty()) {
339 app.dock.setIcon(icon);
340 }
341 } catch (e) {
342 console.warn('Could not set dock icon:', e.message);
343 }
344}
345
346// Try to load node-pty for real terminal support
347let pty;
348try {
349 pty = require('node-pty');
350} catch (e) {
351 console.warn('node-pty not available, terminal features will be limited');
352}
353
354// Parse command line args
355const args = process.argv.slice(2);
356const startInDevMode = args.includes('--dev') || args.includes('--development');
357const pieceArg = args.find(a => a.startsWith('--piece=')) || args[args.indexOf('--piece') + 1];
358const initialPiece = pieceArg?.replace('--piece=', '') || 'prompt';
359
360// URLs - nogap removes the aesthetic gap border for desktop mode
361const URLS = {
362 production: `https://aesthetic.computer/${initialPiece}?nogap=true`,
363 development: `http://localhost:8888/${initialPiece}?nogap=true`
364};
365
366// Track all windows: windowId -> { window, mode, ptyProcess? }
367const windows = new Map();
368let windowIdCounter = 0;
369let focusedWindowId = null;
370let mainWindowId = null; // Track the "main" window for tray title display
371let currentPiece = 'prompt'; // Current piece/prompt shown in main window
372
373// Find docker binary path (needed for packaged app which may not have PATH set)
374function getDockerPath() {
375 if (process.platform === 'darwin') {
376 const dockerLocations = [
377 '/opt/homebrew/bin/docker', // Apple Silicon Homebrew
378 '/usr/local/bin/docker', // Intel Homebrew / Docker Desktop
379 '/Applications/Docker.app/Contents/Resources/bin/docker'
380 ];
381 for (const loc of dockerLocations) {
382 if (fs.existsSync(loc)) {
383 return loc;
384 }
385 }
386 }
387 return 'docker';
388}
389
390// Check if Docker is available
391async function checkDocker() {
392 const dockerPath = getDockerPath();
393 return new Promise((resolve) => {
394 const docker = spawn(dockerPath, ['info'], { stdio: 'pipe' });
395 docker.on('close', (code) => resolve(code === 0));
396 docker.on('error', () => resolve(false));
397 });
398}
399
400// Check if devcontainer is running
401async function checkDevcontainer() {
402 const dockerPath = getDockerPath();
403 return new Promise((resolve) => {
404 const docker = spawn(dockerPath, ['ps', '--filter', 'name=aesthetic', '--format', '{{.Names}}'], { stdio: 'pipe' });
405 let output = '';
406 docker.stdout.on('data', (data) => output += data.toString());
407 docker.on('close', () => resolve(output.includes('aesthetic')));
408 docker.on('error', () => resolve(false));
409 });
410}
411
412// Check if container exists (running or stopped)
413async function checkContainerExists() {
414 const dockerPath = getDockerPath();
415 return new Promise((resolve) => {
416 const docker = spawn(dockerPath, ['ps', '-a', '--filter', 'name=aesthetic', '--format', '{{.Names}}'], { stdio: 'pipe' });
417 let output = '';
418 docker.stdout.on('data', (data) => output += data.toString());
419 docker.on('close', () => resolve(output.includes('aesthetic')));
420 docker.on('error', () => resolve(false));
421 });
422}
423
424// Start an existing stopped container
425async function startExistingContainer() {
426 const dockerPath = getDockerPath();
427 return new Promise((resolve, reject) => {
428 console.log('Starting existing container...');
429 const docker = spawn(dockerPath, ['start', 'aesthetic'], { stdio: 'pipe' });
430 docker.on('close', (code) => {
431 if (code === 0) {
432 console.log('Container started successfully');
433 resolve(true);
434 } else {
435 reject(new Error(`docker start exited with code ${code}`));
436 }
437 });
438 docker.on('error', (err) => reject(err));
439 });
440}
441
442// Start the devcontainer
443async function startDevcontainer() {
444 return new Promise((resolve, reject) => {
445 // For packaged app, the workspace is one level up from the app bundle's Resources folder
446 // When running in dev: __dirname is ac-electron, workspace is ..
447 // When packaged: __dirname is inside app.asar, need to find actual workspace
448 let workspaceFolder;
449 if (__dirname.includes('app.asar')) {
450 // Packaged app - assume workspace is at a known location or use env var
451 workspaceFolder = process.env.AC_WORKSPACE || path.join(process.env.HOME, 'Desktop/code/aesthetic-computer');
452 } else {
453 workspaceFolder = path.resolve(__dirname, '..');
454 }
455 console.log('Starting devcontainer in:', workspaceFolder);
456
457 // Find devcontainer CLI
458 let devcontainerPath = 'devcontainer';
459 if (process.platform === 'darwin') {
460 const locations = [
461 '/opt/homebrew/bin/devcontainer',
462 '/usr/local/bin/devcontainer',
463 path.join(process.env.HOME, '.npm-global/bin/devcontainer'),
464 path.join(process.env.HOME, 'node_modules/.bin/devcontainer')
465 ];
466 for (const loc of locations) {
467 if (fs.existsSync(loc)) {
468 devcontainerPath = loc;
469 break;
470 }
471 }
472 }
473 console.log('Using devcontainer CLI:', devcontainerPath);
474
475 const devcontainer = spawn(devcontainerPath, ['up', '--workspace-folder', workspaceFolder], {
476 stdio: 'pipe',
477 env: {
478 ...process.env,
479 PATH: `${process.env.PATH}:/usr/local/bin:/opt/homebrew/bin`
480 }
481 });
482
483 let output = '';
484 devcontainer.stdout.on('data', (data) => {
485 output += data.toString();
486 console.log('[devcontainer]', data.toString());
487 });
488 devcontainer.stderr.on('data', (data) => {
489 console.error('[devcontainer error]', data.toString());
490 });
491
492 devcontainer.on('close', (code) => {
493 if (code === 0) {
494 resolve(true);
495 } else {
496 reject(new Error(`devcontainer exited with code ${code}`));
497 }
498 });
499 devcontainer.on('error', (err) => reject(err));
500 });
501}
502
503// Get the focused window's mode
504function getFocusedWindowMode() {
505 if (focusedWindowId && windows.has(focusedWindowId)) {
506 const mode = windows.get(focusedWindowId).mode;
507 if (mode === 'production' || mode === 'development') {
508 return mode;
509 }
510 }
511 return (app.isPackaged && !startInDevMode) ? 'production' : 'development';
512}
513
514// Get the focused window
515function getFocusedWindow() {
516 if (focusedWindowId && windows.has(focusedWindowId)) {
517 return windows.get(focusedWindowId).window;
518 }
519 return null;
520}
521
522function createMenu() {
523 const isMac = process.platform === 'darwin';
524
525 const template = [
526 // App menu (macOS only)
527 ...(isMac ? [{
528 label: 'Aesthetic Computer', // Explicit name for menu bar
529 submenu: [
530 {
531 label: 'About Aesthetic Computer',
532 click: () => {
533 dialog.showMessageBox({
534 type: 'info',
535 title: 'About Aesthetic Computer',
536 message: 'Aesthetic Computer',
537 detail: `Version ${app.getVersion()}`,
538 buttons: ['OK']
539 });
540 }
541 },
542 { type: 'separator' },
543 {
544 label: 'Check for Updates...',
545 click: () => {
546 if (autoUpdater) {
547 if (updateDownloaded) {
548 dialog.showMessageBox({
549 type: 'info',
550 title: 'Update Ready',
551 message: 'An update has already been downloaded.',
552 detail: 'It will be installed when you restart the app.',
553 buttons: ['Restart Now', 'Later'],
554 defaultId: 0
555 }).then(({ response }) => {
556 if (response === 0) {
557 autoUpdater.quitAndInstall(false, true);
558 }
559 });
560 } else {
561 autoUpdater.checkForUpdates().then(result => {
562 if (!result || !result.updateInfo || result.updateInfo.version === app.getVersion()) {
563 dialog.showMessageBox({
564 type: 'info',
565 title: 'No Updates',
566 message: 'You are running the latest version.',
567 buttons: ['OK']
568 });
569 }
570 }).catch(err => {
571 dialog.showMessageBox({
572 type: 'error',
573 title: 'Update Check Failed',
574 message: 'Could not check for updates.',
575 detail: err.message,
576 buttons: ['OK']
577 });
578 });
579 }
580 } else {
581 // Show why auto-updater isn't available
582 const reason = autoUpdaterError
583 ? `Failed to load: ${autoUpdaterError}`
584 : app.isPackaged
585 ? 'Unknown error'
586 : 'Only available in packaged builds';
587 dialog.showMessageBox({
588 type: 'info',
589 title: 'Updates',
590 message: 'Auto-updates are not available.',
591 detail: reason,
592 buttons: ['OK']
593 });
594 }
595 }
596 },
597 { type: 'separator' },
598 {
599 label: 'Preferences...',
600 accelerator: 'Cmd+,',
601 click: () => openPreferencesWindow()
602 },
603 { type: 'separator' },
604 { role: 'hide' },
605 { role: 'hideOthers' },
606 { role: 'unhide' },
607 { type: 'separator' },
608 { role: 'quit' }
609 ]
610 }] : []),
611 // File menu
612 {
613 label: 'File',
614 submenu: [
615 {
616 label: 'New AC Pane',
617 accelerator: 'CmdOrCtrl+N',
618 click: () => openAcPaneWindow()
619 },
620 { type: 'separator' },
621 isMac ? { role: 'close' } : { role: 'quit' }
622 ]
623 },
624 // Go menu (navigation)
625 {
626 label: 'Go',
627 submenu: [
628 {
629 label: 'Back',
630 accelerator: 'CmdOrCtrl+[',
631 click: () => getFocusedWindow()?.webContents.send('go-back')
632 },
633 {
634 label: 'Forward',
635 accelerator: 'CmdOrCtrl+]',
636 click: () => getFocusedWindow()?.webContents.send('go-forward')
637 },
638 { type: 'separator' },
639 {
640 label: 'Focus Location',
641 accelerator: 'CmdOrCtrl+L',
642 click: () => getFocusedWindow()?.webContents.send('focus-location')
643 },
644 { type: 'separator' },
645 {
646 label: 'Prompt',
647 click: () => navigateTo('prompt')
648 },
649 {
650 label: 'Wand',
651 click: () => navigateTo('wand')
652 },
653 {
654 label: 'Nopaint',
655 click: () => navigateTo('nopaint')
656 },
657 {
658 label: 'Whistlegraph',
659 click: () => navigateTo('whistlegraph')
660 }
661 ]
662 },
663 // View menu
664 {
665 label: 'View',
666 submenu: [
667 { role: 'reload' },
668 { role: 'forceReload' },
669 { type: 'separator' },
670 { role: 'resetZoom' },
671 { role: 'zoomIn' },
672 { role: 'zoomOut' },
673 { type: 'separator' },
674 { role: 'togglefullscreen' },
675 { type: 'separator' },
676 {
677 label: 'Developer Tools',
678 accelerator: isMac ? 'Cmd+Option+I' : 'Ctrl+Shift+I',
679 click: () => getFocusedWindow()?.webContents.send('toggle-devtools')
680 }
681 ]
682 },
683 // Window menu
684 {
685 label: 'Window',
686 submenu: [
687 { role: 'minimize' },
688 { role: 'zoom' },
689 ...(isMac ? [
690 { type: 'separator' },
691 { role: 'front' }
692 ] : [
693 { role: 'close' }
694 ])
695 ]
696 }
697 ];
698
699 const menu = Menu.buildFromTemplate(template);
700 Menu.setApplicationMenu(menu);
701}
702
703function navigateTo(piece) {
704 const mode = getFocusedWindowMode();
705 const baseUrl = mode === 'production' ? 'https://aesthetic.computer' : 'http://localhost:8888';
706 getFocusedWindow()?.webContents.send('navigate', `${baseUrl}/${piece}?nogap=true`);
707}
708
709// ========== System Tray ==========
710let tray = null;
711
712function createSystemTray() {
713 // Use template icon for proper macOS menu bar appearance
714 // Template images should be black with transparency, macOS will invert for dark mode
715 let iconPath;
716 if (process.platform === 'darwin') {
717 // In production, icons are in Resources folder; in dev, in build/icons
718 if (app.isPackaged) {
719 iconPath = path.join(process.resourcesPath, 'trayTemplate.png');
720 } else {
721 iconPath = path.join(__dirname, 'build', 'icons', 'trayTemplate.png');
722 }
723 } else {
724 // Windows/Linux - use packaged icon in production
725 if (app.isPackaged) {
726 iconPath = path.join(process.resourcesPath, 'tray-icon.png');
727 } else {
728 iconPath = path.join(__dirname, 'build', 'icons', '16x16.png');
729 }
730 }
731
732 console.log('[main] Loading tray icon from:', iconPath);
733 const icon = nativeImage.createFromPath(iconPath);
734
735 if (icon.isEmpty()) {
736 console.warn('[main] Tray icon is empty! Path:', iconPath);
737 return;
738 }
739
740 // Make it template on macOS for proper dark/light mode support
741 if (process.platform === 'darwin') {
742 icon.setTemplateImage(true);
743 }
744
745 // Store original icon for blink toggling
746 originalTrayIcon = icon;
747 updateTrayIcon = createUpdateIcon(icon);
748
749 tray = new Tray(icon);
750 tray.setToolTip('Aesthetic Computer');
751 console.log('[main] System tray created successfully');
752
753 // Build and set the context menu
754 rebuildTrayMenu();
755
756 // On macOS, single click shows menu, on Windows/Linux it toggles window
757 if (process.platform !== 'darwin') {
758 tray.on('click', () => {
759 const allWindows = BrowserWindow.getAllWindows();
760 if (allWindows.length > 0) {
761 const win = allWindows[0];
762 if (win.isVisible()) {
763 win.hide();
764 } else {
765 win.show();
766 }
767 } else {
768 openAcPaneWindow();
769 }
770 });
771 }
772
773 // Set initial tray title
774 updateTrayTitle();
775}
776
777// Rebuild the tray context menu (called when update becomes available)
778function rebuildTrayMenu() {
779 if (!tray) return;
780
781 const isMac = process.platform === 'darwin';
782 const menuItems = [];
783
784 // Update available section (if applicable)
785 if (updateAvailable) {
786 menuItems.push({
787 label: `🆕 Update Available: v${updateAvailable.version}`,
788 click: () => {
789 shell.openExternal(updateAvailable.url);
790 stopTrayBlink();
791 }
792 });
793 menuItems.push({
794 label: 'Download Update',
795 click: () => {
796 shell.openExternal(updateAvailable.url);
797 stopTrayBlink();
798 }
799 });
800 menuItems.push({
801 label: 'Dismiss',
802 click: () => {
803 updateAvailable = null;
804 stopTrayBlink();
805 rebuildTrayMenu();
806 }
807 });
808 menuItems.push({ type: 'separator' });
809 }
810
811 // File-like section
812 menuItems.push({
813 label: 'Show/Hide',
814 accelerator: isMac ? 'Cmd+H' : 'Ctrl+H',
815 click: () => {
816 const allWindows = BrowserWindow.getAllWindows();
817 if (allWindows.length > 0) {
818 const win = allWindows[0];
819 if (win.isVisible()) {
820 allWindows.forEach(w => w.hide());
821 } else {
822 allWindows.forEach(w => w.show());
823 }
824 } else {
825 openAcPaneWindow();
826 }
827 }
828 });
829
830 menuItems.push({ type: 'separator' });
831
832 menuItems.push({
833 label: 'New AC Pane',
834 accelerator: isMac ? 'Cmd+N' : 'Ctrl+N',
835 click: () => openAcPaneWindow()
836 });
837
838 // Quick DevTools access (especially for Windows)
839 menuItems.push({
840 label: 'Open DevTools',
841 accelerator: 'F12',
842 click: () => {
843 const win = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0];
844 if (win) {
845 // Try to open webview devtools if flip-view
846 win.webContents.send('open-devtools');
847 // Also open main window devtools
848 win.webContents.openDevTools({ mode: 'detach' });
849 }
850 }
851 });
852
853 menuItems.push({ type: 'separator' });
854
855 // FF1 Art Computer Bridge section
856 const ff1Devices = ff1Bridge.getDevices();
857 const ff1Running = ff1Bridge.isRunning();
858 menuItems.push({
859 label: '🖼️ FF1 Art Computer',
860 submenu: [
861 {
862 label: ff1Running ? '✓ Bridge Running (port 19999)' : '✗ Bridge Not Running',
863 enabled: false
864 },
865 {
866 label: `Devices Found: ${ff1Devices.length}`,
867 enabled: false
868 },
869 { type: 'separator' },
870 ...ff1Devices.map(device => ({
871 label: `${device.deviceId} (${device.ip})`,
872 submenu: [
873 {
874 label: 'Cast Current Piece',
875 click: () => {
876 const win = getFocusedWindow();
877 if (win) {
878 const mode = getFocusedWindowMode();
879 const baseUrl = mode === 'production' ? 'https://aesthetic.computer' : 'http://localhost:8888';
880 const piece = currentPiece || 'prompt';
881 const url = `${baseUrl}/${piece}`;
882 // Send cast request through the bridge
883 const http = require('http');
884 const payload = JSON.stringify({
885 deviceId: device.deviceId,
886 playlist: [{ url, duration: 0 }]
887 });
888 const req = http.request({
889 hostname: '127.0.0.1',
890 port: 19999,
891 path: '/cast',
892 method: 'POST',
893 headers: { 'Content-Type': 'application/json' }
894 });
895 req.write(payload);
896 req.end();
897 }
898 }
899 },
900 {
901 label: 'Open Device IP',
902 click: () => shell.openExternal(`http://${device.ip}:1111`)
903 }
904 ]
905 })),
906 ...(ff1Devices.length === 0 ? [{
907 label: 'No devices found',
908 enabled: false
909 }] : []),
910 { type: 'separator' },
911 {
912 label: 'Scan for Devices',
913 click: () => {
914 ff1Bridge.scanForDevices();
915 setTimeout(() => rebuildTrayMenu(), 3000);
916 }
917 },
918 {
919 label: 'Open KidLisp.com Editor',
920 click: () => shell.openExternal('https://kidlisp.com')
921 },
922 { type: 'separator' },
923 {
924 label: 'About FF1 Bridge',
925 click: () => {
926 dialog.showMessageBox({
927 type: 'info',
928 title: 'FF1 Art Computer Bridge',
929 message: 'FF1 Bridge',
930 detail: 'The FF1 Bridge allows kidlisp.com to send KidLisp pieces to FF1 Art Computers on your local network.\n\nThe bridge runs on port 19999 and automatically discovers FF1 devices via mDNS.\n\nVisit kidlisp.com to create and cast pieces!'
931 });
932 }
933 }
934 ]
935 });
936
937 menuItems.push({ type: 'separator' });
938
939 // Edit section
940 menuItems.push({
941 label: 'Edit',
942 submenu: [
943 { role: 'undo' },
944 { role: 'redo' },
945 { type: 'separator' },
946 { role: 'cut' },
947 { role: 'copy' },
948 { role: 'paste' },
949 { role: 'selectAll' }
950 ]
951 });
952
953 // View section
954 menuItems.push({
955 label: 'View',
956 submenu: [
957 { role: 'reload' },
958 { role: 'forceReload' },
959 { role: 'toggleDevTools' },
960 { type: 'separator' },
961 { role: 'resetZoom' },
962 { role: 'zoomIn' },
963 { role: 'zoomOut' },
964 { type: 'separator' },
965 { role: 'togglefullscreen' }
966 ]
967 });
968
969 // Navigate to pieces
970 menuItems.push({
971 label: 'Navigate',
972 submenu: [
973 {
974 label: 'Home (prompt)',
975 click: () => navigateToPiece('prompt')
976 },
977 {
978 label: 'Starfield',
979 click: () => navigateToPiece('starfield')
980 },
981 {
982 label: '1v1',
983 click: () => navigateToPiece('1v1')
984 },
985 { type: 'separator' },
986 {
987 label: 'Custom Piece...',
988 click: () => {
989 const win = getFocusedWindow();
990 if (win) {
991 win.webContents.executeJavaScript(`
992 const piece = prompt('Enter piece name:');
993 if (piece) window.location.href = window.location.origin + '/' + piece + '?nogap';
994 `);
995 }
996 }
997 }
998 ]
999 });
1000
1001 menuItems.push({ type: 'separator' });
1002
1003 // Settings
1004 menuItems.push({
1005 label: 'Preferences...',
1006 accelerator: isMac ? 'Cmd+,' : 'Ctrl+,',
1007 click: () => openPreferencesWindow()
1008 });
1009
1010 menuItems.push({ type: 'separator' });
1011
1012 // Help section
1013 menuItems.push({
1014 label: 'Help',
1015 submenu: [
1016 {
1017 label: 'Documentation',
1018 click: () => shell.openExternal('https://aesthetic.computer/docs')
1019 },
1020 {
1021 label: 'GitHub Repository',
1022 click: () => shell.openExternal('https://github.com/whistlegraph/aesthetic-computer')
1023 },
1024 {
1025 label: 'Check for Updates',
1026 click: () => {
1027 checkSiloForUpdates();
1028 if (!updateAvailable) {
1029 dialog.showMessageBox({
1030 type: 'info',
1031 title: 'No Updates',
1032 message: 'You\'re running the latest version!',
1033 detail: `Current version: ${app.getVersion()}`
1034 });
1035 }
1036 }
1037 },
1038 { type: 'separator' },
1039 {
1040 label: `About Aesthetic Computer`,
1041 click: () => {
1042 dialog.showMessageBox({
1043 type: 'info',
1044 title: 'About Aesthetic Computer',
1045 message: 'Aesthetic Computer',
1046 detail: `Version: ${app.getVersion()}\nElectron: ${process.versions.electron}\nChrome: ${process.versions.chrome}\nNode: ${process.versions.node}`
1047 });
1048 }
1049 }
1050 ]
1051 });
1052
1053 menuItems.push({ type: 'separator' });
1054
1055 menuItems.push({
1056 label: 'Quit',
1057 accelerator: isMac ? 'Cmd+Q' : 'Alt+F4',
1058 click: () => app.quit()
1059 });
1060
1061 const contextMenu = Menu.buildFromTemplate(menuItems);
1062 tray.setContextMenu(contextMenu);
1063}
1064
1065// ========== End System Tray ==========
1066
1067// Update the tray title text (shown next to icon in menu bar)
1068function updateTrayTitle(text) {
1069 if (!tray) return;
1070 if (process.platform === 'darwin') {
1071 if (text !== undefined) {
1072 // Explicit text provided
1073 tray.setTitle(text);
1074 } else if (preferences.showTrayTitle) {
1075 // Show current piece name if available, otherwise use preference text
1076 const displayText = currentPiece ? `/${currentPiece}` : (preferences.trayTitleText || '');
1077 tray.setTitle(displayText);
1078 } else {
1079 tray.setTitle('');
1080 }
1081 }
1082}
1083
1084// Preferences window
1085let preferencesWindow = null;
1086
1087function openPreferencesWindow() {
1088 if (preferencesWindow && !preferencesWindow.isDestroyed()) {
1089 preferencesWindow.focus();
1090 return;
1091 }
1092
1093 preferencesWindow = new BrowserWindow({
1094 width: 480,
1095 height: 400,
1096 resizable: false,
1097 minimizable: false,
1098 maximizable: false,
1099 title: 'Preferences',
1100 titleBarStyle: 'hiddenInset',
1101 backgroundColor: '#1a1a2e',
1102 webPreferences: {
1103 nodeIntegration: true,
1104 contextIsolation: false
1105 }
1106 });
1107
1108 preferencesWindow.loadFile(getAppPath('renderer/preferences.html'));
1109
1110 preferencesWindow.on('closed', () => {
1111 preferencesWindow = null;
1112 });
1113}
1114
1115// Get the focused window or first available
1116function getFocusedWindow() {
1117 let win = BrowserWindow.getFocusedWindow();
1118 if (!win) {
1119 const allWindows = BrowserWindow.getAllWindows();
1120 win = allWindows.find(w => w.isVisible() && !w.isDestroyed());
1121 }
1122 return win;
1123}
1124
1125// Navigate a window to a specific piece
1126function navigateToPiece(piece) {
1127 const win = getFocusedWindow();
1128 const mode = getFocusedWindowMode();
1129 const baseUrl = mode === 'production' ? 'https://aesthetic.computer' : 'http://localhost:8888';
1130 const url = `${baseUrl}/${piece}?nogap=true`;
1131 if (win) {
1132 win.webContents.send('navigate', url);
1133 } else {
1134 // No window open, create one and navigate
1135 openAcPaneWindow().then(result => {
1136 if (!result?.window) return;
1137 result.window.webContents.once('did-finish-load', () => {
1138 result.window.webContents.send('navigate', url);
1139 });
1140 });
1141 }
1142}
1143
1144// Open a new AC Pane window
1145async function openAcPaneWindow(options = {}) {
1146 return openAcPaneWindowInternal(options);
1147}
1148
1149// Open KidLisp window (kidlisp.com)
1150function openKidLispWindow() {
1151 const { width, height } = screen.getPrimaryDisplay().workAreaSize;
1152 const winWidth = Math.min(1200, width * 0.8);
1153 const winHeight = Math.min(800, height * 0.8);
1154
1155 const win = new BrowserWindow({
1156 width: winWidth,
1157 height: winHeight,
1158 title: 'KidLisp',
1159 backgroundColor: '#0a0a12',
1160 webPreferences: {
1161 nodeIntegration: false,
1162 contextIsolation: true,
1163 },
1164 titleBarStyle: 'hiddenInset',
1165 trafficLightPosition: { x: 15, y: 12 },
1166 });
1167
1168 win.loadURL('https://kidlisp.com');
1169
1170 // Track it
1171 const windowId = windowIdCounter++;
1172 windows.set(windowId, { window: win, mode: 'kidlisp' });
1173
1174 win.on('closed', () => {
1175 windows.delete(windowId);
1176 });
1177
1178 return win;
1179}
1180
1181// ========== CSS 3D Flip View ==========
1182let ptyProcessFor3D = null;
1183let lastKnownCols = 120;
1184let lastKnownRows = 40;
1185
1186// Calculate offset position for new windows to avoid overlap
1187function getOffsetWindowPosition(sourceWindow, index = 0) {
1188 const offset = 40; // Pixels to offset each new window
1189 const { width, height } = screen.getPrimaryDisplay().workAreaSize;
1190
1191 let baseX, baseY;
1192
1193 if (sourceWindow && !sourceWindow.isDestroyed()) {
1194 const [srcX, srcY] = sourceWindow.getPosition();
1195 const [srcW, srcH] = sourceWindow.getSize();
1196 // Position to the right of source window
1197 baseX = srcX + srcW + 20;
1198 baseY = srcY;
1199 // If it would go off screen, wrap to upper-left with offset
1200 if (baseX + 680 > width) {
1201 baseX = 50 + (index * offset);
1202 baseY = 50 + (index * offset);
1203 }
1204 } else {
1205 // No source window, use cascading from top-left
1206 baseX = 100 + (index * offset);
1207 baseY = 100 + (index * offset);
1208 }
1209
1210 // Apply index offset for multiple windows
1211 const x = Math.min(baseX + (index * offset), width - 680);
1212 const y = Math.min(baseY + (index * offset), height - 520);
1213
1214 return { x: Math.max(0, x), y: Math.max(0, y) };
1215}
1216
1217async function openAcPaneWindowInternal(options = {}) {
1218 const { sourceWindow = null, index = 0 } = options;
1219
1220 // Start with a wide window - extra height for mode tags at bottom
1221 const winWidth = 680;
1222 const winHeight = 520;
1223
1224 // Calculate position to avoid overlap
1225 const { x, y } = getOffsetWindowPosition(sourceWindow, index);
1226
1227 const winOpts = {
1228 width: winWidth,
1229 height: winHeight,
1230 x,
1231 y,
1232 minWidth: 320,
1233 minHeight: 240,
1234 title: 'AC Pane',
1235 frame: false,
1236 transparent: !isPaperWM,
1237 hasShadow: isPaperWM,
1238 alwaysOnTop: !isPaperWM,
1239 backgroundColor: isPaperWM ? '#000000' : '#00000000',
1240 webPreferences: {
1241 nodeIntegration: true,
1242 contextIsolation: false,
1243 webviewTag: true,
1244 backgroundThrottling: false,
1245 },
1246 };
1247 // On PaperWM, use 'normal' type so the WM tiles it instead of floating
1248 if (isPaperWM) winOpts.type = 'normal';
1249 const win = new BrowserWindow(winOpts);
1250
1251 win.loadFile(getAppPath('renderer/flip-view.html'), isPaperWM ? { query: { wm: 'paper' } } : undefined);
1252
1253 // Track it
1254 const windowId = windowIdCounter++;
1255 windows.set(windowId, { window: win, mode: 'ac-pane' });
1256
1257 // Track focus
1258 win.on('focus', () => {
1259 focusedWindowId = windowId;
1260 });
1261
1262 // Register Cmd+F to flip ALL windows, Cmd+B to flip focused window only
1263 const flipAllShortcut = process.platform === 'darwin' ? 'CommandOrControl+F' : 'Ctrl+F';
1264 const flipOneShortcut = process.platform === 'darwin' ? 'CommandOrControl+B' : 'Ctrl+B';
1265 globalShortcut.register(flipAllShortcut, () => {
1266 for (const entry of windows.values()) {
1267 const w = entry.window || entry;
1268 if (w && !w.isDestroyed()) {
1269 w.webContents.send('toggle-flip');
1270 }
1271 }
1272 });
1273 globalShortcut.register(flipOneShortcut, () => {
1274 if (focusedWindowId && windows.has(focusedWindowId)) {
1275 const w = windows.get(focusedWindowId).window;
1276 if (w && !w.isDestroyed()) {
1277 w.webContents.send('toggle-flip');
1278 }
1279 }
1280 });
1281
1282 win.on('closed', () => {
1283 windows.delete(windowId);
1284 if (focusedWindowId === windowId) {
1285 focusedWindowId = null;
1286 }
1287 // Only unregister global shortcuts when the last window closes
1288 if (windows.size === 0) {
1289 globalShortcut.unregister(flipAllShortcut);
1290 globalShortcut.unregister(flipOneShortcut);
1291 globalShortcut.unregister('CommandOrControl+Plus');
1292 globalShortcut.unregister('CommandOrControl+=');
1293 globalShortcut.unregister('CommandOrControl+-');
1294 globalShortcut.unregister('CommandOrControl+0');
1295 }
1296 if (ptyProcessFor3D) {
1297 ptyProcessFor3D.kill();
1298 ptyProcessFor3D = null;
1299 }
1300 });
1301
1302 // PTY for terminal side
1303 ipcMain.on('connect-flip-pty', (event) => {
1304 if (!pty) {
1305 event.sender.send('flip-pty-data', '\r\n\x1b[31mError: node-pty not available\x1b[0m\r\n');
1306 return;
1307 }
1308
1309 const dockerPath = getDockerPath();
1310
1311 // Start container and emacs daemon if needed
1312 ensureContainerForFlip(dockerPath, event.sender);
1313 });
1314
1315 ipcMain.on('flip-pty-input', (event, data) => {
1316 // Check if we're waiting for a prompt response
1317 if (pendingContainerPrompt) {
1318 // Collect input until Enter is pressed
1319 if (data === '\r' || data === '\n') {
1320 handleContainerPromptResponse(pendingInputBuffer || '');
1321 pendingInputBuffer = '';
1322 } else if (data === '\x7f' || data === '\b') {
1323 // Backspace
1324 if (pendingInputBuffer && pendingInputBuffer.length > 0) {
1325 pendingInputBuffer = pendingInputBuffer.slice(0, -1);
1326 event.sender.send('flip-pty-data', '\b \b');
1327 }
1328 } else if (data.length === 1 && data.charCodeAt(0) >= 32) {
1329 // Printable character
1330 pendingInputBuffer = (pendingInputBuffer || '') + data;
1331 event.sender.send('flip-pty-data', data);
1332 }
1333 return;
1334 }
1335
1336 if (ptyProcessFor3D) {
1337 ptyProcessFor3D.write(data);
1338 }
1339 });
1340
1341 // Update terminal dimensions from renderer
1342 ipcMain.on('flip-pty-resize', (event, cols, rows) => {
1343 lastKnownCols = cols;
1344 lastKnownRows = rows;
1345 if (ptyProcessFor3D) {
1346 try {
1347 ptyProcessFor3D.resize(cols, rows);
1348 } catch (err) {
1349 console.warn('[main] Flip PTY resize failed:', err.message);
1350 }
1351 }
1352 });
1353
1354 // Allow renderer to query current dimensions
1355 ipcMain.handle('get-terminal-size', () => ({ cols: lastKnownCols, rows: lastKnownRows }));
1356
1357 // Register zoom shortcuts for flip view
1358 globalShortcut.register('CommandOrControl+Plus', () => {
1359 if (win && !win.isDestroyed()) {
1360 win.webContents.send('zoom-in');
1361 }
1362 });
1363 globalShortcut.register('CommandOrControl+=', () => {
1364 if (win && !win.isDestroyed()) {
1365 win.webContents.send('zoom-in');
1366 }
1367 });
1368 globalShortcut.register('CommandOrControl+-', () => {
1369 if (win && !win.isDestroyed()) {
1370 win.webContents.send('zoom-out');
1371 }
1372 });
1373 globalShortcut.register('CommandOrControl+0', () => {
1374 if (win && !win.isDestroyed()) {
1375 win.webContents.send('zoom-reset');
1376 }
1377 });
1378
1379 // Custom window resize handler
1380 ipcMain.on('resize-window', (event, bounds) => {
1381 const senderWindow = BrowserWindow.fromWebContents(event.sender);
1382 if (senderWindow) {
1383 senderWindow.setBounds({
1384 x: Math.round(bounds.x),
1385 y: Math.round(bounds.y),
1386 width: Math.round(bounds.width),
1387 height: Math.round(bounds.height)
1388 });
1389 }
1390 });
1391
1392 return { window: win, windowId };
1393}
1394
1395// State for interactive prompt
1396let pendingContainerPrompt = null;
1397let pendingInputBuffer = '';
1398
1399async function ensureContainerForFlip(dockerPath, webContents) {
1400 const { spawn } = require('child_process');
1401
1402 // First check if Docker is available at all
1403 const dockerAvailable = await new Promise((resolve) => {
1404 const check = spawn(dockerPath, ['info'], { stdio: 'pipe' });
1405 check.on('close', (code) => resolve(code === 0));
1406 check.on('error', () => resolve(false));
1407 });
1408
1409 if (!dockerAvailable) {
1410 webContents.send('flip-pty-data', '\x1b[33mDocker not available. Starting local shell...\x1b[0m\r\n\r\n');
1411 startLocalShellForFlip(webContents);
1412 return;
1413 }
1414
1415 // Check if container exists
1416 const containerExists = await new Promise((resolve) => {
1417 const check = spawn(dockerPath, ['ps', '-a', '--filter', 'name=aesthetic', '--format', '{{.Names}}'], { stdio: 'pipe' });
1418 let output = '';
1419 check.stdout.on('data', (data) => output += data.toString());
1420 check.on('close', () => resolve(output.includes('aesthetic')));
1421 check.on('error', () => resolve(false));
1422 });
1423
1424 if (!containerExists) {
1425 webContents.send('flip-pty-data', '\x1b[33mNo AC container found. Starting local shell...\x1b[0m\r\n');
1426 webContents.send('flip-pty-data', '\x1b[90m(Run "npm run aesthetic" in the repo to start the devcontainer)\x1b[0m\r\n\r\n');
1427 startLocalShellForFlip(webContents);
1428 return;
1429 }
1430
1431 // Ask user if they want to start the devcontainer
1432 webContents.send('flip-pty-data', '\x1b[36mStart devcontainer with emacs? [y/n]: \x1b[0m');
1433
1434 // Wait for user input
1435 pendingContainerPrompt = { dockerPath, webContents };
1436}
1437
1438// Handle user response to container prompt
1439function handleContainerPromptResponse(response) {
1440 if (!pendingContainerPrompt) return false;
1441
1442 const { dockerPath, webContents } = pendingContainerPrompt;
1443 pendingContainerPrompt = null;
1444
1445 const answer = response.trim().toLowerCase();
1446
1447 if (answer === 'y' || answer === 'yes') {
1448 webContents.send('flip-pty-data', 'y\r\n');
1449 startContainerAndEmacs(dockerPath, webContents);
1450 return true;
1451 } else {
1452 webContents.send('flip-pty-data', answer ? `${answer}\r\n` : 'n\r\n');
1453 webContents.send('flip-pty-data', '\x1b[33mStarting local shell instead...\x1b[0m\r\n\r\n');
1454 startLocalShellForFlip(webContents);
1455 return true;
1456 }
1457}
1458
1459// Emacs log file path (inside container, but we'll also keep a local copy)
1460const EMACS_LOG_PATH = '/tmp/ac-emacs-daemon.log';
1461const LOCAL_EMACS_LOG_PATH = path.join(app.getPath('userData'), 'emacs-daemon.log');
1462
1463// Write to local emacs log
1464function writeEmacsLog(content, append = true) {
1465 try {
1466 if (append) {
1467 fs.appendFileSync(LOCAL_EMACS_LOG_PATH, content);
1468 } else {
1469 fs.writeFileSync(LOCAL_EMACS_LOG_PATH, content);
1470 }
1471 } catch (e) {
1472 console.warn('[main] Failed to write emacs log:', e.message);
1473 }
1474}
1475
1476// Read local emacs log
1477function readEmacsLog() {
1478 try {
1479 if (fs.existsSync(LOCAL_EMACS_LOG_PATH)) {
1480 return fs.readFileSync(LOCAL_EMACS_LOG_PATH, 'utf-8');
1481 }
1482 } catch (e) {
1483 console.warn('[main] Failed to read emacs log:', e.message);
1484 }
1485 return null;
1486}
1487
1488async function startContainerAndEmacs(dockerPath, webContents) {
1489 const { spawn } = require('child_process');
1490
1491 // Start fresh log for this session
1492 const timestamp = new Date().toISOString();
1493 writeEmacsLog(`\n\n========== EMACS SESSION START: ${timestamp} ==========\n`, false);
1494
1495 // Restart container to ensure clean state
1496 webContents.send('flip-pty-data', '\x1b[33mRestarting container...\x1b[0m\r\n');
1497 writeEmacsLog(`[${timestamp}] Restarting container...\n`);
1498 await new Promise((resolve) => {
1499 const restart = spawn(dockerPath, ['restart', 'aesthetic'], { stdio: 'pipe' });
1500 restart.on('close', () => resolve());
1501 restart.on('error', () => resolve());
1502 });
1503
1504 // Wait for container to be ready
1505 await new Promise(r => setTimeout(r, 2000));
1506 writeEmacsLog(`Container restarted, waiting for ready state...\n`);
1507
1508 // Kill any existing emacs daemon first
1509 writeEmacsLog(`Killing any existing emacs daemon...\n`);
1510 await new Promise((resolve) => {
1511 const kill = spawn(dockerPath, ['exec', 'aesthetic', 'pkill', '-9', '-f', 'emacs.*daemon'], { stdio: 'pipe' });
1512 kill.on('close', () => resolve());
1513 kill.on('error', () => resolve());
1514 });
1515
1516 await new Promise(r => setTimeout(r, 500));
1517
1518 // Start emacs daemon with AC config
1519 webContents.send('flip-pty-data', '\x1b[33mStarting emacs daemon with AC config...\x1b[0m\r\n');
1520 const configPath = '/home/me/aesthetic-computer/dotfiles/dot_config/emacs.el';
1521 writeEmacsLog(`Starting emacs daemon with config: ${configPath}\n`);
1522 writeEmacsLog(`Command: emacs -q --daemon -l ${configPath}\n`);
1523
1524 // Start daemon and capture output
1525 const emacsStartResult = await new Promise((resolve) => {
1526 let output = '';
1527 const emacs = spawn(dockerPath, ['exec', 'aesthetic', 'emacs', '-q', '--daemon', '-l', configPath], { stdio: 'pipe' });
1528 emacs.stdout.on('data', (d) => {
1529 output += d.toString();
1530 writeEmacsLog(`[stdout] ${d.toString()}`);
1531 });
1532 emacs.stderr.on('data', (d) => {
1533 output += d.toString();
1534 writeEmacsLog(`[stderr] ${d.toString()}`);
1535 });
1536 emacs.on('close', (code) => resolve({ code, output }));
1537 emacs.on('error', (err) => resolve({ code: -1, output: err.message }));
1538 });
1539
1540 writeEmacsLog(`\nEmacs daemon exit code: ${emacsStartResult.code}\n`);
1541 console.log('[main] Emacs daemon started, code:', emacsStartResult.code);
1542 if (emacsStartResult.output) {
1543 console.log('[main] Emacs output:', emacsStartResult.output.substring(0, 500));
1544 }
1545
1546 // Wait for emacs daemon to be responsive (poll with emacsclient -e t)
1547 webContents.send('flip-pty-data', '\x1b[33mWaiting for emacs daemon...\x1b[0m\r\n');
1548 writeEmacsLog(`Waiting for emacs daemon to be responsive...\n`);
1549 let emacsReady = false;
1550 for (let i = 0; i < 30; i++) { // Max 30 seconds
1551 const ready = await new Promise((resolve) => {
1552 const check = spawn(dockerPath, ['exec', 'aesthetic', 'emacsclient', '-e', 't'], { stdio: 'pipe' });
1553 check.on('close', (code) => resolve(code === 0));
1554 check.on('error', () => resolve(false));
1555 });
1556 if (ready) {
1557 emacsReady = true;
1558 break;
1559 }
1560 await new Promise(r => setTimeout(r, 1000));
1561 webContents.send('flip-pty-data', '.');
1562 writeEmacsLog(`.`);
1563 }
1564
1565 if (!emacsReady) {
1566 writeEmacsLog(`\nFAILED: Emacs daemon did not become responsive after 30 seconds\n`);
1567 webContents.send('flip-pty-data', '\r\n\x1b[31mEmacs daemon failed to start.\x1b[0m\r\n');
1568 // Show the log to the user
1569 const log = readEmacsLog();
1570 if (log) {
1571 webContents.send('flip-pty-data', '\r\n\x1b[33m--- Emacs Startup Log ---\x1b[0m\r\n');
1572 webContents.send('flip-pty-data', log.split('\n').slice(-30).join('\r\n') + '\r\n');
1573 webContents.send('flip-pty-data', '\x1b[33m--- End Log ---\x1b[0m\r\n\r\n');
1574 }
1575 webContents.send('flip-pty-data', '\x1b[90mFalling back to fish shell...\x1b[0m\r\n');
1576 startLocalShellForFlip(webContents);
1577 return;
1578 }
1579
1580 writeEmacsLog(`\nEmacs daemon ready!\n`);
1581
1582 webContents.send('flip-pty-data', ' ready!\r\n');
1583
1584 // Request current terminal size from renderer before spawning PTY
1585 webContents.send('flip-pty-data', '\x1b[33mConnecting to emacs...\x1b[0m\r\n');
1586 writeEmacsLog(`Connecting emacsclient...\n`);
1587
1588 // Get the last known size (will be updated by renderer)
1589 const { cols, rows } = await new Promise((resolve) => {
1590 // Request size update from renderer
1591 webContents.send('request-terminal-size');
1592 // Wait a moment for any pending resize to arrive
1593 setTimeout(() => {
1594 resolve({ cols: lastKnownCols || 120, rows: lastKnownRows || 40 });
1595 }, 100);
1596 });
1597
1598 console.log('[main] Starting PTY with size:', cols, 'x', rows);
1599 writeEmacsLog(`PTY size: ${cols}x${rows}\n`);
1600 writeEmacsLog(`Command: emacsclient -nw -c --eval "(aesthetic-backend 'artery)"\n`);
1601
1602 // Connect PTY - run aesthetic-backend to set up tabs
1603 ptyProcessFor3D = pty.spawn(dockerPath, [
1604 'exec', '-it', '-e', 'LANG=en_US.UTF-8', '-e', 'LC_ALL=en_US.UTF-8',
1605 '-e', `COLUMNS=${cols}`, '-e', `LINES=${rows}`,
1606 'aesthetic',
1607 'emacsclient', '-nw', '-c', '--eval', "(aesthetic-backend 'artery)"
1608 ], {
1609 name: 'xterm-256color',
1610 cols: cols,
1611 rows: rows,
1612 cwd: process.env.HOME,
1613 env: { ...process.env, TERM: 'xterm-256color', LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8' }
1614 });
1615
1616 let firstDataReceived = false;
1617 let sessionStartTime = Date.now();
1618
1619 ptyProcessFor3D.onData((data) => {
1620 if (!webContents.isDestroyed()) {
1621 webContents.send('flip-pty-data', data);
1622
1623 // After first data, send a resize to ensure emacs picks it up
1624 if (!firstDataReceived) {
1625 firstDataReceived = true;
1626 writeEmacsLog(`First data received, emacsclient connected.\n`);
1627 // Give emacs a moment to initialize, then force resize
1628 setTimeout(() => {
1629 if (ptyProcessFor3D) {
1630 console.log('[main] Sending post-connect resize:', lastKnownCols, 'x', lastKnownRows);
1631 try {
1632 ptyProcessFor3D.resize(lastKnownCols, lastKnownRows);
1633 } catch (e) {
1634 console.warn('[main] Post-connect resize failed:', e.message);
1635 }
1636 // Send another resize after a longer delay to catch any late initialization
1637 setTimeout(() => {
1638 if (ptyProcessFor3D) {
1639 try {
1640 ptyProcessFor3D.resize(lastKnownCols, lastKnownRows);
1641 } catch (e) {}
1642 }
1643 }, 1000);
1644 }
1645 }, 500);
1646 }
1647 }
1648 });
1649
1650 ptyProcessFor3D.onExit(({ exitCode, signal }) => {
1651 const duration = Math.round((Date.now() - sessionStartTime) / 1000);
1652 const exitInfo = `Session ended after ${duration}s - exit code: ${exitCode}, signal: ${signal}`;
1653 writeEmacsLog(`\n${exitInfo}\n`);
1654 console.log('[main]', exitInfo);
1655
1656 if (!webContents.isDestroyed()) {
1657 webContents.send('flip-pty-data', '\r\n\x1b[33m[Session ended]\x1b[0m\r\n');
1658
1659 // If session was very short (< 5 seconds) or crashed, show the log
1660 if (duration < 5 || exitCode !== 0) {
1661 const log = readEmacsLog();
1662 if (log) {
1663 webContents.send('flip-pty-data', '\r\n\x1b[33m--- Session Log (last 40 lines) ---\x1b[0m\r\n');
1664 const lines = log.split('\n').slice(-40);
1665 webContents.send('flip-pty-data', lines.join('\r\n') + '\r\n');
1666 webContents.send('flip-pty-data', '\x1b[33m--- End Log ---\x1b[0m\r\n');
1667 webContents.send('flip-pty-data', `\x1b[90mFull log: ${LOCAL_EMACS_LOG_PATH}\x1b[0m\r\n`);
1668 }
1669 }
1670 }
1671 });
1672}
1673
1674// Start a local shell when docker isn't available
1675function startLocalShellForFlip(webContents) {
1676 // Find the best available shell
1677 const shellOptions = process.platform === 'darwin'
1678 ? ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/bin/zsh', '/bin/bash']
1679 : ['/usr/bin/fish', '/bin/bash', '/bin/sh'];
1680
1681 let shellPath = '/bin/sh';
1682 for (const s of shellOptions) {
1683 if (fs.existsSync(s)) {
1684 shellPath = s;
1685 break;
1686 }
1687 }
1688
1689 console.log('[main] Starting local shell:', shellPath);
1690
1691 ptyProcessFor3D = pty.spawn(shellPath, [], {
1692 name: 'xterm-256color',
1693 cols: 120,
1694 rows: 40,
1695 cwd: process.env.HOME,
1696 env: { ...process.env, TERM: 'xterm-256color', LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8' },
1697 env: { ...process.env, TERM: 'xterm-256color' }
1698 });
1699
1700 ptyProcessFor3D.onData((data) => {
1701 if (!webContents.isDestroyed()) {
1702 webContents.send('flip-pty-data', data);
1703 }
1704 });
1705
1706 ptyProcessFor3D.onExit(() => {
1707 if (!webContents.isDestroyed()) {
1708 webContents.send('flip-pty-data', '\r\n\x1b[33m[Session ended]\x1b[0m\r\n');
1709 }
1710 });
1711}
1712
1713// Get the repo path (where this Electron app lives, which is inside aesthetic-computer repo)
1714function getRepoPath() {
1715 // In development, __dirname is ac-electron/, in packaged app it's inside Resources/app.asar
1716 // The repo is the parent of ac-electron
1717 const devRepoPath = path.resolve(__dirname, '..');
1718 if (fs.existsSync(path.join(devRepoPath, '.git'))) {
1719 return devRepoPath;
1720 }
1721 // For packaged app, try common locations
1722 const homeRepoPath = path.join(process.env.HOME, 'Desktop', 'code', 'aesthetic-computer');
1723 if (fs.existsSync(path.join(homeRepoPath, '.git'))) {
1724 return homeRepoPath;
1725 }
1726 return null;
1727}
1728
1729// IPC Handlers for preferences
1730ipcMain.handle('get-preferences', () => preferences);
1731
1732ipcMain.handle('set-preferences', (event, newPrefs) => {
1733 preferences = { ...preferences, ...newPrefs };
1734 savePreferences();
1735 updateTrayTitle();
1736
1737 // Handle launch at login
1738 if (process.platform === 'darwin' || process.platform === 'win32') {
1739 app.setLoginItemSettings({
1740 openAtLogin: preferences.launchAtLogin,
1741 openAsHidden: true
1742 });
1743 }
1744
1745 return preferences;
1746});
1747
1748ipcMain.handle('set-tray-title', (event, text) => {
1749 updateTrayTitle(text);
1750});
1751
1752// Update the current piece name (called when webview navigates)
1753ipcMain.handle('set-current-piece', (event, pieceName) => {
1754 currentPiece = pieceName || 'prompt';
1755 console.log('[main] Current piece updated:', currentPiece);
1756 updateTrayTitle();
1757 return currentPiece;
1758});
1759
1760// Mark a window as the "main" window
1761ipcMain.handle('set-main-window', (event) => {
1762 const win = BrowserWindow.fromWebContents(event.sender);
1763 for (const [id, data] of windows) {
1764 if (data.window === win) {
1765 mainWindowId = id;
1766 console.log('[main] Main window set to:', id);
1767 return true;
1768 }
1769 }
1770 return false;
1771});
1772
1773// IPC Handlers for welcome screen
1774ipcMain.handle('get-repo-path', () => {
1775 const repoPath = getRepoPath();
1776 if (repoPath) {
1777 // Return just the base name for cleaner display
1778 return { path: repoPath, name: path.basename(repoPath) };
1779 }
1780 return null;
1781});
1782
1783ipcMain.handle('get-git-user', async () => {
1784 try {
1785 const name = execSync('git config user.name', { encoding: 'utf8', timeout: 3000 }).trim();
1786 const email = execSync('git config user.email', { encoding: 'utf8', timeout: 3000 }).trim();
1787 return { name, email };
1788 } catch (e) {
1789 return null;
1790 }
1791});
1792
1793ipcMain.handle('start-flip-devcontainer', async (event) => {
1794 const dockerPath = getDockerPath();
1795 startContainerAndEmacs(dockerPath, event.sender);
1796 return { success: true };
1797});
1798
1799ipcMain.handle('start-flip-local-shell', async (event) => {
1800 startLocalShellForFlip(event.sender);
1801 return { success: true };
1802});
1803
1804// IPC Handlers
1805ipcMain.handle('get-mode', (event) => {
1806 // Find which window sent this
1807 const win = BrowserWindow.fromWebContents(event.sender);
1808 for (const [id, data] of windows) {
1809 if (data.window === win) return data.mode;
1810 }
1811 return 'production';
1812});
1813
1814ipcMain.handle('get-urls', () => URLS);
1815
1816// Get app info for desktop.mjs piece
1817ipcMain.handle('get-app-info', () => {
1818 return {
1819 version: app.getVersion(),
1820 electron: process.versions.electron,
1821 chrome: process.versions.chrome,
1822 platform: process.platform,
1823 arch: process.arch,
1824 isPackaged: app.isPackaged,
1825 updateAvailable: updateAvailable,
1826 latestVersion: updateAvailable?.version || null,
1827 };
1828});
1829
1830// CDP (Chrome DevTools Protocol) info
1831ipcMain.handle('get-cdp-info', () => {
1832 const args = process.argv.join(' ');
1833 const cdpMatch = args.match(/--remote-debugging-port=(\d+)/);
1834 const inspectMatch = args.match(/--inspect=(\d+)/);
1835 return {
1836 enabled: !!cdpMatch,
1837 port: cdpMatch ? cdpMatch[1] : null,
1838 inspectPort: inspectMatch ? inspectMatch[1] : null
1839 };
1840});
1841
1842ipcMain.handle('check-docker', async () => {
1843 console.log('[main] check-docker called');
1844 const result = await checkDocker();
1845 console.log('[main] check-docker result:', result);
1846 return result;
1847});
1848
1849ipcMain.handle('check-container', async () => {
1850 console.log('[main] check-container called');
1851 const result = await checkDevcontainer();
1852 console.log('[main] check-container result:', result);
1853 return result;
1854});
1855
1856ipcMain.handle('check-container-exists', async () => {
1857 console.log('[main] check-container-exists called');
1858 const result = await checkContainerExists();
1859 console.log('[main] check-container-exists result:', result);
1860 return result;
1861});
1862
1863ipcMain.handle('start-existing-container', async () => {
1864 console.log('[main] start-existing-container called');
1865 try {
1866 await startExistingContainer();
1867 console.log('[main] start-existing-container success');
1868 return { success: true };
1869 } catch (err) {
1870 console.error('[main] start-existing-container error:', err);
1871 return { success: false, error: err.message };
1872 }
1873});
1874
1875ipcMain.handle('start-container', async () => {
1876 console.log('[main] start-container called');
1877 try {
1878 await startDevcontainer();
1879 console.log('[main] start-container success');
1880 return { success: true };
1881 } catch (err) {
1882 console.error('[main] start-container error:', err);
1883 return { success: false, error: err.message };
1884 }
1885});
1886
1887ipcMain.handle('stop-container', async () => {
1888 console.log('[main] stop-container called');
1889 try {
1890 const dockerPath = getDockerPath();
1891 await new Promise((resolve, reject) => {
1892 const docker = spawn(dockerPath, ['stop', 'aesthetic'], { stdio: 'pipe' });
1893 docker.on('close', (code) => {
1894 if (code === 0) {
1895 console.log('[main] Container stopped successfully');
1896 resolve(true);
1897 } else {
1898 reject(new Error(`docker stop exited with code ${code}`));
1899 }
1900 });
1901 docker.on('error', (err) => reject(err));
1902 });
1903 return { success: true };
1904 } catch (err) {
1905 console.error('[main] stop-container error:', err);
1906 return { success: false, error: err.message };
1907 }
1908});
1909
1910ipcMain.handle('stop-container-aggressive', async () => {
1911 console.log('[main] stop-container-aggressive called');
1912 try {
1913 const dockerPath = getDockerPath();
1914
1915 // Kill all processes in the container first
1916 console.log('[main] Killing all processes in container...');
1917 await new Promise((resolve) => {
1918 const kill = spawn(dockerPath, ['exec', 'aesthetic', 'pkill', '-9', '-f', '.*'], { stdio: 'pipe' });
1919 kill.on('close', () => resolve());
1920 kill.on('error', () => resolve());
1921 });
1922
1923 // Give it a moment
1924 await new Promise(r => setTimeout(r, 500));
1925
1926 // Force stop with timeout
1927 console.log('[main] Force stopping container...');
1928 await new Promise((resolve, reject) => {
1929 const docker = spawn(dockerPath, ['stop', '-t', '2', 'aesthetic'], { stdio: 'pipe' });
1930 docker.on('close', (code) => {
1931 console.log('[main] Container force stopped with code:', code);
1932 resolve(true);
1933 });
1934 docker.on('error', (err) => {
1935 console.error('[main] Error force stopping:', err);
1936 resolve(true); // Resolve anyway
1937 });
1938 });
1939
1940 // Kill it if still running
1941 console.log('[main] Ensuring container is killed...');
1942 await new Promise((resolve) => {
1943 const kill = spawn(dockerPath, ['kill', 'aesthetic'], { stdio: 'pipe' });
1944 kill.on('close', () => resolve());
1945 kill.on('error', () => resolve());
1946 });
1947
1948 return { success: true };
1949 } catch (err) {
1950 console.error('[main] stop-container-aggressive error:', err);
1951 return { success: false, error: err.message };
1952 }
1953});
1954
1955// =============================================================================
1956// USB Flash Handlers (Linux only)
1957// =============================================================================
1958
1959ipcMain.handle('usb:list-devices', async () => {
1960 console.log('[main] usb:list-devices called');
1961 if (process.platform !== 'linux') {
1962 return { success: false, error: 'USB flashing is only supported on Linux' };
1963 }
1964
1965 try {
1966 const output = await new Promise((resolve, reject) => {
1967 const proc = spawn('lsblk', [
1968 '-J', '-d', '-o', 'NAME,SIZE,MODEL,TRAN,RM,HOTPLUG,TYPE'
1969 ], { stdio: 'pipe' });
1970 let stdout = '';
1971 let stderr = '';
1972 proc.stdout.on('data', (d) => stdout += d.toString());
1973 proc.stderr.on('data', (d) => stderr += d.toString());
1974 proc.on('close', (code) => {
1975 if (code === 0) resolve(stdout);
1976 else reject(new Error(`lsblk exited with code ${code}: ${stderr}`));
1977 });
1978 proc.on('error', (err) => reject(err));
1979 });
1980
1981 const parsed = JSON.parse(output);
1982 const devices = (parsed.blockdevices || []).filter(dev =>
1983 dev.type === 'disk' &&
1984 (dev.tran === 'usb' || dev.rm === true || dev.rm === '1' ||
1985 dev.hotplug === true || dev.hotplug === '1')
1986 ).map(dev => ({
1987 name: dev.name,
1988 path: `/dev/${dev.name}`,
1989 size: dev.size,
1990 model: (dev.model || 'Unknown USB Device').trim(),
1991 }));
1992
1993 console.log('[main] Found USB devices:', devices);
1994 return { success: true, devices };
1995 } catch (err) {
1996 console.error('[main] usb:list-devices error:', err);
1997 return { success: false, error: err.message };
1998 }
1999});
2000
2001ipcMain.handle('usb:flash-image', async (event, { url, devicePath, filename }) => {
2002 console.log('[main] usb:flash-image called:', { url, devicePath, filename });
2003 if (process.platform !== 'linux') {
2004 return { success: false, error: 'USB flashing is only supported on Linux' };
2005 }
2006
2007 if (!/^\/dev\/sd[a-z]$/.test(devicePath) && !/^\/dev\/nvme\d+n\d+$/.test(devicePath)) {
2008 return { success: false, error: `Invalid device path: ${devicePath}` };
2009 }
2010
2011 const sender = event.sender;
2012 const tmpFile = path.join(app.getPath('temp'), filename || 'ac-os.img');
2013
2014 const sendProgress = (stage, percent, message) => {
2015 if (!sender.isDestroyed()) {
2016 sender.send('usb:flash-progress', { stage, percent, message });
2017 }
2018 };
2019
2020 try {
2021 // Phase 1: Download image to temp file
2022 sendProgress('download', 0, 'Starting download...');
2023
2024 await new Promise((resolve, reject) => {
2025 const file = fs.createWriteStream(tmpFile);
2026 const request = net.request(url);
2027 let totalBytes = 0;
2028 let receivedBytes = 0;
2029
2030 request.on('response', (response) => {
2031 const cl = response.headers['content-length'];
2032 totalBytes = parseInt(Array.isArray(cl) ? cl[0] : cl || '0', 10);
2033
2034 response.on('data', (chunk) => {
2035 file.write(chunk);
2036 receivedBytes += chunk.length;
2037 if (totalBytes > 0) {
2038 const pct = Math.round((receivedBytes / totalBytes) * 100);
2039 sendProgress('download', pct,
2040 `Downloading: ${(receivedBytes / 1e9).toFixed(1)}GB / ${(totalBytes / 1e9).toFixed(1)}GB`);
2041 }
2042 });
2043
2044 response.on('end', () => file.end(() => resolve()));
2045 response.on('error', (err) => { file.destroy(); reject(err); });
2046 });
2047
2048 request.on('error', (err) => { file.destroy(); reject(err); });
2049 request.end();
2050 });
2051
2052 sendProgress('download', 100, 'Download complete');
2053
2054 // Phase 2: Unmount all partitions on the device
2055 sendProgress('unmount', 0, `Unmounting ${devicePath}...`);
2056
2057 await new Promise((resolve) => {
2058 const umount = spawn('sh', ['-c', `umount ${devicePath}* 2>/dev/null`], { stdio: 'pipe' });
2059 umount.on('close', () => resolve());
2060 umount.on('error', () => resolve());
2061 });
2062
2063 sendProgress('unmount', 100, 'Device unmounted');
2064
2065 // Phase 3: Flash with dd via pkexec (privilege escalation)
2066 sendProgress('write', 0, `Writing to ${devicePath}...`);
2067
2068 const imageSize = fs.statSync(tmpFile).size;
2069
2070 await new Promise((resolve, reject) => {
2071 const dd = spawn('pkexec', [
2072 'dd', `if=${tmpFile}`, `of=${devicePath}`,
2073 'bs=4M', 'conv=fsync', 'status=progress'
2074 ], { stdio: ['pipe', 'pipe', 'pipe'] });
2075
2076 let lastPercent = 0;
2077
2078 dd.stderr.on('data', (data) => {
2079 const text = data.toString();
2080 const match = text.match(/(\d+)\s+bytes/);
2081 if (match && imageSize > 0) {
2082 const written = parseInt(match[1], 10);
2083 const pct = Math.round((written / imageSize) * 100);
2084 if (pct > lastPercent) {
2085 lastPercent = pct;
2086 sendProgress('write', pct,
2087 `Writing: ${(written / 1e9).toFixed(1)}GB / ${(imageSize / 1e9).toFixed(1)}GB`);
2088 }
2089 }
2090 });
2091
2092 dd.on('close', (code) => {
2093 if (code === 0) resolve();
2094 else reject(new Error(`dd exited with code ${code}`));
2095 });
2096 dd.on('error', (err) => reject(err));
2097 });
2098
2099 sendProgress('write', 100, 'Write complete');
2100
2101 // Phase 4: Sync and eject
2102 sendProgress('eject', 0, 'Syncing and ejecting...');
2103
2104 await new Promise((resolve) => {
2105 const sync = spawn('sync', [], { stdio: 'pipe' });
2106 sync.on('close', () => resolve());
2107 sync.on('error', () => resolve());
2108 });
2109
2110 await new Promise((resolve) => {
2111 const eject = spawn('eject', [devicePath], { stdio: 'pipe' });
2112 eject.on('close', () => resolve());
2113 eject.on('error', () => resolve());
2114 });
2115
2116 sendProgress('eject', 100, 'Device ejected safely');
2117
2118 // Cleanup temp file
2119 try { fs.unlinkSync(tmpFile); } catch {}
2120
2121 return { success: true };
2122 } catch (err) {
2123 console.error('[main] usb:flash-image error:', err);
2124 try { fs.unlinkSync(tmpFile); } catch {}
2125 return { success: false, error: err.message };
2126 }
2127});
2128
2129ipcMain.handle('open-shell', async () => {
2130 console.log('[main] open-shell called');
2131 await openAcPaneWindow();
2132 return { success: true };
2133});
2134
2135// PTY handlers for AC Pane windows (legacy path)
2136ipcMain.handle('connect-pty', async (event) => {
2137 console.log('[main] connect-pty called');
2138 if (!pty) {
2139 console.error('[main] node-pty not available');
2140 return false;
2141 }
2142
2143 const win = BrowserWindow.fromWebContents(event.sender);
2144 let windowId = null;
2145 let winData = null;
2146
2147 for (const [id, data] of windows) {
2148 if (data.window === win) {
2149 windowId = id;
2150 winData = data;
2151 break;
2152 }
2153 }
2154
2155 if (!winData || winData.mode !== 'ac-pane') {
2156 console.error('connect-pty called on non-AC Pane window');
2157 return false;
2158 }
2159
2160 try {
2161 // Spawn docker exec to get into the container with fish shell
2162 const dockerPath = getDockerPath();
2163
2164 console.log('[main] Spawning PTY with docker at:', dockerPath);
2165
2166 // Ensure PATH includes common locations for packaged app
2167 const extraPaths = '/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin';
2168 const fullPath = process.env.PATH ? `${process.env.PATH}:${extraPaths}` : extraPaths;
2169
2170 const shellProcess = pty.spawn(dockerPath, ['exec', '-it', 'aesthetic', 'fish'], {
2171 name: 'xterm-256color',
2172 cols: 120,
2173 rows: 30,
2174 cwd: process.env.HOME,
2175 env: {
2176 ...process.env,
2177 TERM: 'xterm-256color',
2178 // Tell programs not to query for colors (eat/vim/etc respect this)
2179 COLORTERM: 'truecolor',
2180 // Prevent OSC query responses by indicating we don't support them
2181 VTE_VERSION: '', // Empty signals no VTE terminal
2182 PATH: fullPath
2183 },
2184 });
2185
2186 winData.ptyProcess = shellProcess;
2187
2188 // Forward PTY output to renderer
2189 shellProcess.onData((data) => {
2190 console.log('[main] PTY data received, length:', data.length, 'preview:', data.substring(0, 50).replace(/\n/g, '\\n'));
2191 if (!win.isDestroyed()) {
2192 win.webContents.send('pty-data', data);
2193 console.log('[main] PTY data sent to renderer');
2194 } else {
2195 console.log('[main] Window destroyed, cannot send PTY data');
2196 }
2197 });
2198
2199 shellProcess.onExit(({ exitCode }) => {
2200 console.log('[main] PTY exited with code:', exitCode);
2201 if (!win.isDestroyed()) {
2202 win.webContents.send('pty-exit', exitCode);
2203 }
2204 });
2205
2206 return true;
2207 } catch (err) {
2208 console.error('[main] PTY spawn failed:', err.message);
2209 console.error('[main] This usually means node-pty needs rebuilding for Electron.');
2210 console.error('[main] Run: cd ac-electron && npx electron-rebuild');
2211 return false;
2212 }
2213});
2214
2215ipcMain.on('pty-input', (event, data) => {
2216 const win = BrowserWindow.fromWebContents(event.sender);
2217 for (const [id, winData] of windows) {
2218 if (winData.window === win && winData.ptyProcess) {
2219 winData.ptyProcess.write(data);
2220 break;
2221 }
2222 }
2223});
2224
2225ipcMain.on('pty-resize', (event, cols, rows) => {
2226 const win = BrowserWindow.fromWebContents(event.sender);
2227 for (const [id, winData] of windows) {
2228 if (winData.window === win && winData.ptyProcess) {
2229 try {
2230 winData.ptyProcess.resize(cols, rows);
2231 } catch (err) {
2232 // PTY may not be ready yet or already exited
2233 console.warn('[main] PTY resize failed:', err.message);
2234 }
2235 break;
2236 }
2237 }
2238});
2239
2240// Global window move handler (for alt+scroll drag in any window)
2241ipcMain.on('move-window', (event, position) => {
2242 const senderWindow = BrowserWindow.fromWebContents(event.sender);
2243 if (senderWindow) {
2244 senderWindow.setPosition(Math.round(position.x), Math.round(position.y));
2245 }
2246});
2247
2248// Open/close windows from inside the embedded AC prompt (via webview popup interception)
2249ipcMain.handle('ac-open-window', async (event, { url, index = 0, total = 1 } = {}) => {
2250 console.log('[main] ac-open-window called with url:', url, 'index:', index, 'total:', total);
2251 const sourceWindow = BrowserWindow.fromWebContents(event.sender);
2252 const { window: newWindow } = await openAcPaneWindowInternal({ sourceWindow, index });
2253 console.log('[main] openAcPaneWindow returned:', !!newWindow);
2254 if (url && newWindow) {
2255 newWindow.webContents.once('did-finish-load', () => {
2256 if (!newWindow.isDestroyed()) {
2257 console.log('[main] Sending navigate to new window:', url);
2258 newWindow.webContents.send('navigate', url);
2259 }
2260 });
2261 }
2262 return { success: true };
2263});
2264
2265ipcMain.handle('ac-close-window', async (event) => {
2266 console.log('[main] ac-close-window called');
2267 const senderWindow = BrowserWindow.fromWebContents(event.sender);
2268 console.log('[main] sender window found:', !!senderWindow);
2269 if (senderWindow && !senderWindow.isDestroyed()) {
2270 console.log('[main] Closing window');
2271 senderWindow.close();
2272 }
2273 return { success: true };
2274});
2275
2276// Open external URL in the system's default browser
2277ipcMain.handle('open-external-url', async (event, url) => {
2278 console.log('[main] Opening external URL:', url);
2279 shell.openExternal(url);
2280 return { success: true };
2281});
2282
2283ipcMain.handle('switch-mode', async (event, mode) => {
2284 // Always open AC Pane
2285 await openAcPaneWindow();
2286 return 'ac-pane';
2287});
2288
2289ipcMain.handle('spawn-terminal', async (event, command) => {
2290 // This would use node-pty in a real implementation
2291 return { success: true };
2292});
2293
2294// Reboot/restart the Electron app
2295ipcMain.handle('app-reboot', async () => {
2296 console.log('[main] Rebooting app...');
2297 app.relaunch();
2298 app.quit();
2299 return { success: true };
2300});
2301
2302// ========== IPC Bridge for Artery/Tests ==========
2303// Forward commands from external tools to webview and vice versa
2304
2305// Navigate to a piece (from shell/artery-tui)
2306ipcMain.on('ac-navigate', (event, piece) => {
2307 // Forward to all AC panes
2308 for (const [id, winData] of windows) {
2309 if (winData.mode === 'ac-pane') {
2310 winData.window.webContents.send('ac-navigate', piece);
2311 }
2312 }
2313});
2314
2315// Set environment (local/prod)
2316ipcMain.on('ac-set-env', (event, env) => {
2317 for (const [id, winData] of windows) {
2318 if (winData.mode === 'ac-pane') {
2319 winData.window.webContents.send('ac-set-env', env);
2320 }
2321 }
2322});
2323
2324// Evaluate JavaScript in webview
2325ipcMain.handle('ac-eval', async (event, code) => {
2326 return new Promise((resolve) => {
2327 // Find the AC Pane
2328 for (const [id, winData] of windows) {
2329 if (winData.mode === 'ac-pane') {
2330 // Set up one-time listener for result
2331 const handler = (event, result) => {
2332 ipcMain.removeListener('ac-eval-result', handler);
2333 resolve(result);
2334 };
2335 ipcMain.on('ac-eval-result', handler);
2336 winData.window.webContents.send('ac-eval', code);
2337 // Timeout after 10 seconds
2338 setTimeout(() => {
2339 ipcMain.removeListener('ac-eval-result', handler);
2340 resolve({ success: false, error: 'Timeout' });
2341 }, 10000);
2342 return;
2343 }
2344 }
2345 resolve({ success: false, error: 'No AC Pane found' });
2346 });
2347});
2348
2349// Get current state
2350ipcMain.handle('ac-get-state', async (event) => {
2351 return new Promise((resolve) => {
2352 for (const [id, winData] of windows) {
2353 if (winData.mode === 'ac-pane') {
2354 const handler = (event, state) => {
2355 ipcMain.removeListener('ac-state', handler);
2356 resolve(state);
2357 };
2358 ipcMain.on('ac-state', handler);
2359 winData.window.webContents.send('ac-get-state');
2360 setTimeout(() => {
2361 ipcMain.removeListener('ac-state', handler);
2362 resolve(null);
2363 }, 5000);
2364 return;
2365 }
2366 }
2367 resolve(null);
2368 });
2369});
2370
2371// Take screenshot
2372ipcMain.handle('ac-screenshot', async (event) => {
2373 return new Promise((resolve) => {
2374 for (const [id, winData] of windows) {
2375 if (winData.mode === 'ac-pane') {
2376 const handler = (event, result) => {
2377 ipcMain.removeListener('ac-screenshot-result', handler);
2378 resolve(result);
2379 };
2380 ipcMain.on('ac-screenshot-result', handler);
2381 winData.window.webContents.send('ac-screenshot');
2382 setTimeout(() => {
2383 ipcMain.removeListener('ac-screenshot-result', handler);
2384 resolve({ success: false, error: 'Timeout' });
2385 }, 10000);
2386 return;
2387 }
2388 }
2389 resolve({ success: false, error: 'No AC Pane found' });
2390 });
2391});
2392
2393// Reload webview
2394ipcMain.on('ac-reload', (event) => {
2395 for (const [id, winData] of windows) {
2396 if (winData.mode === 'ac-pane') {
2397 winData.window.webContents.send('ac-reload');
2398 }
2399 }
2400});
2401
2402// ~ command: toggle DevTools docked at bottom, navigating to Console panel
2403ipcMain.on('open-devtools', (event) => {
2404 const win = BrowserWindow.fromWebContents(event.sender) || BrowserWindow.getFocusedWindow();
2405 if (!win) return;
2406 if (win.webContents.isDevToolsOpened()) {
2407 win.webContents.closeDevTools();
2408 } else {
2409 win.webContents.openDevTools({ mode: 'bottom' });
2410 win.webContents.once('devtools-opened', () => {
2411 try {
2412 win.webContents.devToolsWebContents?.executeJavaScript(
2413 'DevToolsAPI.showPanel("console")'
2414 ).catch(() => {});
2415 } catch (e) { /* ignore if DevTools API unavailable */ }
2416 });
2417 }
2418});
2419
2420// App lifecycle
2421app.whenReady().then(async () => {
2422 loadPreferences();
2423 createMenu();
2424 createSystemTray();
2425
2426 // Start FF1 Bridge server for kidlisp.com integration
2427 ff1Bridge.startBridge();
2428
2429 // Start GitHub update checks (works in dev and production)
2430 startSiloUpdateChecks();
2431
2432 // Check for updates on startup (production builds only)
2433 if (autoUpdater && !startInDevMode && app.isPackaged) {
2434 setTimeout(() => {
2435 console.log('[updater] Checking for updates on startup...');
2436 autoUpdater.checkForUpdates().catch(err => {
2437 console.log('[updater] Update check failed:', err.message);
2438 });
2439 }, 3000); // Wait 3s after startup
2440 }
2441
2442 // Watch for reboot request file from devcontainer (electric-snake-bite)
2443 const REBOOT_MARKER = path.join(__dirname, '.reboot-requested');
2444 const checkRebootMarker = () => {
2445 if (fs.existsSync(REBOOT_MARKER)) {
2446 console.log('[main] ⚡🐍 Electric Snake Bite detected! Relaunching...');
2447 fs.unlinkSync(REBOOT_MARKER); // Clean up marker
2448 // Relaunch the app then quit current instance
2449 app.relaunch();
2450 app.quit();
2451 }
2452 };
2453
2454 // Check every 2 seconds for reboot marker
2455 setInterval(checkRebootMarker, 2000);
2456 setInterval(checkRebootMarker, 2000);
2457
2458 // Create initial window(s)
2459 // Always start with an AC Pane window
2460 openAcPaneWindow();
2461
2462 app.on('activate', () => {
2463 if (BrowserWindow.getAllWindows().length === 0) {
2464 openAcPaneWindow();
2465 }
2466 });
2467
2468 // Allow webview preload scripts (required for webview-preload.js to work)
2469 app.on('web-contents-created', (_, contents) => {
2470 contents.on('will-attach-webview', (wawevent, webPreferences, params) => {
2471 // Verify the preload path is our legitimate webview-preload.js
2472 const preloadPath = params.preload;
2473 if (preloadPath) {
2474 // Use dev path if in dev mode, otherwise use bundled path
2475 const expectedPreload = getAppPath('webview-preload.js');
2476 // Allow if it matches our webview-preload.js
2477 if (preloadPath.includes('webview-preload.js')) {
2478 // Set the absolute path for the preload
2479 webPreferences.preload = expectedPreload;
2480 console.log('[main] Allowing webview preload:', expectedPreload);
2481 } else {
2482 // Block unknown preload scripts for security
2483 console.warn('[main] Blocking unknown webview preload:', preloadPath);
2484 delete webPreferences.preload;
2485 }
2486 }
2487 });
2488 });
2489});
2490
2491// Handle webview window.open() calls (popups) from guest content
2492// This is required in Electron 12+ as new-window event is deprecated
2493app.on('web-contents-created', (event, contents) => {
2494 // Only handle webviews
2495 if (contents.getType() === 'webview') {
2496 contents.on('will-navigate', (navEvent, url) => {
2497 if (!url) return;
2498 if (url.startsWith('ac://close')) {
2499 navEvent.preventDefault();
2500 const allWindows = BrowserWindow.getAllWindows();
2501 for (const win of allWindows) {
2502 if (!win.isDestroyed() && win.webContents.id === contents.hostWebContents?.id) {
2503 win.close();
2504 break;
2505 }
2506 }
2507 return;
2508 }
2509 // Handle 'local' / 'prod' commands - switch between dev and production servers
2510 try {
2511 const navUrl = new URL(url);
2512 const pathname = navUrl.pathname.replace(/^\//, '');
2513 if (pathname === 'local' || pathname === 'prod') {
2514 navEvent.preventDefault();
2515 const base = pathname === 'local'
2516 ? 'http://localhost:8888'
2517 : 'https://aesthetic.computer';
2518 const hostWin = BrowserWindow.getAllWindows().find(win =>
2519 !win.isDestroyed() && win.webContents.id === contents.hostWebContents?.id
2520 );
2521 if (hostWin) {
2522 console.log(`[main] Switching to ${pathname} server: ${base}`);
2523 hostWin.webContents.send('navigate', `${base}/prompt?desktop`);
2524 }
2525 return;
2526 }
2527 } catch (e) {}
2528 if (url.startsWith('ac://open')) {
2529 navEvent.preventDefault();
2530 let targetUrl = '';
2531 try {
2532 const urlObj = new URL(url);
2533 targetUrl = urlObj.searchParams.get('url') || '';
2534 } catch (err) {
2535 console.warn('[main] Failed to parse ac://open URL:', url, err.message);
2536 }
2537 openAcPaneWindow().then(({ window: newWin }) => {
2538 if (newWin && !newWin.isDestroyed()) {
2539 newWin.webContents.once('did-finish-load', () => {
2540 if (!newWin.isDestroyed() && targetUrl) {
2541 newWin.webContents.send('navigate', targetUrl);
2542 }
2543 });
2544 }
2545 });
2546 }
2547 });
2548
2549 contents.setWindowOpenHandler(({ url }) => {
2550 console.log('[main] Webview window.open:', url);
2551
2552 // Handle ac://close request (from prompt.mjs '-' command)
2553 if (url.startsWith('ac://close')) {
2554 // Find the parent BrowserWindow of this webview
2555 const allWindows = BrowserWindow.getAllWindows();
2556 for (const win of allWindows) {
2557 if (!win.isDestroyed() && win.webContents.id === contents.hostWebContents?.id) {
2558 win.close();
2559 break;
2560 }
2561 }
2562 return { action: 'deny' };
2563 }
2564
2565 // Check if this is an external URL (not aesthetic.computer or localhost)
2566 // External URLs should open in the system's default browser
2567 try {
2568 const urlObj = new URL(url);
2569 const isExternal = !urlObj.hostname.includes('aesthetic.computer') &&
2570 !urlObj.hostname.includes('localhost') &&
2571 !urlObj.hostname.includes('127.0.0.1') &&
2572 !url.startsWith('ac://');
2573
2574 if (isExternal) {
2575 console.log('[main] Opening external URL in system browser:', url);
2576 shell.openExternal(url);
2577 return { action: 'deny' };
2578 }
2579 } catch (e) {
2580 console.warn('[main] Failed to parse URL:', url, e.message);
2581 }
2582
2583 // Handle new window request (from prompt.mjs '+' command)
2584 // Open a new AC Pane and navigate to the URL
2585 // Find the source window from the webview's host
2586 const sourceWindow = BrowserWindow.getAllWindows().find(win =>
2587 !win.isDestroyed() && win.webContents.id === contents.hostWebContents?.id
2588 );
2589 openAcPaneWindowInternal({ sourceWindow, index: 0 }).then(({ window: newWin }) => {
2590 if (newWin && !newWin.isDestroyed()) {
2591 newWin.webContents.once('did-finish-load', () => {
2592 if (!newWin.isDestroyed()) {
2593 newWin.webContents.send('navigate', url);
2594 }
2595 });
2596 }
2597 });
2598
2599 return { action: 'deny' }; // We handle it ourselves
2600 });
2601 }
2602});
2603
2604app.on('window-all-closed', () => {
2605 // Quit when all windows are closed, even on macOS
2606 app.quit();
2607});
2608
2609app.on('will-quit', () => {
2610 globalShortcut.unregisterAll();
2611
2612 // Stop FF1 Bridge server
2613 ff1Bridge.stopBridge();
2614
2615 // Kill all PTY processes
2616 for (const [id, winData] of windows) {
2617 if (winData.ptyProcess) {
2618 winData.ptyProcess.kill();
2619 }
2620 }
2621
2622 // NOTE: We intentionally do NOT stop the devcontainer when the app quits.
2623 // The devcontainer should keep running for the VS Code workspace.
2624});
2625
2626// Handle certificate errors for localhost in dev mode
2627app.on('certificate-error', (event, webContents, url, error, certificate, callback) => {
2628 if (url.startsWith('https://localhost')) {
2629 event.preventDefault();
2630 callback(true);
2631 } else {
2632 callback(false);
2633 }
2634});
2635
2636// Handle SIGTERM/SIGINT for clean shutdown (when killed via pkill, etc)
2637function cleanup() {
2638 console.log('[main] Cleanup triggered');
2639
2640 // Kill all PTY processes
2641 for (const [id, winData] of windows) {
2642 if (winData.ptyProcess) {
2643 winData.ptyProcess.kill();
2644 }
2645 }
2646
2647 // NOTE: We intentionally do NOT stop the devcontainer.
2648 // It should keep running for the VS Code workspace.
2649
2650 process.exit(0);
2651}
2652
2653process.on('SIGTERM', cleanup);
2654process.on('SIGINT', cleanup);