Monorepo for Aesthetic.Computer aesthetic.computer
at main 1171 lines 37 kB view raw
1<!DOCTYPE html> 2<html lang="en"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; frame-src https://localhost:* https://aesthetic.computer https://*.aesthetic.computer; connect-src 'self' https://localhost:* https://aesthetic.computer https://*.aesthetic.computer;"> 7 <title>Aesthetic Computer</title> 8 <link rel="stylesheet" href="../node_modules/@xterm/xterm/css/xterm.css"> 9 <style> 10 * { margin: 0; padding: 0; box-sizing: border-box; } 11 html, body { 12 width: 100%; 13 height: 100%; 14 overflow: hidden; 15 background: #050508; 16 } 17 18 /* Stacked layers - both always visible with opacity crossfade */ 19 #device-wrapper { 20 position: absolute; 21 top: 0; left: 0; right: 0; bottom: 0; 22 display: flex; 23 align-items: center; 24 justify-content: center; 25 background: #050508; 26 -webkit-app-region: drag; 27 } 28 29 #flip-container { 30 position: relative; 31 width: 100%; 32 height: 100%; 33 -webkit-app-region: no-drag; 34 } 35 36 /* Both layers stacked - z-index swaps, opacity creates bleed-through */ 37 #front-face, #back-face { 38 position: absolute; 39 width: 100%; 40 height: 100%; 41 overflow: hidden; 42 transition: opacity 0.5s ease, z-index 0s; 43 -webkit-app-region: no-drag; 44 } 45 46 /* Front face - AC App (default on top) */ 47 #front-face { 48 background: transparent; 49 z-index: 2; 50 opacity: 1; 51 } 52 53 /* Back face - Terminal (default behind with ghost) */ 54 #back-face { 55 background: transparent; 56 display: flex; 57 flex-direction: column; 58 z-index: 1; 59 opacity: 0.08; 60 } 61 62 /* Traffic lights area - keep space for window controls */ 63 .traffic-lights-spacer { 64 position: fixed; 65 top: 0; 66 left: 0; 67 width: 80px; 68 height: 40px; 69 -webkit-app-region: drag; 70 z-index: 1000; 71 } 72 73 /* Flip glow effect during animation */ 74 @keyframes flipGlow { 75 0% { box-shadow: none; } 76 50% { box-shadow: 0 0 40px rgba(255, 0, 255, 0.4); } 77 100% { box-shadow: none; } 78 } 79 80 #flip-container.flipping #front-face, 81 #flip-container.flipping #back-face { 82 animation: flipGlow 0.8s ease-in-out; 83 } 84 85 /* Webview - inherits parent opacity */ 86 #ac-webview { 87 width: 100%; 88 height: 100%; 89 border: none; 90 opacity: 0; 91 transition: opacity 0.3s ease; 92 } 93 #ac-webview.connected { opacity: 1; } 94 95 /* Terminal container - ensure transparency */ 96 #terminal-container { 97 flex: 1; 98 padding: 15px; 99 overflow: hidden; 100 background: transparent; 101 } 102 .xterm { height: 100%; background: transparent !important; } 103 .xterm-viewport { background: transparent !important; } 104 .xterm-screen { background: transparent !important; } 105 .xterm canvas { background: transparent !important; } 106 107 /* When control bar is hidden */ 108 body.bar-hidden #device-wrapper { 109 top: 0; 110 } 111 112 /* Control bar - frameless window, positioned after traffic lights */ 113 #control-bar { 114 position: fixed; 115 top: 8px; left: 80px; right: 15px; 116 height: 32px; 117 display: flex; 118 align-items: center; 119 gap: 12px; 120 padding: 6px 12px; 121 background: rgba(0, 0, 0, 0.8); 122 border-radius: 8px; 123 border: 1px solid rgba(255, 0, 255, 0.2); 124 font-family: 'SF Mono', monospace; 125 font-size: 11px; 126 color: #888; 127 z-index: 300; 128 -webkit-app-region: no-drag; 129 transition: transform 0.2s ease, opacity 0.2s ease; 130 } 131 132 #control-bar.hidden { 133 transform: translateY(-100%); 134 opacity: 0; 135 pointer-events: none; 136 } 137 138 #control-bar > * { 139 -webkit-app-region: no-drag; 140 } 141 142 .title { 143 color: #f0f; 144 cursor: default; 145 -webkit-app-region: drag; 146 } 147 148 /* URL/Environment selector */ 149 .env-switch { 150 display: flex; 151 background: rgba(0, 0, 0, 0.3); 152 border-radius: 4px; 153 overflow: hidden; 154 border: 1px solid rgba(255, 255, 255, 0.1); 155 } 156 .env-btn { 157 background: transparent; 158 border: none; 159 color: #666; 160 padding: 5px 10px; 161 font-family: inherit; 162 font-size: 11px; 163 cursor: pointer; 164 transition: all 0.15s ease; 165 } 166 .env-btn:hover { 167 color: #aaa; 168 background: rgba(255, 255, 255, 0.05); 169 } 170 .env-btn.active.local { 171 background: rgba(255, 150, 0, 0.2); 172 color: #f90; 173 } 174 .env-btn.active.prod { 175 background: rgba(0, 200, 100, 0.2); 176 color: #0c6; 177 } 178 179 /* Mode switch - Front/Back flip */ 180 .mode-switch { 181 display: flex; 182 background: rgba(0, 0, 0, 0.3); 183 border-radius: 4px; 184 overflow: hidden; 185 border: 1px solid rgba(255, 255, 255, 0.1); 186 } 187 .mode-btn { 188 background: transparent; 189 border: none; 190 color: #666; 191 padding: 5px 12px; 192 font-family: inherit; 193 font-size: 11px; 194 cursor: pointer; 195 transition: all 0.15s ease; 196 } 197 .mode-btn:hover { 198 color: #aaa; 199 background: rgba(255, 255, 255, 0.05); 200 } 201 .mode-btn.active.front-mode { 202 background: rgba(0, 255, 150, 0.2); 203 color: #0fa; 204 } 205 .mode-btn.active.back-mode { 206 background: rgba(255, 0, 255, 0.2); 207 color: #f0f; 208 } 209 210 /* Flip button */ 211 /* CDP indicator */ 212 .cdp-indicator { 213 display: none; 214 align-items: center; 215 gap: 5px; 216 padding: 4px 8px; 217 background: rgba(0, 200, 255, 0.15); 218 border: 1px solid rgba(0, 200, 255, 0.4); 219 border-radius: 4px; 220 font-size: 10px; 221 color: #0cf; 222 } 223 .cdp-indicator.active { 224 display: flex; 225 } 226 .cdp-dot { 227 width: 6px; 228 height: 6px; 229 background: #0cf; 230 border-radius: 50%; 231 animation: cdpPulse 1.5s ease-in-out infinite; 232 } 233 @keyframes cdpPulse { 234 0%, 100% { opacity: 0.4; box-shadow: 0 0 2px #0cf; } 235 50% { opacity: 1; box-shadow: 0 0 8px #0cf; } 236 } 237 238 .flip-btn { 239 background: rgba(255, 0, 255, 0.1); 240 border: 1px solid rgba(255, 0, 255, 0.3); 241 color: #f0f; 242 padding: 5px 12px; 243 font-family: inherit; 244 font-size: 11px; 245 cursor: pointer; 246 border-radius: 4px; 247 transition: all 0.2s ease; 248 } 249 .flip-btn:hover { 250 background: rgba(255, 0, 255, 0.2); 251 transform: scale(1.05); 252 } 253 .flip-btn:active { 254 transform: scale(0.95); 255 } 256 .flip-btn .flip-icon { 257 display: inline-block; 258 transition: transform 0.6s cubic-bezier(0.4, 0.0, 0.2, 1); 259 } 260 body.showing-back .flip-btn .flip-icon { 261 transform: rotateY(180deg); 262 } 263 264 /* Transparency slider */ 265 .slider-group { 266 display: flex; 267 align-items: center; 268 gap: 8px; 269 margin-left: auto; 270 } 271 .slider-label { 272 color: #666; 273 font-size: 11px; 274 } 275 #opacity-slider { 276 -webkit-appearance: none; 277 width: 100px; 278 height: 4px; 279 background: rgba(255, 255, 255, 0.1); 280 border-radius: 2px; 281 outline: none; 282 } 283 #opacity-slider::-webkit-slider-thumb { 284 -webkit-appearance: none; 285 width: 14px; 286 height: 14px; 287 background: #f0f; 288 border-radius: 50%; 289 cursor: pointer; 290 transition: transform 0.1s ease; 291 } 292 #opacity-slider::-webkit-slider-thumb:hover { 293 transform: scale(1.2); 294 } 295 #opacity-value { 296 color: #888; 297 font-size: 11px; 298 min-width: 35px; 299 } 300 301 .hint { 302 color: #555; 303 font-size: 11px; 304 } 305 .hint kbd { 306 background: rgba(255, 255, 255, 0.1); 307 padding: 2px 5px; 308 border-radius: 3px; 309 } 310 311 /* Mode indicator overlay */ 312 #mode-indicator { 313 position: fixed; 314 bottom: 40px; 315 right: 40px; 316 background: rgba(0, 0, 0, 0.9); 317 padding: 12px 20px; 318 border-radius: 8px; 319 font-family: 'SF Mono', monospace; 320 font-size: 16px; 321 opacity: 0; 322 transition: opacity 0.3s ease, transform 0.3s ease; 323 pointer-events: none; 324 z-index: 500; 325 transform: translateY(10px); 326 } 327 #mode-indicator.front-active { 328 color: #0fa; 329 border: 1px solid rgba(0, 255, 150, 0.4); 330 box-shadow: 0 0 20px rgba(0, 255, 150, 0.2); 331 } 332 #mode-indicator.back-active { 333 color: #f0f; 334 border: 1px solid rgba(255, 0, 255, 0.4); 335 box-shadow: 0 0 20px rgba(255, 0, 255, 0.2); 336 } 337 #mode-indicator.visible { 338 opacity: 1; 339 transform: translateY(0); 340 } 341 342 /* Connection overlay (shown when server not ready) */ 343 #connection-overlay { 344 position: absolute; 345 top: 0; left: 0; right: 0; bottom: 0; 346 display: flex; 347 flex-direction: column; 348 align-items: center; 349 justify-content: center; 350 background: #0a0a12; 351 color: #888; 352 font-family: 'SF Mono', 'Fira Code', monospace; 353 font-size: 14px; 354 z-index: 50; 355 transition: opacity 0.3s ease; 356 } 357 #connection-overlay.hidden { 358 opacity: 0; 359 pointer-events: none; 360 } 361 362 #status-icon { 363 font-size: 48px; 364 margin-bottom: 20px; 365 animation: pulse 2s ease-in-out infinite; 366 } 367 @keyframes pulse { 368 0%, 100% { opacity: 0.4; } 369 50% { opacity: 1; } 370 } 371 372 #status-message { color: #f90; margin-bottom: 8px; } 373 #status-detail { color: #666; font-size: 12px; margin-bottom: 20px; } 374 #retry-count { color: #444; font-size: 11px; } 375 </style> 376</head> 377<body> 378 <!-- Traffic lights spacer for frameless window --> 379 <div class="traffic-lights-spacer"></div> 380 381 <!-- Connection overlay (for local dev server) --> 382 <div id="connection-overlay" class="hidden"> 383 <div id="status-icon"></div> 384 <div id="status-message">Connecting to dev server...</div> 385 <div id="status-detail">https://localhost:8888</div> 386 <div id="retry-count">Attempt 1</div> 387 </div> 388 389 <!-- Mode indicator (shows briefly on flip) --> 390 <div id="mode-indicator">Back</div> 391 392 <!-- Control bar --> 393 <div id="control-bar"> 394 <span class="title">🩸 AC</span> 395 396 <!-- CDP indicator --> 397 <div id="cdp-indicator" class="cdp-indicator"> 398 <span class="cdp-dot"></span> 399 <span>CDP <span id="cdp-port"></span></span> 400 </div> 401 402 <!-- Flip button --> 403 <button id="flip-btn" class="flip-btn"><span class="flip-icon"></span> Flip</button> 404 405 <!-- Environment switch (only shown on front) --> 406 <div class="env-switch" id="env-switch"> 407 <button id="env-local" class="env-btn local">Local</button> 408 <button id="env-prod" class="env-btn prod active">Prod</button> 409 </div> 410 411 <span class="hint"><kbd>⌘`</kbd> flip · <kbd>⌘H</kbd> hide bar</span> 412 </div> 413 414 <!-- Device wrapper for 3D centering --> 415 <div id="device-wrapper"> 416 <!-- 3D Flip Container - the "device" --> 417 <div id="flip-container"> 418 <!-- Front Face - AC App --> 419 <div id="front-face"> 420 <webview id="ac-webview" src="about:blank" allowpopups></webview> 421 </div> 422 423 <!-- Back Face - Terminal --> 424 <div id="back-face"> 425 <div id="terminal-container"></div> 426 </div> 427 </div> 428 </div> 429 430 <script> 431 // With nodeIntegration, we can use require 432 const { Terminal } = require('@xterm/xterm'); 433 const { FitAddon } = require('@xterm/addon-fit'); 434 const { WebglAddon } = require('@xterm/addon-webgl'); 435 const { ipcRenderer } = require('electron'); 436 437 // Elements 438 const webview = document.getElementById('ac-webview'); 439 const controlBar = document.getElementById('control-bar'); 440 const flipContainer = document.getElementById('flip-container'); 441 const frontFace = document.getElementById('front-face'); 442 const backFace = document.getElementById('back-face'); 443 const terminalContainer = document.getElementById('terminal-container'); 444 const connectionOverlay = document.getElementById('connection-overlay'); 445 const statusMessage = document.getElementById('status-message'); 446 const statusDetail = document.getElementById('status-detail'); 447 const retryCount = document.getElementById('retry-count'); 448 const flipBtn = document.getElementById('flip-btn'); 449 const envSwitch = document.getElementById('env-switch'); 450 const envLocalBtn = document.getElementById('env-local'); 451 const envProdBtn = document.getElementById('env-prod'); 452 const modeIndicator = document.getElementById('mode-indicator'); 453 454 // URLs 455 const URLS = { 456 local: 'https://localhost:8888', 457 prod: 'https://aesthetic.computer' 458 }; 459 460 // Check for CDP (Chrome DevTools Protocol) on startup 461 (async () => { 462 const cdpInfo = await ipcRenderer.invoke('get-cdp-info'); 463 if (cdpInfo && cdpInfo.enabled) { 464 const indicator = document.getElementById('cdp-indicator'); 465 const portSpan = document.getElementById('cdp-port'); 466 indicator.classList.add('active'); 467 portSpan.textContent = `:${cdpInfo.port}`; 468 indicator.title = `DevTools: ws://127.0.0.1:${cdpInfo.port}`; 469 } 470 })(); 471 472 let currentEnv = 'prod'; // 'local' or 'prod' - start with prod 473 let currentPiece = 'starfield'; // default piece 474 let attempts = 0; 475 let serverConnected = false; 476 let checkInterval = null; 477 let showingBack = true; // Start showing back (terminal) 478 let controlBarVisible = true; 479 480 // Helper to build AC URL with electron-specific params 481 function acUrl(piece) { 482 const baseUrl = URLS[currentEnv]; 483 return `${baseUrl}/${piece}?nogap`; 484 } 485 486 // ========== Terminal Setup ========== 487 const term = new Terminal({ 488 cursorBlink: true, 489 cursorStyle: 'block', 490 fontSize: 13, 491 fontFamily: "'SF Mono', 'Fira Code', monospace", 492 scrollback: 5000, 493 fastScrollModifier: 'alt', 494 fastScrollSensitivity: 5, 495 // Disable OSC query responses - prevents ]11;rgb:... from being sent back 496 allowProposedApi: true, 497 theme: { 498 background: 'transparent', 499 foreground: '#eee', 500 cursor: '#f0f', 501 cursorAccent: '#0a0a12', 502 selectionBackground: 'rgba(255, 0, 255, 0.3)', 503 black: '#1a1a2e', 504 red: '#ff5555', 505 green: '#50fa7b', 506 yellow: '#f1fa8c', 507 blue: '#6272a4', 508 magenta: '#ff79c6', 509 cyan: '#8be9fd', 510 white: '#f8f8f2', 511 }, 512 allowTransparency: true, 513 }); 514 515 const fitAddon = new FitAddon(); 516 term.loadAddon(fitAddon); 517 term.open(terminalContainer); 518 519 // Load WebGL addon for GPU-accelerated rendering 520 try { 521 const webglAddon = new WebglAddon(); 522 webglAddon.onContextLoss(() => { 523 webglAddon.dispose(); 524 }); 525 term.loadAddon(webglAddon); 526 console.log('[dev] WebGL renderer enabled'); 527 } catch (e) { 528 console.warn('[dev] WebGL not available, using canvas renderer'); 529 } 530 531 setTimeout(() => fitAddon.fit(), 50); 532 533 // Resize handling 534 const resizeObserver = new ResizeObserver(() => { 535 fitAddon.fit(); 536 ipcRenderer.send('pty-resize', term.cols, term.rows); 537 }); 538 resizeObserver.observe(terminalContainer); 539 540 // ========== Environment Switching ========== 541 function setEnv(env) { 542 currentEnv = env; 543 const baseUrl = URLS[env]; 544 545 if (env === 'local') { 546 envLocalBtn.classList.add('active'); 547 envProdBtn.classList.remove('active'); 548 statusDetail.textContent = baseUrl; 549 // For local, check if server is running first 550 if (!serverConnected) { 551 connectionOverlay.classList.remove('hidden'); 552 checkServer(); 553 } else { 554 webview.src = acUrl(currentPiece); 555 } 556 } else { 557 envLocalBtn.classList.remove('active'); 558 envProdBtn.classList.add('active'); 559 // Production is always available 560 connectionOverlay.classList.add('hidden'); 561 webview.classList.add('connected'); 562 webview.src = acUrl(currentPiece); 563 if (checkInterval) { 564 clearInterval(checkInterval); 565 checkInterval = null; 566 } 567 } 568 569 updateTitle(); 570 } 571 572 envLocalBtn.addEventListener('click', () => setEnv('local')); 573 envProdBtn.addEventListener('click', () => setEnv('prod')); 574 575 // ========== Control Bar Visibility ========== 576 function toggleControlBar() { 577 controlBarVisible = !controlBarVisible; 578 if (controlBarVisible) { 579 controlBar.classList.remove('hidden'); 580 document.body.classList.remove('bar-hidden'); 581 } else { 582 controlBar.classList.add('hidden'); 583 document.body.classList.add('bar-hidden'); 584 } 585 // Refit terminal after layout change 586 setTimeout(() => fitAddon.fit(), 50); 587 } 588 589 // ========== Flip Animation ========== 590 function flip() { 591 showingBack = !showingBack; 592 593 const frontFace = document.getElementById('front-face'); 594 const backFace = document.getElementById('back-face'); 595 596 if (showingBack) { 597 // Show back (terminal) - swap z-index, crossfade opacity 598 frontFace.style.zIndex = '1'; 599 frontFace.style.opacity = '0.08'; 600 backFace.style.zIndex = '2'; 601 backFace.style.opacity = '1'; 602 603 // Webview ghost, terminal full 604 webview.style.opacity = '0.08'; 605 webview.style.transition = 'opacity 0.5s ease'; 606 terminalContainer.style.opacity = '1'; 607 terminalContainer.style.transition = 'opacity 0.5s ease'; 608 609 document.body.classList.add('showing-back'); 610 modeIndicator.textContent = '🩸 Back'; 611 modeIndicator.className = 'back-active visible'; 612 envSwitch.style.display = 'none'; 613 614 setTimeout(() => { 615 term.focus(); 616 fitAddon.fit(); 617 }, 200); 618 } else { 619 // Show front (app) - swap z-index, crossfade opacity 620 frontFace.style.zIndex = '2'; 621 frontFace.style.opacity = '1'; 622 backFace.style.zIndex = '1'; 623 backFace.style.opacity = '0.08'; 624 625 // Terminal ghost, webview full 626 webview.style.opacity = '1'; 627 webview.style.transition = 'opacity 0.5s ease'; 628 terminalContainer.style.opacity = '0.08'; 629 terminalContainer.style.transition = 'opacity 0.5s ease'; 630 631 document.body.classList.remove('showing-back'); 632 modeIndicator.textContent = '⚡ Front'; 633 modeIndicator.className = 'front-active visible'; 634 envSwitch.style.display = 'flex'; 635 636 setTimeout(() => webview.focus(), 200); 637 } 638 639 // Hide indicator after a moment 640 setTimeout(() => { 641 modeIndicator.classList.remove('visible'); 642 }, 1000); 643 } 644 645 // Initialize - start with back showing (terminal) 646 flipContainer.classList.add('flipped'); 647 document.body.classList.add('showing-back'); 648 envSwitch.style.display = 'none'; 649 // Set initial opacity states 650 webview.style.opacity = '0.08'; 651 terminalContainer.style.opacity = '1'; 652 653 // Flip button click 654 flipBtn.addEventListener('click', flip); 655 656 // Keyboard shortcuts 657 document.addEventListener('keydown', async (e) => { 658 // Cmd+C to copy selected text from terminal (when showing back) 659 if ((e.metaKey || e.ctrlKey) && e.key === 'c' && showingBack) { 660 if (term.hasSelection()) { 661 e.preventDefault(); 662 const selection = term.getSelection(); 663 await navigator.clipboard.writeText(selection); 664 } 665 // If no selection, let Ctrl+C pass through to terminal (interrupt) 666 } 667 // Cmd+V to paste into terminal 668 if ((e.metaKey || e.ctrlKey) && e.key === 'v' && showingBack) { 669 e.preventDefault(); 670 try { 671 const text = await navigator.clipboard.readText(); 672 if (text) { 673 ipcRenderer.send('pty-input', text); 674 } 675 } catch (err) { 676 // Clipboard access denied 677 } 678 } 679 // Cmd+` to flip 680 if ((e.metaKey || e.ctrlKey) && e.key === '`') { 681 e.preventDefault(); 682 flip(); 683 } 684 // Cmd+H to toggle control bar visibility 685 if ((e.metaKey || e.ctrlKey) && e.key === 'h') { 686 e.preventDefault(); 687 toggleControlBar(); 688 } 689 // Cmd+1 for local, Cmd+2 for prod (only when showing front) 690 if ((e.metaKey || e.ctrlKey) && e.key === '1' && !showingBack) { 691 e.preventDefault(); 692 setEnv('local'); 693 } 694 if ((e.metaKey || e.ctrlKey) && e.key === '2' && !showingBack) { 695 e.preventDefault(); 696 setEnv('prod'); 697 } 698 // Cmd+Shift+R to trigger full app reboot (electric-snake-bite) 699 if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'R') { 700 e.preventDefault(); 701 term.writeln('\r\n\x1b[95m⚡🐍 Electric Snake Bite - Rebooting Electron...\x1b[0m\r\n'); 702 await ipcRenderer.invoke('app-reboot'); 703 } 704 }); 705 706 // ========== Server Connection ========== 707 async function checkServer() { 708 const baseUrl = URLS[currentEnv]; 709 attempts++; 710 retryCount.textContent = `Attempt ${attempts}`; 711 712 try { 713 const controller = new AbortController(); 714 const timeout = setTimeout(() => controller.abort(), 3000); 715 716 await fetch(baseUrl, { 717 method: 'HEAD', 718 mode: 'no-cors', 719 signal: controller.signal 720 }); 721 clearTimeout(timeout); 722 723 statusMessage.textContent = 'Server found!'; 724 serverConnected = true; 725 webview.src = acUrl(currentPiece); 726 return true; 727 } catch (e) { 728 return false; 729 } 730 } 731 732 // Webview load handlers 733 webview.addEventListener('did-finish-load', async () => { 734 const baseUrl = URLS[currentEnv]; 735 if (webview.src.startsWith(baseUrl) || webview.src.startsWith(URLS.prod)) { 736 serverConnected = true; 737 statusMessage.textContent = 'Connected!'; 738 739 // Inject Electron app info into webview for pieces like desktop.mjs 740 try { 741 const appInfo = await ipcRenderer.invoke('get-app-info'); 742 await webview.executeJavaScript(`window.acElectronApp = ${JSON.stringify(appInfo)};`); 743 } catch (e) { 744 console.warn('Could not inject app info:', e); 745 } 746 747 setTimeout(() => { 748 connectionOverlay.classList.add('hidden'); 749 webview.classList.add('connected'); 750 }, 300); 751 752 if (checkInterval) { 753 clearInterval(checkInterval); 754 checkInterval = null; 755 } 756 } 757 }); 758 759 webview.addEventListener('did-fail-load', (e) => { 760 if (e.errorCode === -3) return; 761 webview.src = 'about:blank'; 762 if (!checkInterval) { 763 checkInterval = setInterval(checkServer, 2000); 764 } 765 }); 766 767 // Title updates 768 function updateTitle(url) { 769 try { 770 const u = new URL(url); 771 currentPiece = u.pathname.replace(/^\//, '').replace(/\/$/, '') || 'prompt'; 772 const envLabel = currentEnv === 'prod' ? '' : ' [DEV]'; 773 document.title = `${currentPiece} — Aesthetic Computer${envLabel}`; 774 } catch (e) { 775 document.title = 'Aesthetic Computer'; 776 } 777 } 778 779 webview.addEventListener('did-navigate', (e) => updateTitle(e.url)); 780 webview.addEventListener('did-navigate-in-page', (e) => updateTitle(e.url)); 781 782 // Intercept window.open from inside the webview. 783 // Used by AC prompt commands: '+' opens new window, '-' requests close via ac://close. 784 webview.addEventListener('new-window', (e) => { 785 if (e?.preventDefault) e.preventDefault(); 786 const url = e?.url || ''; 787 if (url.startsWith('ac://close')) { 788 ipcRenderer.invoke('ac-close-window'); 789 return; 790 } 791 ipcRenderer.invoke('ac-open-window', { url }); 792 }); 793 794 // Allow main process to navigate this window (used for newly spawned windows). 795 ipcRenderer.on('navigate', (event, url) => { 796 if (typeof url === 'string' && url.length) { 797 webview.src = url; 798 updateTitle(url); 799 } 800 }); 801 802 // Menu handlers 803 window.ac?.onNavigate?.((url) => { 804 webview.src = url; 805 updateTitle(url); 806 }); 807 window.ac?.onGoBack?.(() => webview.goBack()); 808 window.ac?.onGoForward?.(() => webview.goForward()); 809 window.ac?.onToggleDevtools?.(() => { 810 if (webview.isDevToolsOpened()) webview.closeDevTools(); 811 else webview.openDevTools(); 812 }); 813 814 // ========== PTY Connection ========== 815 async function initShell() { 816 try { 817 const dockerOk = await ipcRenderer.invoke('check-docker'); 818 if (!dockerOk) { 819 term.writeln('\x1b[31mError: Docker not running\x1b[0m'); 820 return; 821 } 822 823 const containerRunning = await ipcRenderer.invoke('check-container'); 824 if (!containerRunning) { 825 // Check if container exists but is stopped 826 const containerExists = await ipcRenderer.invoke('check-container-exists'); 827 if (containerExists) { 828 term.writeln('\x1b[33mStarting existing container...\x1b[0m'); 829 await ipcRenderer.invoke('start-existing-container'); 830 } else { 831 term.writeln('\x1b[33mCreating devcontainer...\x1b[0m'); 832 await ipcRenderer.invoke('start-container'); 833 } 834 } 835 836 const connected = await ipcRenderer.invoke('connect-pty'); 837 if (!connected) { 838 term.writeln('\x1b[31mFailed to connect to shell\x1b[0m'); 839 return; 840 } 841 842 // ⚡🐍 Electric Snake Bite - Special escape sequence detection 843 // When the devcontainer outputs this magic string, reload the window 844 const ELECTRIC_SNAKE_BITE = '\x1b]9999;electric-snake-bite\x07'; 845 let escapeBuffer = ''; 846 let dataCount = 0; 847 848 ipcRenderer.on('pty-data', (event, data) => { 849 dataCount++; 850 851 // Check for electric-snake-bite escape sequence (must be the FULL OSC sequence) 852 escapeBuffer += data; 853 if (escapeBuffer.length > 100) { 854 escapeBuffer = escapeBuffer.slice(-50); // Keep buffer small 855 } 856 857 // Only trigger on the actual OSC 9999 escape sequence, not plain text 858 if (escapeBuffer.includes('\x1b]9999;electric-snake-bite')) { 859 escapeBuffer = ''; 860 // Don't write the escape sequence to terminal 861 const cleanData = data.replace(/\x1b\]9999;electric-snake-bite\x07/g, ''); 862 if (cleanData) term.write(cleanData); 863 // Reload after a brief delay 864 setTimeout(() => location.reload(), 100); 865 return; 866 } 867 868 term.write(data); 869 }); 870 // Filter out terminal query responses before sending to PTY 871 // These are responses to OSC queries like ]11;rgb:... (background color) 872 // Buffer small inputs to catch responses that come character by character 873 let inputBuffer = ''; 874 let inputTimeout = null; 875 876 term.onData((data) => { 877 // Accumulate data 878 inputBuffer += data; 879 880 // Clear any pending flush 881 if (inputTimeout) clearTimeout(inputTimeout); 882 883 // Flush after short delay or when buffer gets large enough 884 inputTimeout = setTimeout(() => { 885 const toSend = inputBuffer; 886 inputBuffer = ''; 887 888 // Filter out OSC responses 889 // Check for escape sequence form 890 if (toSend.match(/\x1b\]1[0-9];/) || toSend.match(/\x1b\[\?/)) { 891 return; 892 } 893 // Filter plain OSC responses without escape 894 if (toSend.match(/^\]1[0-9];/) || toSend.match(/\]1[0-9];rgb:/)) { 895 return; 896 } 897 // Filter responses that contain rgb: color data or OSC markers 898 if (toSend.includes(';rgb:') || toSend.includes(']11;') || toSend.includes(']10;')) { 899 return; 900 } 901 902 ipcRenderer.send('pty-input', toSend); 903 }, 5); // 5ms buffer - enough to catch multi-char sequences 904 }); 905 ipcRenderer.on('pty-exit', (event, code) => { 906 term.writeln(`\r\n\x1b[33mShell exited with code ${code}\x1b[0m`); 907 }); 908 909 ipcRenderer.send('pty-resize', term.cols, term.rows); 910 911 // Auto-start emacs with proper daemon wait 912 // The container may need time for emacs daemon to fully initialize 913 setTimeout(() => { 914 // First ensure emacs daemon is ready, then connect 915 // Use a retry-aware startup command 916 ipcRenderer.send('pty-input', 'ensure-emacs-daemon-ready --timeout=60 && ac-aesthetic\n'); 917 // After emacs loads, send resize and refresh to sync terminal 918 setTimeout(() => { 919 // Fit terminal first 920 fitAddon.fit(); 921 // Send resize to PTY 922 ipcRenderer.send('pty-resize', term.cols, term.rows); 923 // Redraw emacs 924 ipcRenderer.send('pty-input', '\x0c'); // Ctrl+L to redraw emacs 925 }, 3000); // Wait longer for daemon init 926 }, 500); // Give fish a moment to fully init 927 928 } catch (err) { 929 term.writeln(`\x1b[31mError: ${err.message}\x1b[0m`); 930 } 931 } 932 933 // ========== IPC Bridge for Artery/Tests ========== 934 // These handlers allow artery-tui to control the webview for testing 935 936 // Navigate to a piece 937 ipcRenderer.on('ac-navigate', (event, piece) => { 938 const baseUrl = URLS[currentEnv]; 939 currentPiece = piece; 940 webview.src = acUrl(piece); 941 updateTitle(webview.src); 942 }); 943 944 // Switch environment 945 ipcRenderer.on('ac-set-env', (event, env) => { 946 if (env === 'local' || env === 'prod') { 947 setEnv(env); 948 } 949 }); 950 951 // Execute JavaScript in the webview 952 ipcRenderer.on('ac-eval', async (event, code) => { 953 try { 954 const result = await webview.executeJavaScript(code); 955 ipcRenderer.send('ac-eval-result', { success: true, result }); 956 } catch (e) { 957 ipcRenderer.send('ac-eval-result', { success: false, error: e.message }); 958 } 959 }); 960 961 // Get current state 962 ipcRenderer.on('ac-get-state', (event) => { 963 ipcRenderer.send('ac-state', { 964 env: currentEnv, 965 piece: currentPiece, 966 url: webview.src, 967 mode: currentMode, 968 connected: serverConnected 969 }); 970 }); 971 972 // Toggle shell/app mode 973 ipcRenderer.on('ac-set-mode', (event, mode) => { 974 if (mode === 'shell' || mode === 'app') { 975 setMode(mode); 976 } 977 }); 978 979 // Take screenshot of webview 980 ipcRenderer.on('ac-screenshot', async (event) => { 981 try { 982 const nativeImage = await webview.capturePage(); 983 const dataUrl = nativeImage.toDataURL(); 984 ipcRenderer.send('ac-screenshot-result', { success: true, dataUrl }); 985 } catch (e) { 986 ipcRenderer.send('ac-screenshot-result', { success: false, error: e.message }); 987 } 988 }); 989 990 // Reload webview 991 ipcRenderer.on('ac-reload', (event) => { 992 webview.reload(); 993 }); 994 995 // ========== File-Based Bridge for Devcontainer Tests ========== 996 // Watches for command files from artery-electron.mjs in the devcontainer 997 const { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } = require('fs'); 998 const pathModule = require('path'); 999 // @electron/remote is optional - don't crash if not available 1000 let remote = null; 1001 try { remote = require('@electron/remote'); } catch (e) { } 1002 1003 // Get workspace root - the parent of ac-electron 1004 // In dev mode, process.cwd() is usually the workspace root 1005 // Or we can use the app path 1006 let WORKSPACE_ROOT = process.cwd(); 1007 1008 // Verify we found the right place 1009 if (!existsSync(pathModule.join(WORKSPACE_ROOT, '.electron-bridge')) && 1010 !existsSync(pathModule.join(WORKSPACE_ROOT, 'ac-electron'))) { 1011 // Try going up from current directory 1012 let testPath = WORKSPACE_ROOT; 1013 for (let i = 0; i < 5; i++) { 1014 if (existsSync(pathModule.join(testPath, 'ac-electron'))) { 1015 WORKSPACE_ROOT = testPath; 1016 break; 1017 } 1018 testPath = pathModule.dirname(testPath); 1019 } 1020 } 1021 1022 const BRIDGE_DIR = pathModule.join(WORKSPACE_ROOT, '.electron-bridge'); 1023 const CMD_FILE = pathModule.join(BRIDGE_DIR, 'command.json'); 1024 const RESULT_FILE = pathModule.join(BRIDGE_DIR, 'result.json'); 1025 1026 let lastProcessedId = null; 1027 let bridgeWatcher = null; 1028 1029 async function processCommand(request) { 1030 const { id, cmd, params } = request; 1031 1032 // Skip if already processed 1033 if (id === lastProcessedId) return; 1034 lastProcessedId = id; 1035 1036 let result = { id, data: null, error: null }; 1037 1038 try { 1039 switch (cmd) { 1040 case 'get-state': 1041 result.data = { 1042 env: currentEnv, 1043 piece: currentPiece, 1044 url: webview.src, 1045 mode: currentMode, 1046 connected: serverConnected 1047 }; 1048 break; 1049 1050 case 'navigate': 1051 const baseUrl = URLS[currentEnv]; 1052 currentPiece = params.piece; 1053 webview.src = acUrl(params.piece); 1054 updateTitle(webview.src); 1055 result.data = { success: true, piece: params.piece }; 1056 break; 1057 1058 case 'eval': 1059 try { 1060 const evalResult = await webview.executeJavaScript(params.code); 1061 result.data = { success: true, result: evalResult }; 1062 } catch (e) { 1063 result.data = { success: false, error: e.message }; 1064 } 1065 break; 1066 1067 case 'reload': 1068 webview.reload(); 1069 result.data = { success: true }; 1070 break; 1071 1072 case 'screenshot': 1073 try { 1074 const nativeImage = await webview.capturePage(); 1075 result.data = { success: true, dataUrl: nativeImage.toDataURL() }; 1076 } catch (e) { 1077 result.data = { success: false, error: e.message }; 1078 } 1079 break; 1080 1081 case 'set-env': 1082 if (params.env === 'local' || params.env === 'prod') { 1083 setEnv(params.env); 1084 result.data = { success: true, env: params.env }; 1085 } else { 1086 result.error = 'Invalid env'; 1087 } 1088 break; 1089 1090 case 'set-mode': 1091 if (params.mode === 'shell' || params.mode === 'app') { 1092 setMode(params.mode); 1093 result.data = { success: true, mode: params.mode }; 1094 } else { 1095 result.error = 'Invalid mode'; 1096 } 1097 break; 1098 1099 case 'enable-console': 1100 // Console capture - could forward to a log file 1101 result.data = { success: true }; 1102 break; 1103 1104 default: 1105 result.error = `Unknown command: ${cmd}`; 1106 } 1107 } catch (e) { 1108 result.error = e.message; 1109 } 1110 1111 // Write result 1112 writeFileSync(RESULT_FILE, JSON.stringify(result, null, 2)); 1113 } 1114 1115 function checkBridgeCommand() { 1116 try { 1117 if (existsSync(CMD_FILE)) { 1118 const content = readFileSync(CMD_FILE, 'utf8'); 1119 const request = JSON.parse(content); 1120 if (request.id !== lastProcessedId) { 1121 processCommand(request); 1122 } 1123 } 1124 } catch (e) { 1125 // Ignore read errors 1126 } 1127 } 1128 1129 function initBridge() { 1130 try { 1131 // Ensure bridge directory exists 1132 if (!existsSync(BRIDGE_DIR)) { 1133 mkdirSync(BRIDGE_DIR, { recursive: true }); 1134 } 1135 1136 // Poll for commands (file watching can be unreliable across Docker) 1137 setInterval(checkBridgeCommand, 100); 1138 1139 console.log('[dev] File bridge initialized at', BRIDGE_DIR); 1140 } catch (e) { 1141 console.warn('[dev] Could not init file bridge:', e.message); 1142 } 1143 } 1144 1145 // ========== Init ========== 1146 setEnv('prod'); // Start with production - loads /starfield 1147 initShell(); 1148 initBridge(); 1149 1150 // Ensure webview loads immediately 1151 setTimeout(() => { 1152 if (webview.src === 'about:blank') { 1153 webview.src = acUrl('starfield'); 1154 } 1155 }, 100); 1156 1157 // ========== Alt + Scroll to Drag Window ========== 1158 document.addEventListener('wheel', (e) => { 1159 // Alt + scroll (two-finger drag with alt) moves the window 1160 if (e.altKey) { 1161 e.preventDefault(); 1162 const currentX = window.screenX; 1163 const currentY = window.screenY; 1164 const newX = currentX - e.deltaX; 1165 const newY = currentY - e.deltaY; 1166 ipcRenderer.send('move-window', { x: Math.round(newX), y: Math.round(newY) }); 1167 } 1168 }, { passive: false }); 1169 </script> 1170</body> 1171</html>