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