Monorepo for Aesthetic.Computer aesthetic.computer
at main 2654 lines 85 kB view raw
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);