Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

wifi: add log ring buffer visible from JS + log view in wifi piece

- Add wifi_log() helper that writes to both ac_log (stderr/USB log)
and a 32-entry ring buffer on the ACWifi struct
- Replace ac_log calls in connect/scan/autoconnect/disconnect/reconnect
with wifi_log so all wifi activity is captured in the ring buffer
- Expose wifi.logs array in JS bindings (chronological order)
- Add log view to wifi.mjs (press 'l' to toggle fullscreen log panel)
with color-coded entries: red=errors, green=success, yellow=WPA/DHCP
- Logs also persist to /mnt/ac-native.log for offline diagnosis

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+617 -3740
+467
fedac/native/pieces/wifi.mjs
··· 1 + // wifi.mjs — AC Native WiFi management piece 2 + // Shows available networks, connects, manages saved credentials. 3 + // Jumped to from prompt.mjs via "net" or "wifi" command. 4 + // Respects __theme (dark/light mode + presets). 5 + 6 + const AC_SSID = "aesthetic.computer"; 7 + const AC_PASS = "aesthetic.computer"; 8 + const CREDS_PATH = "/mnt/wifi_creds.json"; 9 + const SHIFT_MAP = { 10 + "1":"!","2":"@","3":"#","4":"$","5":"%","6":"^","7":"&","8":"*","9":"(","0":")", 11 + "-":"_","=":"+","[":"{","]":"}",";":":","'":'"',",":"<",".":">","/":"?","\\":"|","`":"~", 12 + }; 13 + 14 + let selectedIdx = -1; 15 + let password = ""; 16 + let passwordMode = false; 17 + let shiftHeld = false; 18 + let cursorBlink = 0; 19 + let savedCreds = []; 20 + let frame = 0; 21 + let connectingSSID = ""; // track which SSID we're connecting to 22 + let lastState = 0; // track state changes for feedback 23 + let showLogs = false; // toggle with 'l' key 24 + let logScroll = 0; // scroll offset for log view 25 + 26 + function boot({ system }) { 27 + // Load saved credentials 28 + try { 29 + const raw = system?.readFile?.(CREDS_PATH); 30 + if (raw) savedCreds = JSON.parse(raw); 31 + } catch (_) {} 32 + } 33 + 34 + function act({ event: e, sound, system, wifi }) { 35 + if (!e.is("touch") && !e.is("keyboard:down") && !e.is("keyboard:up")) return; 36 + 37 + // Shift tracking 38 + if (e.is("keyboard:down:shift")) { shiftHeld = true; return; } 39 + if (e.is("keyboard:up:shift")) { shiftHeld = false; return; } 40 + 41 + // Password input mode 42 + if (passwordMode) { 43 + if (e.is("keyboard:down:escape")) { 44 + passwordMode = false; 45 + password = ""; 46 + return; 47 + } 48 + if (e.is("keyboard:down:enter")) { 49 + const merged = globalThis.__wifiMergedList || []; 50 + const entry = merged[selectedIdx]; 51 + if (entry && password.length > 0) { 52 + wifi?.connect?.(entry.ssid, password); 53 + connectingSSID = entry.ssid; 54 + // Save credentials 55 + if (!savedCreds.find(c => c.ssid === entry.ssid)) { 56 + savedCreds.push({ ssid: entry.ssid, pass: password }); 57 + system?.writeFile?.(CREDS_PATH, JSON.stringify(savedCreds)); 58 + } 59 + passwordMode = false; 60 + password = ""; 61 + } 62 + return; 63 + } 64 + if (e.is("keyboard:down:backspace")) { 65 + password = password.slice(0, -1); 66 + return; 67 + } 68 + if (e.is("keyboard:down")) { 69 + const key = e.key; 70 + if (key && key.length === 1) { 71 + const ch = shiftHeld ? (SHIFT_MAP[key] || key.toUpperCase()) : key; 72 + password += ch; 73 + } 74 + return; 75 + } 76 + return; // Block all other events in password mode 77 + } 78 + 79 + // Escape goes back to prompt 80 + if (e.is("keyboard:down:escape") || e.is("keyboard:down:backspace")) { 81 + if (showLogs) { showLogs = false; return; } 82 + system?.jump?.("prompt"); 83 + return; 84 + } 85 + 86 + // Toggle log view 87 + if (e.is("keyboard:down:l") && !passwordMode) { 88 + showLogs = !showLogs; 89 + logScroll = 0; 90 + return; 91 + } 92 + 93 + const merged = globalThis.__wifiMergedList || []; 94 + 95 + // Keyboard navigation 96 + if (e.is("keyboard:down:arrowdown")) { 97 + let next = selectedIdx + 1; 98 + while (next < merged.length && merged[next].type === "separator") next++; 99 + if (next < merged.length) { 100 + selectedIdx = next; 101 + sound?.synth({ type: "sine", tone: 440, duration: 0.03, volume: 0.08, attack: 0.002, decay: 0.02 }); 102 + } 103 + return; 104 + } 105 + if (e.is("keyboard:down:arrowup")) { 106 + let prev = selectedIdx - 1; 107 + while (prev >= 0 && merged[prev].type === "separator") prev--; 108 + if (prev >= 0) { 109 + selectedIdx = prev; 110 + sound?.synth({ type: "sine", tone: 440, duration: 0.03, volume: 0.08, attack: 0.002, decay: 0.02 }); 111 + } 112 + return; 113 + } 114 + if (e.is("keyboard:down:enter")) { 115 + if (selectedIdx >= 0 && selectedIdx < merged.length) { 116 + connectEntry(merged[selectedIdx], wifi, sound, system); 117 + } 118 + return; 119 + } 120 + 121 + // Touch: select or connect to network 122 + if (e.is("touch")) { 123 + const { x, y } = e; 124 + const rowH = 16; 125 + const listY = 44; 126 + 127 + const tappedRow = Math.floor((y - listY) / rowH); 128 + if (tappedRow >= 0 && tappedRow < merged.length) { 129 + const entry = merged[tappedRow]; 130 + if (entry.type === "separator") return; 131 + 132 + if (tappedRow === selectedIdx) { 133 + // Second tap: connect 134 + connectEntry(entry, wifi, sound, system); 135 + } else { 136 + selectedIdx = tappedRow; 137 + sound?.synth({ type: "sine", tone: 440, duration: 0.03, volume: 0.08, attack: 0.002, decay: 0.02 }); 138 + } 139 + } 140 + } 141 + } 142 + 143 + function connectEntry(entry, wifi, sound, system) { 144 + const allSaved = [ 145 + { ssid: AC_SSID, pass: AC_PASS }, 146 + ...savedCreds, 147 + ]; 148 + const cred = allSaved.find(c => c.ssid === entry.ssid); 149 + 150 + if (cred) { 151 + wifi?.connect?.(cred.ssid, cred.pass); 152 + connectingSSID = cred.ssid; 153 + sound?.synth({ type: "sine", tone: 660, duration: 0.08, volume: 0.15, attack: 0.005, decay: 0.06 }); 154 + } else if (entry.encrypted) { 155 + passwordMode = true; 156 + password = ""; 157 + cursorBlink = 0; 158 + } else { 159 + wifi?.connect?.(entry.ssid, ""); 160 + connectingSSID = entry.ssid; 161 + sound?.synth({ type: "sine", tone: 660, duration: 0.08, volume: 0.15, attack: 0.005, decay: 0.06 }); 162 + } 163 + } 164 + 165 + function paint({ wipe, ink, box, write, screen, system, wifi, sound }) { 166 + frame++; 167 + const T = __theme.update(); 168 + const w = screen.width, h = screen.height; 169 + 170 + // Play sound on state transitions 171 + if (wifi && wifi.state !== lastState) { 172 + if (wifi.state === 4 && lastState === 3) { // CONNECTED after CONNECTING 173 + sound?.synth({ type: "sine", tone: 880, duration: 0.15, volume: 0.12, attack: 0.005, decay: 0.1 }); 174 + } else if (wifi.state === 5 && lastState === 3) { // FAILED after CONNECTING 175 + sound?.synth({ type: "sine", tone: 220, duration: 0.2, volume: 0.12, attack: 0.005, decay: 0.15 }); 176 + } 177 + lastState = wifi ? wifi.state : 0; 178 + } 179 + 180 + // Password entry fullscreen 181 + if (passwordMode && wifi) { 182 + cursorBlink++; 183 + const merged = globalThis.__wifiMergedList || []; 184 + const entry = merged[selectedIdx]; 185 + const ssid = entry ? entry.ssid : "?"; 186 + 187 + wipe(T.bg[0], T.bg[1], T.bg[2]); 188 + 189 + ink(T.fg, T.fg, T.fg); 190 + const titleX = Math.max(10, (w - ssid.length * 18) / 2); 191 + write(ssid, { x: titleX, y: h / 2 - 50, size: 2, font: "matrix" }); 192 + 193 + ink(T.fgDim, T.fgDim, T.fgDim); 194 + write("enter password:", { x: 20, y: h / 2 - 24, size: 2, font: "font_1" }); 195 + 196 + // Password field 197 + ink(T.bgAlt[0], T.bgAlt[1], T.bgAlt[2]); 198 + box(18, h / 2 - 6, w - 36, 18, true); 199 + ink(T.border[0], T.border[1], T.border[2]); 200 + box(18, h / 2 - 6, w - 36, 18, "outline"); 201 + 202 + const cursor = (cursorBlink % 60) < 35 ? "|" : ""; 203 + ink(T.fg, T.fg, T.fg); 204 + write(password + cursor, { x: 22, y: h / 2 - 2, size: 1, font: "font_1" }); 205 + 206 + ink(T.fgMute, T.fgMute, T.fgMute); 207 + write("Enter: connect Esc: cancel", { x: 20, y: h / 2 + 22, size: 1, font: "font_1" }); 208 + 209 + if (wifi.state === 3) { 210 + ink(T.warn[0], T.warn[1], T.warn[2]); 211 + write("connecting...", { x: 20, y: h / 2 + 40, size: 1, font: "font_1" }); 212 + } else if (wifi.state === 5) { 213 + ink(T.err[0], T.err[1], T.err[2]); 214 + write("failed: " + (wifi.status || "?"), { x: 20, y: h / 2 + 40, size: 1, font: "font_1" }); 215 + } 216 + return; 217 + } 218 + 219 + wipe(T.bg[0], T.bg[1], T.bg[2]); 220 + 221 + // Title 222 + ink(T.fg, T.fg, T.fg); 223 + write("WiFi Networks", { x: 20, y: 12, size: 2, font: "matrix" }); 224 + 225 + // Status line 226 + ink(T.fgDim, T.fgDim, T.fgDim); 227 + const statusStr = wifi ? ((wifi.status || "scanning...") + (wifi.iface ? " [" + wifi.iface + "]" : "")) : "no wifi"; 228 + write(statusStr, { x: 20, y: 30, size: 1, font: "font_1" }); 229 + 230 + if (!wifi) { 231 + ink(T.err[0], T.err[1], T.err[2]); 232 + write("wifi not available", { x: 20, y: 60, size: 1, font: "font_1" }); 233 + ink(T.fgMute, T.fgMute, T.fgMute); 234 + write("esc: back", { x: 10, y: h - 12, size: 1, font: "font_1" }); 235 + return; 236 + } 237 + 238 + // Build merged network list 239 + const rawNets = wifi.networks || []; 240 + const ssidBest = new Map(); 241 + for (const n of rawNets) { 242 + if (!n.ssid) continue; 243 + const prev = ssidBest.get(n.ssid); 244 + if (!prev || n.signal > prev.signal) ssidBest.set(n.ssid, n); 245 + } 246 + const scannedNets = [...ssidBest.values()].sort((a, b) => b.signal - a.signal); 247 + const scannedSSIDs = new Set(scannedNets.map(n => n.ssid)); 248 + const allSaved = [ 249 + { ssid: AC_SSID, pass: AC_PASS }, 250 + ...savedCreds.filter(c => c.ssid !== AC_SSID), 251 + ]; 252 + const offlineSaved = allSaved.filter(c => !scannedSSIDs.has(c.ssid)); 253 + 254 + const rowH = 16; 255 + const listY = 44; 256 + const totalRows = scannedNets.length + (offlineSaved.length > 0 ? 1 + offlineSaved.length : 0); 257 + const maxRows = Math.min(totalRows, Math.floor((h - listY - 60) / rowH)); 258 + globalThis.__wifiMergedList = []; 259 + 260 + let row = 0; 261 + 262 + // Scanned networks 263 + for (let i = 0; i < scannedNets.length && row < maxRows; i++, row++) { 264 + const net = scannedNets[i]; 265 + const ry = listY + row * rowH; 266 + const isSaved = allSaved.find(c => c.ssid === net.ssid); 267 + const isSelected = row === selectedIdx; 268 + const isConnecting = wifi.state === 3 && connectingSSID === net.ssid; 269 + const isConnected = wifi.connected && wifi.ssid === net.ssid; 270 + 271 + if (isSelected) { 272 + ink(T.bar[0], T.bar[1], T.bar[2]); 273 + box(10, ry, w - 20, rowH, true); 274 + } 275 + 276 + // Signal bars 277 + const bars = net.signal > -50 ? 4 : net.signal > -60 ? 3 : net.signal > -70 ? 2 : 1; 278 + for (let b = 0; b < 4; b++) { 279 + if (b < bars) ink(T.ok[0], T.ok[1], T.ok[2]); 280 + else ink(T.bgDim[0], T.bgDim[1], T.bgDim[2]); 281 + box(16 + b * 4, ry + 10 - (b + 1) * 2, 3, (b + 1) * 2, true); 282 + } 283 + 284 + // SSID 285 + if (isConnected) ink(T.ok[0], T.ok[1], T.ok[2]); 286 + else if (isSaved) ink(T.ok[0], T.ok[1], T.ok[2]); 287 + else ink(T.fg, T.fg, T.fg); 288 + const ssidDisplay = net.ssid.length > 26 ? net.ssid.slice(0, 25) + "~" : net.ssid; 289 + write(ssidDisplay, { x: 36, y: ry + 2, size: 1, font: "font_1" }); 290 + 291 + // Right side status 292 + if (isConnected) { 293 + ink(T.ok[0], T.ok[1], T.ok[2]); 294 + write("connected", { x: w - 60, y: ry + 2, size: 1, font: "font_1" }); 295 + } else if (isConnecting) { 296 + const dots = ".".repeat((Math.floor(frame / 15) % 3) + 1); 297 + ink(T.warn[0], T.warn[1], T.warn[2]); 298 + write("connecting" + dots, { x: w - 72, y: ry + 2, size: 1, font: "font_1" }); 299 + } else if (isSelected) { 300 + ink(T.link[0], T.link[1], T.link[2]); 301 + write("connect", { x: w - 52, y: ry + 2, size: 1, font: "font_1" }); 302 + } else if (isSaved) { 303 + ink(T.fgMute, T.fgMute, T.fgMute); 304 + write("saved", { x: w - 40, y: ry + 2, size: 1, font: "font_1" }); 305 + } else if (net.encrypted) { 306 + ink(T.fgMute, T.fgMute, T.fgMute); 307 + write("*", { x: w - 20, y: ry + 2, size: 1, font: "font_1" }); 308 + } 309 + 310 + globalThis.__wifiMergedList.push({ type: "scan", idx: i, ssid: net.ssid, encrypted: net.encrypted }); 311 + } 312 + 313 + // Saved/preset networks not in scan 314 + if (offlineSaved.length > 0 && row < maxRows) { 315 + const sepY = listY + row * rowH; 316 + ink(T.fgMute, T.fgMute, T.fgMute); 317 + write("-- saved (not in range) --", { x: 20, y: sepY + 2, size: 1, font: "font_1" }); 318 + globalThis.__wifiMergedList.push({ type: "separator" }); 319 + row++; 320 + 321 + for (let i = 0; i < offlineSaved.length && row < maxRows; i++, row++) { 322 + const cred = offlineSaved[i]; 323 + const ry = listY + row * rowH; 324 + const isPreset = cred.ssid === AC_SSID; 325 + const isSelected = row === selectedIdx; 326 + 327 + if (isSelected) { 328 + ink(T.bar[0], T.bar[1], T.bar[2]); 329 + box(10, ry, w - 20, rowH, true); 330 + } 331 + 332 + for (let b = 0; b < 4; b++) { 333 + ink(T.bgDim[0], T.bgDim[1], T.bgDim[2]); 334 + box(16 + b * 4, ry + 10 - (b + 1) * 2, 3, (b + 1) * 2, true); 335 + } 336 + 337 + ink(T.fgDim, T.fgDim, T.fgDim); 338 + write(cred.ssid, { x: 36, y: ry + 2, size: 1, font: "font_1" }); 339 + 340 + if (isSelected) { 341 + ink(T.link[0], T.link[1], T.link[2]); 342 + write("connect", { x: w - 52, y: ry + 2, size: 1, font: "font_1" }); 343 + } else if (isPreset) { 344 + ink(T.fgMute, T.fgMute, T.fgMute); 345 + write("preset", { x: w - 44, y: ry + 2, size: 1, font: "font_1" }); 346 + } else { 347 + ink(T.fgMute, T.fgMute, T.fgMute); 348 + write("saved", { x: w - 40, y: ry + 2, size: 1, font: "font_1" }); 349 + } 350 + 351 + globalThis.__wifiMergedList.push({ type: "saved", ssid: cred.ssid, pass: cred.pass }); 352 + } 353 + } 354 + 355 + // Connection status panel (bottom area) 356 + const panelY = h - 52; 357 + 358 + if (wifi.connected && wifi.ip) { 359 + // Connected: show SSID, IP, signal, SSH 360 + const okBg = T.dark ? [20, 40, 20] : [220, 245, 220]; 361 + const okBorder = T.dark ? [40, 60, 40] : [180, 220, 180]; 362 + ink(okBg[0], okBg[1], okBg[2]); 363 + box(10, panelY, w - 20, 40, true); 364 + ink(okBorder[0], okBorder[1], okBorder[2]); 365 + box(10, panelY, w - 20, 40, "outline"); 366 + 367 + ink(T.ok[0], T.ok[1], T.ok[2]); 368 + write(wifi.ssid || "connected", { x: 16, y: panelY + 4, size: 1, font: "font_1" }); 369 + 370 + ink(T.fg, T.fg, T.fg); 371 + write(wifi.ip, { x: 16, y: panelY + 16, size: 1, font: "font_1" }); 372 + 373 + // Signal strength 374 + if (wifi.signal) { 375 + const sig = wifi.signal; 376 + const qual = sig > -50 ? "excellent" : sig > -60 ? "good" : sig > -70 ? "fair" : "weak"; 377 + ink(T.fgDim, T.fgDim, T.fgDim); 378 + write(sig + " dBm (" + qual + ")", { x: w / 2, y: panelY + 4, size: 1, font: "font_1" }); 379 + } 380 + 381 + if (system?.sshStarted) { 382 + ink(T.link[0], T.link[1], T.link[2]); 383 + write("ssh root@" + wifi.ip, { x: w / 2, y: panelY + 16, size: 1, font: "font_1" }); 384 + } 385 + } else if (wifi.state === 3) { 386 + // Connecting 387 + const dots = ".".repeat((Math.floor(frame / 15) % 3) + 1); 388 + const warnBg = T.dark ? [30, 30, 15] : [255, 250, 220]; 389 + ink(warnBg[0], warnBg[1], warnBg[2]); 390 + box(10, panelY, w - 20, 40, true); 391 + ink(T.warn[0], T.warn[1], T.warn[2]); 392 + write("connecting" + dots, { x: 16, y: panelY + 4, size: 1, font: "font_1" }); 393 + ink(T.fgDim, T.fgDim, T.fgDim); 394 + write((connectingSSID || wifi.status || ""), { x: 16, y: panelY + 16, size: 1, font: "font_1" }); 395 + } else if (wifi.state === 5) { 396 + // Failed 397 + const errBg = T.dark ? [40, 15, 15] : [255, 230, 230]; 398 + ink(errBg[0], errBg[1], errBg[2]); 399 + box(10, panelY, w - 20, 40, true); 400 + ink(T.err[0], T.err[1], T.err[2]); 401 + write("failed", { x: 16, y: panelY + 4, size: 1, font: "font_1" }); 402 + ink(T.fgDim, T.fgDim, T.fgDim); 403 + write((connectingSSID ? connectingSSID + ": " : "") + (wifi.status || "unknown error"), { x: 16, y: panelY + 16, size: 1, font: "font_1" }); 404 + } else if (wifi.state === 1) { 405 + // Scanning 406 + const dots = ".".repeat((Math.floor(frame / 20) % 3) + 1); 407 + ink(T.bgAlt[0], T.bgAlt[1], T.bgAlt[2]); 408 + box(10, panelY, w - 20, 40, true); 409 + ink(T.fgDim, T.fgDim, T.fgDim); 410 + write("scanning" + dots, { x: 16, y: panelY + 12, size: 1, font: "font_1" }); 411 + } 412 + 413 + // Log view (fullscreen overlay, toggle with 'l') 414 + if (showLogs && wifi) { 415 + const logs = wifi.logs || []; 416 + ink(T.bg[0], T.bg[1], T.bg[2]); 417 + box(0, 0, w, h, true); 418 + 419 + ink(T.fg, T.fg, T.fg); 420 + write("WiFi Logs", { x: 20, y: 8, size: 2, font: "matrix" }); 421 + ink(T.fgMute, T.fgMute, T.fgMute); 422 + write(logs.length + " entries", { x: w - 70, y: 12, size: 1, font: "font_1" }); 423 + 424 + const logY = 28; 425 + const lineH = 10; 426 + const maxLines = Math.floor((h - logY - 16) / lineH); 427 + const start = Math.max(0, logs.length - maxLines - logScroll); 428 + const end = Math.min(logs.length, start + maxLines); 429 + 430 + for (let i = start; i < end; i++) { 431 + const ly = logY + (i - start) * lineH; 432 + const line = logs[i] || ""; 433 + // Color by content 434 + if (line.includes("fail") || line.includes("error") || line.includes("killed")) { 435 + ink(T.err[0], T.err[1], T.err[2]); 436 + } else if (line.includes("Connected") || line.includes("success")) { 437 + ink(T.ok[0], T.ok[1], T.ok[2]); 438 + } else if (line.includes("WPA:") || line.includes("DHCP")) { 439 + ink(T.warn[0], T.warn[1], T.warn[2]); 440 + } else { 441 + ink(T.fgDim, T.fgDim, T.fgDim); 442 + } 443 + write(line.slice(0, 60), { x: 8, y: ly, size: 1, font: "font_1" }); 444 + } 445 + 446 + ink(T.fgMute, T.fgMute, T.fgMute); 447 + write("l: close esc: back", { x: w - 120, y: h - 10, size: 1, font: "font_1" }); 448 + return; // skip normal rendering below 449 + } 450 + 451 + // Bottom hints 452 + ink(T.fgMute, T.fgMute, T.fgMute); 453 + write("l: logs esc: back", { x: w - 100, y: h - 10, size: 1, font: "font_1" }); 454 + if (wifi.iface) { 455 + ink(T.fgMute, T.fgMute, T.fgMute); 456 + write(wifi.iface, { x: 16, y: h - 10, size: 1, font: "font_1" }); 457 + } 458 + 459 + // Rescan every ~10s when not connected 460 + if (frame % 600 === 0 && !wifi.connected) { 461 + wifi.scan?.(); 462 + } 463 + } 464 + 465 + function sim() {} 466 + 467 + export { boot, paint, act, sim };
+12
fedac/native/src/js-bindings.c
··· 2110 2110 JS_SetPropertyUint32(ctx, networks, i, net); 2111 2111 } 2112 2112 JS_SetPropertyStr(ctx, obj, "networks", networks); 2113 + 2114 + // Log ring buffer (last 32 messages from wifi thread) 2115 + JSValue logs = JS_NewArray(ctx); 2116 + int total = wifi->log_count; 2117 + int count = total < 32 ? total : 32; 2118 + for (int i = 0; i < count; i++) { 2119 + // Read in chronological order (oldest first) 2120 + int idx = (total >= 32) ? ((total - 32 + i) % 32) : i; 2121 + JS_SetPropertyUint32(ctx, logs, i, 2122 + JS_NewString(ctx, wifi->log[idx])); 2123 + } 2124 + JS_SetPropertyStr(ctx, obj, "logs", logs); 2113 2125 } else { 2114 2126 extern int wifi_disabled; 2115 2127 JS_SetPropertyStr(ctx, obj, "state", JS_NewInt32(ctx, WIFI_STATE_OFF));
+41 -23
fedac/native/src/wifi.c
··· 9 9 #include <fcntl.h> 10 10 #include <errno.h> 11 11 #include <time.h> 12 + #include <stdarg.h> 12 13 13 14 // Defined in ac-native.c 14 15 extern void ac_log(const char *fmt, ...); 15 16 17 + // Write to both ac_log and the wifi ring buffer (readable from JS) 18 + static void wifi_log(ACWifi *wifi, const char *fmt, ...) __attribute__((format(printf, 2, 3))); 19 + static void wifi_log(ACWifi *wifi, const char *fmt, ...) { 20 + char buf[128]; 21 + va_list ap; 22 + va_start(ap, fmt); 23 + vsnprintf(buf, sizeof(buf), fmt, ap); 24 + va_end(ap); 25 + ac_log("[wifi] %s", buf); 26 + if (wifi) { 27 + int idx = wifi->log_count % 32; 28 + strncpy(wifi->log[idx], buf, 127); 29 + wifi->log[idx][127] = 0; 30 + wifi->log_count++; 31 + } 32 + } 33 + 16 34 // ============================================================ 17 35 // Helpers (called from wifi thread only — blocking is fine) 18 36 // ============================================================ ··· 178 196 snprintf(wifi->status_msg, sizeof(wifi->status_msg), "%d networks", w); 179 197 pthread_mutex_unlock(&wifi->lock); 180 198 181 - ac_log("[wifi] Scan complete: %d networks", w); 199 + wifi_log(wifi, "Scan complete: %d networks", w); 182 200 } 183 201 184 202 // ============================================================ ··· 189 207 if (!ssid || !wifi->iface[0]) return; 190 208 191 209 wifi_set_state_and_status(wifi, WIFI_STATE_CONNECTING, "connecting..."); 192 - ac_log("[wifi] Connecting to '%s'", ssid); 210 + wifi_log(wifi, "Connecting to '%s'", ssid); 193 211 194 212 // Kill any existing wpa_supplicant / dhclient 195 213 if (wifi->wpa_pid > 0) { ··· 233 251 snprintf(sock_path, sizeof(sock_path), 234 252 "/var/run/wpa_supplicant/%s", wifi->iface); 235 253 if (unlink(sock_path) == 0) 236 - ac_log("[wifi] Removed stale socket: %s", sock_path); 254 + wifi_log(wifi, "Removed stale socket: %s", sock_path); 237 255 } 238 256 239 257 // Start wpa_supplicant ··· 267 285 while (connect_ticks < 1200 && wifi->thread_running) { // ~60 seconds max (50ms polls) 268 286 // Check if a new command interrupted us 269 287 if (wifi->pending_cmd != WIFI_CMD_NONE) { 270 - ac_log("[wifi] Connect interrupted by new command"); 288 + wifi_log(wifi, "Connect interrupted by new command"); 271 289 return; 272 290 } 273 291 ··· 293 311 strncpy(state_str, st, sizeof(state_str) - 1); 294 312 state_str[strcspn(state_str, "\n\r")] = 0; 295 313 if (state_str[0] && strcmp(state_str, last_wpa_state) != 0) { 296 - ac_log("[wifi] WPA state: %s (tick %d)", state_str, connect_ticks); 314 + wifi_log(wifi, "WPA: %s (tick %d)", state_str, connect_ticks); 297 315 strncpy(last_wpa_state, state_str, sizeof(last_wpa_state) - 1); 298 316 // Update user-visible status with WPA state detail 299 317 if (strstr(state_str, "SCANNING")) ··· 310 328 if (strstr(line, "COMPLETED")) { 311 329 // WPA connected — start DHCP if not already running 312 330 if (!dhcp_started) { 313 - ac_log("[wifi] WPA connected, starting DHCP"); 331 + wifi_log(wifi, "WPA connected, starting DHCP"); 314 332 wifi_set_status(wifi, "getting IP..."); 315 333 316 334 pid_t dpid = fork(); ··· 357 375 wifi->state = WIFI_STATE_CONNECTED; 358 376 snprintf(wifi->status_msg, sizeof(wifi->status_msg), "%s", ip); 359 377 pthread_mutex_unlock(&wifi->lock); 360 - ac_log("[wifi] Connected! IP: %s", ip); 378 + wifi_log(wifi, "Connected! IP: %s", ip); 361 379 362 380 // Save credentials for auto-reconnect 363 381 strncpy(wifi->last_ssid, ssid, WIFI_SSID_MAX - 1); ··· 386 404 pid_t r = waitpid(wifi->dhcp_pid, &status, WNOHANG); 387 405 if (r > 0) { 388 406 if (WIFEXITED(status) && WEXITSTATUS(status) != 0) { 389 - ac_log("[wifi] dhclient exit %d", WEXITSTATUS(status)); 407 + wifi_log(wifi, "dhclient exit %d", WEXITSTATUS(status)); 390 408 FILE *dlog = fopen("/tmp/dhclient.log", "r"); 391 409 if (dlog) { 392 410 char dline[256]; 393 411 while (fgets(dline, sizeof(dline), dlog)) 394 - ac_log("[dhclient] %s", dline); 412 + wifi_log(wifi, "dhclient: %s", dline); 395 413 fclose(dlog); 396 414 } 397 415 // Retry DHCP on next iteration ··· 399 417 dhcp_started = 0; 400 418 } 401 419 if (WIFSIGNALED(status)) { 402 - ac_log("[wifi] dhclient killed by signal %d", WTERMSIG(status)); 420 + wifi_log(wifi, "dhclient killed by signal %d", WTERMSIG(status)); 403 421 wifi->dhcp_pid = 0; 404 422 dhcp_started = 0; 405 423 } ··· 409 427 // Still waiting for WPA auth 410 428 if (connect_ticks > 200) { // ~10 seconds (50ms polls) 411 429 wifi_set_state_and_status(wifi, WIFI_STATE_FAILED, "auth failed"); 412 - ac_log("[wifi] Connection timeout"); 430 + wifi_log(wifi, "Auth failed after %d ticks", connect_ticks); 413 431 return; 414 432 } 415 433 } ··· 418 436 // Timed out 419 437 if (wifi->state == WIFI_STATE_CONNECTING) { 420 438 wifi_set_state_and_status(wifi, WIFI_STATE_FAILED, "timeout"); 421 - ac_log("[wifi] Connect timed out after 60s"); 439 + wifi_log(wifi, "Connect timed out after 60s (last WPA: %s)", last_wpa_state); 422 440 } 423 441 } 424 442 ··· 452 470 snprintf(wifi->status_msg, sizeof(wifi->status_msg), "disconnected"); 453 471 pthread_mutex_unlock(&wifi->lock); 454 472 455 - ac_log("[wifi] Disconnected"); 473 + wifi_log(wifi, "Disconnected"); 456 474 } 457 475 458 476 // ============================================================ ··· 464 482 #define AC_PASS "aesthetic.computer" 465 483 466 484 static void wifi_do_autoconnect(ACWifi *wifi) { 467 - ac_log("[wifi] Auto-connect: scanning for saved networks..."); 485 + wifi_log(wifi, "Auto-connect: scanning..."); 468 486 469 487 // Step 1: Scan 470 488 wifi_do_scan(wifi); 471 489 if (wifi->network_count == 0) { 472 - ac_log("[wifi] Auto-connect: no networks found"); 490 + wifi_log(wifi, "Auto-connect: no networks found"); 473 491 wifi_set_state_and_status(wifi, WIFI_STATE_SCAN_DONE, "no networks"); 474 492 return; 475 493 } ··· 530 548 } 531 549 p = eq + 1; 532 550 } 533 - ac_log("[wifi] Auto-connect: loaded %d saved networks", cred_count); 551 + wifi_log(wifi, "Auto-connect: %d saved creds", cred_count); 534 552 } else { 535 - ac_log("[wifi] Auto-connect: no saved creds, using preset only"); 553 + wifi_log(wifi, "Auto-connect: no saved creds, preset only"); 536 554 } 537 555 538 556 // Step 3: Match scanned networks against saved creds (by signal strength) ··· 548 566 pass[WIFI_PASS_MAX - 1] = 0; 549 567 pthread_mutex_unlock(&wifi->lock); 550 568 551 - ac_log("[wifi] Auto-connect: trying '%s' (signal %d dBm)", 569 + wifi_log(wifi, "Trying '%s' (%d dBm)", 552 570 ssid, wifi->networks[i].signal); 553 571 wifi_do_connect(wifi, ssid, pass); 554 572 555 573 if (wifi->state == WIFI_STATE_CONNECTED) { 556 - ac_log("[wifi] Auto-connect: success!"); 574 + wifi_log(wifi, "Auto-connect: success!"); 557 575 return; 558 576 } 559 - ac_log("[wifi] Auto-connect: '%s' failed, trying next...", ssid); 577 + wifi_log(wifi, "'%s' failed, trying next...", ssid); 560 578 pthread_mutex_lock(&wifi->lock); 561 579 break; // Move to next scanned network 562 580 } ··· 564 582 } 565 583 pthread_mutex_unlock(&wifi->lock); 566 584 567 - ac_log("[wifi] Auto-connect: no saved network matched or connected"); 585 + wifi_log(wifi, "Auto-connect: no match"); 568 586 wifi_set_state_and_status(wifi, WIFI_STATE_SCAN_DONE, "no saved network"); 569 587 } 570 588 ··· 646 664 if (watchdog_counter >= 5) { 647 665 watchdog_counter = 0; 648 666 if (!wifi_check_link(wifi)) { 649 - ac_log("[wifi] Connection lost — auto-reconnecting to '%s'", 667 + wifi_log(wifi, "Connection lost — reconnecting '%s'", 650 668 wifi->last_ssid); 651 669 wifi_set_state_and_status(wifi, WIFI_STATE_CONNECTING, 652 670 "reconnecting..."); 653 671 wifi_do_connect(wifi, wifi->last_ssid, wifi->last_pass); 654 672 if (wifi->state != WIFI_STATE_CONNECTED) { 655 673 wifi->reconnect_failures++; 656 - ac_log("[wifi] Reconnect failed (%d)", 674 + wifi_log(wifi, "Reconnect failed (%d)", 657 675 wifi->reconnect_failures); 658 676 // Back off: wait longer between retries 659 677 if (wifi->reconnect_failures > 3)
+97
fedac/native/src/wifi.h
··· 1 + #ifndef AC_WIFI_H 2 + #define AC_WIFI_H 3 + 4 + #include <stdint.h> 5 + #include <sys/types.h> 6 + #include <pthread.h> 7 + 8 + #define WIFI_MAX_NETWORKS 32 9 + #define WIFI_SSID_MAX 33 // 32 chars + null 10 + #define WIFI_IFACE_MAX 32 11 + #define WIFI_PASS_MAX 128 12 + 13 + typedef enum { 14 + WIFI_STATE_OFF = 0, 15 + WIFI_STATE_SCANNING, 16 + WIFI_STATE_SCAN_DONE, 17 + WIFI_STATE_CONNECTING, 18 + WIFI_STATE_CONNECTED, 19 + WIFI_STATE_FAILED, 20 + } WiFiState; 21 + 22 + typedef enum { 23 + WIFI_CMD_NONE = 0, 24 + WIFI_CMD_SCAN, 25 + WIFI_CMD_CONNECT, 26 + WIFI_CMD_DISCONNECT, 27 + WIFI_CMD_AUTOCONNECT, 28 + } WiFiCommand; 29 + 30 + typedef struct { 31 + char ssid[WIFI_SSID_MAX]; 32 + int signal; // dBm (e.g. -45) 33 + int encrypted; // 1 if WPA/WPA2, 0 if open 34 + char bssid[18]; // "AA:BB:CC:DD:EE:FF" 35 + } WiFiNetwork; 36 + 37 + typedef struct { 38 + // --- Main-thread readable state (protected by lock) --- 39 + WiFiState state; 40 + WiFiNetwork networks[WIFI_MAX_NETWORKS]; 41 + int network_count; 42 + char connected_ssid[WIFI_SSID_MAX]; 43 + char ip_address[16]; // "192.168.1.X" 44 + int signal_strength; // current signal dBm 45 + char status_msg[64]; // human-readable status 46 + char iface[WIFI_IFACE_MAX]; // detected wireless interface name 47 + 48 + // --- Internal (thread-owned) --- 49 + pid_t wpa_pid; // wpa_supplicant process 50 + pid_t dhcp_pid; // dhclient process 51 + 52 + // Auto-reconnect state 53 + char last_ssid[WIFI_SSID_MAX]; // last successfully connected SSID 54 + char last_pass[WIFI_PASS_MAX]; // last password used 55 + int reconnect_failures; // consecutive reconnect failures 56 + 57 + // --- Threading --- 58 + pthread_t thread; 59 + pthread_mutex_t lock; 60 + pthread_cond_t cond; // signaled when a new command arrives 61 + volatile int thread_running; 62 + 63 + // Command queue (single slot — latest command wins) 64 + volatile WiFiCommand pending_cmd; 65 + char cmd_ssid[WIFI_SSID_MAX]; 66 + char cmd_pass[WIFI_PASS_MAX]; 67 + 68 + // Log ring buffer — readable from JS for diagnostics 69 + char log[32][128]; // last 32 log lines 70 + volatile int log_count; // total lines written (modulo 32 for ring index) 71 + } ACWifi; 72 + 73 + // Initialize WiFi subsystem (bring up interface, start worker thread) 74 + ACWifi *wifi_init(void); 75 + 76 + // Post async scan command (non-blocking, runs on wifi thread) 77 + void wifi_scan(ACWifi *wifi); 78 + 79 + // Post async connect command (non-blocking, runs on wifi thread) 80 + void wifi_connect(ACWifi *wifi, const char *ssid, const char *password); 81 + 82 + // Post async disconnect command (non-blocking, runs on wifi thread) 83 + void wifi_disconnect(ACWifi *wifi); 84 + 85 + // These are now no-ops — polling happens on the wifi thread internally. 86 + // Kept for API compatibility; main thread just reads wifi->state. 87 + int wifi_scan_poll(ACWifi *wifi); 88 + int wifi_connect_poll(ACWifi *wifi); 89 + 90 + // Auto-connect on boot: scan, match against saved creds, connect to best. 91 + // Non-blocking — posts scan+connect commands to the wifi thread. 92 + void wifi_autoconnect(ACWifi *wifi); 93 + 94 + // Stop thread and free 95 + void wifi_destroy(ACWifi *wifi); 96 + 97 + #endif
-3717
oven/server.mjs
··· 1 - #!/usr/bin/env node 2 - // Oven Server 3 - // Main Express server for the unified bake processing service 4 - 5 - import 'dotenv/config'; 6 - import express from 'express'; 7 - import https from 'https'; 8 - import http from 'http'; 9 - import fs from 'fs'; 10 - import { execSync } from 'child_process'; 11 - import { gunzipSync, gzipSync } from 'node:zlib'; 12 - import { WebSocketServer } from 'ws'; 13 - import { healthHandler, bakeHandler, statusHandler, bakeCompleteHandler, bakeStatusHandler, getActiveBakes, getIncomingBakes, getRecentBakes, subscribeToUpdates, cleanupStaleBakes } from './baker.mjs'; 14 - import { grabHandler, grabGetHandler, grabIPFSHandler, grabPiece, getCachedOrGenerate, getActiveGrabs, getRecentGrabs, getLatestKeepThumbnail, ensureLatestKeepThumbnail, getLatestIPFSUpload, getAllLatestIPFSUploads, setNotifyCallback, setLogCallback, cleanupStaleGrabs, clearAllActiveGrabs, getQueueStatus, getCurrentProgress, getAllProgress, getConcurrencyStatus, IPFS_GATEWAY, generateKidlispOGImage, getOGImageCacheStatus, getFrozenPieces, clearFrozenPiece, getLatestOGImageUrl, regenerateOGImagesBackground, generateKidlispBackdrop, getLatestBackdropUrl, APP_SCREENSHOT_PRESETS, generateNotepatOGImage, getLatestNotepatOGUrl, prewarmGrabBrowser } from './grabber.mjs'; 15 - import archiver from 'archiver'; 16 - import sharp from 'sharp'; 17 - import { createBundle, createJSPieceBundle, createM4DBundle, generateDeviceHTML, prewarmCache, getCacheStatus, setSkipMinification } from './bundler.mjs'; 18 - import { streamOSImage, getOSBuildStatus, invalidateManifest, purgeOSBuildCache, clearOSBuildLocalCache } from './os-builder.mjs'; 19 - import { startOSBaseBuild, getOSBaseBuild, getOSBaseBuildsSummary, cancelOSBaseBuild } from './os-base-build.mjs'; 20 - import { startNativeBuild, getNativeBuild, getNativeBuildsSummary, cancelNativeBuild } from './native-builder.mjs'; 21 - import { startPoller as startNativeGitPoller, getPollerStatus as getNativePollerStatus } from './native-git-poller.mjs'; 22 - import { startPapersBuild, getPapersBuild, getPapersBuildsSummary, cancelPapersBuild } from './papers-builder.mjs'; 23 - import { startPoller as startPapersGitPoller, getPollerStatus as getPapersPollerStatus } from './papers-git-poller.mjs'; 24 - import { join, dirname } from 'path'; 25 - import { fileURLToPath } from 'url'; 26 - 27 - const app = express(); 28 - const PORT = process.env.PORT || 3002; 29 - const dev = process.env.NODE_ENV === 'development'; 30 - 31 - // Track server start time for uptime display 32 - const SERVER_START_TIME = Date.now(); 33 - 34 - // Get git version at startup (from env var set during deploy, or try git) 35 - let GIT_VERSION = process.env.OVEN_VERSION || 'unknown'; 36 - if (GIT_VERSION === 'unknown') { 37 - try { 38 - GIT_VERSION = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim(); 39 - } catch (e) { 40 - // Not a git repo, that's fine 41 - } 42 - } 43 - console.log(`📦 Oven version: ${GIT_VERSION}`); 44 - console.log(`🕐 Server started at: ${new Date(SERVER_START_TIME).toISOString()}`); 45 - 46 - // Activity log buffer for streaming to clients 47 - const activityLogBuffer = []; 48 - const MAX_ACTIVITY_LOG = 100; 49 - let wss = null; // Will be set after server starts 50 - 51 - function addServerLog(type, icon, msg) { 52 - const entry = { time: new Date().toISOString(), type, icon, msg }; 53 - activityLogBuffer.unshift(entry); 54 - if (activityLogBuffer.length > MAX_ACTIVITY_LOG) { 55 - activityLogBuffer.pop(); 56 - } 57 - // Broadcast to connected clients if wss exists and has clients 58 - if (wss && wss.clients) { 59 - const logMsg = JSON.stringify({ logEntry: entry }); 60 - wss.clients.forEach(client => { 61 - if (client.readyState === 1) client.send(logMsg); 62 - }); 63 - } 64 - } 65 - 66 - // Export for use in other modules 67 - export { addServerLog }; 68 - 69 - // Log server startup 70 - addServerLog('info', '🔥', 'Oven server starting...'); 71 - 72 - // OS base-build admin key auth 73 - // Accepts either OS_BUILD_ADMIN_KEY directly in env, or OS_BUILD_ADMIN_KEY_FILE. 74 - let cachedOSBuildAdminKey = null; 75 - let cachedOSBuildAdminMtimeMs = null; 76 - 77 - function getConfiguredOSBuildAdminKey() { 78 - const envKey = (process.env.OS_BUILD_ADMIN_KEY || '').trim(); 79 - if (envKey) return envKey; 80 - 81 - const keyFile = (process.env.OS_BUILD_ADMIN_KEY_FILE || '').trim(); 82 - if (!keyFile) return ''; 83 - 84 - try { 85 - const stat = fs.statSync(keyFile); 86 - if (cachedOSBuildAdminKey && cachedOSBuildAdminMtimeMs === stat.mtimeMs) { 87 - return cachedOSBuildAdminKey; 88 - } 89 - const nextKey = fs.readFileSync(keyFile, 'utf8').trim(); 90 - cachedOSBuildAdminKey = nextKey; 91 - cachedOSBuildAdminMtimeMs = stat.mtimeMs; 92 - return nextKey; 93 - } catch { 94 - return ''; 95 - } 96 - } 97 - 98 - function getOSBuildRequestKey(req) { 99 - const headerKey = (req.get('x-oven-os-key') || '').trim(); 100 - if (headerKey) return headerKey; 101 - const auth = (req.get('authorization') || '').trim(); 102 - if (auth.startsWith('Bearer ')) return auth.slice(7).trim(); 103 - return ''; 104 - } 105 - 106 - function requireOSBuildAdmin(req, res, next) { 107 - const expectedKey = getConfiguredOSBuildAdminKey(); 108 - if (!expectedKey) { 109 - return res.status(503).json({ 110 - error: 'OS build admin key not configured. Set OS_BUILD_ADMIN_KEY or OS_BUILD_ADMIN_KEY_FILE.', 111 - }); 112 - } 113 - 114 - const providedKey = getOSBuildRequestKey(req); 115 - if (!providedKey || providedKey !== expectedKey) { 116 - return res.status(401).json({ error: 'Unauthorized' }); 117 - } 118 - 119 - return next(); 120 - } 121 - 122 - // ===== SHARED PROGRESS UI COMPONENTS ===== 123 - // Shared CSS for progress indicators across all oven dashboards 124 - const PROGRESS_UI_CSS = ` 125 - /* Oven Progress UI - shared across all dashboards */ 126 - .oven-loading { 127 - position: absolute; 128 - inset: 0; 129 - display: flex; 130 - flex-direction: column; 131 - align-items: center; 132 - justify-content: center; 133 - background: rgba(0,0,0,0.85); 134 - color: #888; 135 - text-align: center; 136 - padding: 10px; 137 - z-index: 10; 138 - } 139 - .oven-loading .preview-img { 140 - width: 80px; 141 - height: 80px; 142 - image-rendering: pixelated; 143 - border: 1px solid #333; 144 - margin-bottom: 8px; 145 - display: none; 146 - background: #111; 147 - } 148 - .oven-loading .loading-text { 149 - font-size: 12px; 150 - color: #fff; 151 - } 152 - .oven-loading .progress-text { 153 - font-size: 11px; 154 - margin-top: 8px; 155 - color: #88ff88; 156 - font-family: monospace; 157 - max-width: 150px; 158 - word-break: break-word; 159 - } 160 - .oven-loading .progress-bar { 161 - width: 80%; 162 - max-width: 150px; 163 - height: 4px; 164 - background: #333; 165 - border-radius: 2px; 166 - margin: 8px auto 0; 167 - overflow: hidden; 168 - } 169 - .oven-loading .progress-bar-fill { 170 - height: 100%; 171 - background: #88ff88; 172 - width: 0%; 173 - transition: width 0.3s ease; 174 - } 175 - .oven-loading.error { 176 - color: #f44; 177 - } 178 - .oven-loading.success { 179 - color: #4f4; 180 - } 181 - `; 182 - 183 - // Shared JavaScript for progress polling and UI updates 184 - const PROGRESS_UI_JS = ` 185 - // Shared progress state 186 - let progressPollInterval = null; 187 - 188 - // Update any loading indicator with progress data 189 - function updateOvenLoadingUI(container, data, queueInfo) { 190 - if (!container) return; 191 - 192 - const loadingText = container.querySelector('.loading-text'); 193 - const progressText = container.querySelector('.progress-text'); 194 - const progressBar = container.querySelector('.progress-bar-fill'); 195 - const previewImg = container.querySelector('.preview-img'); 196 - 197 - // Check if item is in queue and get position 198 - let queuePosition = null; 199 - if (queueInfo && queueInfo.length > 0 && data.piece) { 200 - const queueItem = queueInfo.find(q => q.piece === data.piece); 201 - if (queueItem) { 202 - queuePosition = queueItem.position; 203 - } 204 - } 205 - 206 - // Map stage to friendly text 207 - const stageText = { 208 - 'loading': '🚀 Loading piece...', 209 - 'waiting-content': '⏳ Waiting for render...', 210 - 'settling': '⏸️ Settling...', 211 - 'capturing': '📸 Capturing...', 212 - 'encoding': '🔄 Processing...', 213 - 'uploading': '☁️ Uploading...', 214 - 'queued': queuePosition ? '⏳ In queue (#' + queuePosition + ')...' : '⏳ In queue...', 215 - }; 216 - 217 - if (loadingText && data.stage) { 218 - loadingText.textContent = stageText[data.stage] || data.stage; 219 - } 220 - if (progressText && data.stageDetail) { 221 - progressText.textContent = data.stageDetail; 222 - } 223 - if (progressBar && data.percent != null) { 224 - progressBar.style.width = data.percent + '%'; 225 - } 226 - // Show streaming preview 227 - if (previewImg && data.previewFrame) { 228 - previewImg.src = 'data:image/jpeg;base64,' + data.previewFrame; 229 - previewImg.style.display = 'block'; 230 - } 231 - } 232 - 233 - // Create loading HTML structure 234 - function createOvenLoadingHTML(initialText = '🔥 Loading...') { 235 - return '<img class="preview-img" alt="preview">' + 236 - '<span class="loading-text">' + initialText + '</span>' + 237 - '<div class="progress-text"></div>' + 238 - '<div class="progress-bar"><div class="progress-bar-fill"></div></div>'; 239 - } 240 - 241 - // Start polling /grab-status for progress updates 242 - function startProgressPolling(callback, intervalMs = 150) { 243 - stopProgressPolling(); 244 - progressPollInterval = setInterval(async () => { 245 - try { 246 - const res = await fetch('/grab-status'); 247 - const data = await res.json(); 248 - if (callback && data.progress) { 249 - callback(data); 250 - } 251 - } catch (err) { 252 - // Ignore polling errors 253 - } 254 - }, intervalMs); 255 - } 256 - 257 - function stopProgressPolling() { 258 - if (progressPollInterval) { 259 - clearInterval(progressPollInterval); 260 - progressPollInterval = null; 261 - } 262 - } 263 - `; 264 - 265 - // Parse JSON bodies 266 - app.use(express.json()); 267 - 268 - // CORS headers for cross-origin image loading (needed for canvas pixel validation) 269 - app.use((req, res, next) => { 270 - res.setHeader('Access-Control-Allow-Origin', '*'); 271 - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); 272 - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); 273 - if (req.method === 'OPTIONS') { 274 - return res.sendStatus(200); 275 - } 276 - next(); 277 - }); 278 - 279 - // Serve font glyph JSONs locally for Puppeteer captures. 280 - // Font_1 glyph XHR requests from the disk.mjs worker are redirected here 281 - // by the request interceptor to avoid Puppeteer's broken concurrent XHR handling. 282 - const __serverDirname = dirname(fileURLToPath(import.meta.url)); 283 - app.get('/local-glyph/*', (req, res) => { 284 - const glyphPath = req.params[0]; // Express auto-decodes URI params 285 - // Sanitize: only allow paths within ac-source/disks/drawings 286 - if (glyphPath.includes('..') || glyphPath.includes('\0')) { 287 - return res.status(400).send('Invalid path'); 288 - } 289 - const filePath = join(__serverDirname, 'ac-source', 'disks', 'drawings', glyphPath); 290 - res.sendFile(filePath, (err) => { 291 - if (err) res.status(404).json({ error: 'glyph not found' }); 292 - }); 293 - }); 294 - 295 - // Oven TV dashboard — live-updating visual bake monitor 296 - app.get('/', (req, res) => { 297 - res.setHeader('Content-Type', 'text/html'); 298 - res.send(OVEN_TV_HTML); 299 - }); 300 - 301 - const OVEN_TV_HTML = `<!DOCTYPE html> 302 - <html> 303 - <head> 304 - <meta charset="utf-8"> 305 - <title>oven</title> 306 - <meta name="viewport" content="width=device-width, initial-scale=1"> 307 - <link rel="icon" href="https://aesthetic.computer/icon/128x128/prompt.png" type="image/png"> 308 - <style> 309 - :root { 310 - --bg: #f7f7f7; 311 - --bg-deep: #ececec; 312 - --bg-card: #fff; 313 - --bg-hover: #f0f0f0; 314 - --text: #111; 315 - --text-secondary: #555; 316 - --text-muted: #888; 317 - --text-dim: #aaa; 318 - --border: #ddd; 319 - --border-subtle: #e8e8e8; 320 - --accent: rgb(205, 92, 155); 321 - --accent-hover: rgb(220, 110, 170); 322 - --success: #2a9a2a; 323 - --error: #c44; 324 - --preview-bg: #e0e0e0; 325 - --overlay-bg: rgba(255,255,255,0.92); 326 - --scrollbar: transparent; 327 - } 328 - @media (prefers-color-scheme: dark) { 329 - :root { 330 - --bg: #1e1e1e; 331 - --bg-deep: #161616; 332 - --bg-card: #252526; 333 - --bg-hover: #2a2a2a; 334 - --text: #d4d4d4; 335 - --text-secondary: #888; 336 - --text-muted: #666; 337 - --text-dim: #444; 338 - --border: #3e3e42; 339 - --border-subtle: #2e2e32; 340 - --accent: rgb(205, 92, 155); 341 - --accent-hover: rgb(225, 115, 175); 342 - --success: #4caf50; 343 - --error: #f44; 344 - --preview-bg: #111; 345 - --overlay-bg: rgba(0,0,0,0.88); 346 - } 347 - } 348 - 349 - * { box-sizing: border-box; margin: 0; padding: 0; } 350 - ::-webkit-scrollbar { display: none; } 351 - 352 - body { 353 - font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace; 354 - font-size: 15px; 355 - background: var(--bg); 356 - color: var(--text); 357 - height: 100vh; 358 - display: flex; 359 - flex-direction: column; 360 - overflow: hidden; 361 - } 362 - 363 - .status-bar { 364 - background: var(--bg-deep); 365 - border-bottom: 2px solid var(--accent); 366 - padding: 5px 12px; 367 - display: flex; 368 - align-items: center; 369 - justify-content: space-between; 370 - flex-shrink: 0; 371 - gap: 8px; 372 - } 373 - .status-bar .title { color: var(--accent); font-weight: bold; font-size: 1.1em; } 374 - .status-bar .stats { display: flex; gap: 12px; color: var(--text-muted); font-size: 0.95em; } 375 - .status-bar .stats span { white-space: nowrap; } 376 - .status-bar .stats .active { color: var(--success); } 377 - .status-bar .stats .queued { color: var(--accent); } 378 - .sb-btn { 379 - background: var(--bg-card); color: var(--text-secondary); border: 1px solid var(--border); 380 - padding: 3px 8px; cursor: pointer; font-family: inherit; font-size: 0.85em; 381 - border-radius: 3px; text-decoration: none; display: inline-block; 382 - } 383 - .sb-btn:hover { color: var(--text); border-color: var(--accent); } 384 - 385 - .hero { 386 - flex: 1; 387 - display: flex; 388 - align-items: center; 389 - justify-content: center; 390 - gap: 10px; 391 - padding: 10px; 392 - min-height: 0; 393 - overflow: hidden; 394 - } 395 - .hero.idle { color: var(--text-dim); font-size: 1.2em; } 396 - .hero-card { 397 - background: var(--bg-card); 398 - border: 2px solid var(--border); 399 - border-radius: 6px; 400 - display: flex; 401 - flex-direction: row; 402 - align-items: stretch; 403 - height: 90px; 404 - min-width: 200px; 405 - max-width: 320px; 406 - flex-shrink: 0; 407 - overflow: hidden; 408 - } 409 - .hero-card.capturing { border-color: var(--accent); } 410 - .hero-card .preview { 411 - width: 86px; 412 - min-width: 86px; 413 - background: var(--preview-bg); 414 - overflow: hidden; 415 - display: flex; 416 - align-items: center; 417 - justify-content: center; 418 - } 419 - .hero-card .preview img { 420 - width: 100%; 421 - height: 100%; 422 - object-fit: cover; 423 - image-rendering: pixelated; 424 - } 425 - .hero-card .preview .placeholder { color: var(--text-dim); font-size: 1.2em; } 426 - .hero-card .info { 427 - flex: 1; 428 - padding: 4px 6px; 429 - display: flex; 430 - flex-direction: column; 431 - justify-content: center; 432 - gap: 2px; 433 - min-width: 0; 434 - overflow: hidden; 435 - } 436 - .hero-card .piece-name { color: var(--accent); font-weight: bold; font-size: 0.95em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 437 - .hero-card .meta { color: var(--text-dim); font-size: 0.75em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.4; } 438 - .hero-card .meta .author { color: var(--text-secondary); } 439 - .hero-card .stage { color: var(--text-muted); font-size: 0.8em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.4; } 440 - .hero-card .progress-bar { 441 - width: 100%; 442 - height: 3px; 443 - background: var(--border); 444 - flex-shrink: 0; 445 - margin-top: auto; 446 - } 447 - .hero-card .progress-bar .fill { 448 - height: 100%; 449 - background: var(--accent); 450 - transition: width 0.3s ease; 451 - } 452 - 453 - .strip { 454 - background: var(--bg-deep); 455 - border-top: 1px solid var(--border); 456 - padding: 5px 12px; 457 - flex-shrink: 0; 458 - overflow: hidden; 459 - } 460 - .strip-label { 461 - color: var(--text-muted); 462 - font-size: 0.75em; 463 - text-transform: uppercase; 464 - letter-spacing: 1px; 465 - margin-bottom: 4px; 466 - } 467 - .strip-items { 468 - display: flex; 469 - gap: 6px; 470 - padding-bottom: 2px; 471 - white-space: nowrap; 472 - } 473 - .strip-items.train { 474 - animation: train-scroll 30s linear infinite; 475 - width: max-content; 476 - } 477 - @keyframes train-scroll { 478 - 0% { transform: translateX(0); } 479 - 100% { transform: translateX(-50%); } 480 - } 481 - .strip-item { 482 - background: var(--bg-card); 483 - border: 1px solid var(--border); 484 - border-radius: 3px; 485 - padding: 2px 8px; 486 - white-space: nowrap; 487 - font-size: 0.9em; 488 - flex-shrink: 0; 489 - } 490 - .strip-item.queue { color: var(--accent); } 491 - .strip-empty { color: var(--text-dim); font-size: 0.9em; padding: 2px 0; } 492 - 493 - .history { 494 - background: var(--bg-deep); 495 - border-top: 1px solid var(--border); 496 - flex: 1; 497 - min-height: 0; 498 - overflow-y: auto; 499 - padding: 0; 500 - } 501 - .history .strip-label { padding: 5px 12px 3px; } 502 - .history-row { 503 - display: flex; 504 - align-items: center; 505 - gap: 10px; 506 - padding: 5px 12px; 507 - border-bottom: 1px solid var(--border-subtle); 508 - } 509 - .history-row:hover { background: var(--bg-hover); } 510 - .history-row .h-thumb { 511 - width: 44px; 512 - height: 44px; 513 - min-width: 44px; 514 - border-radius: 3px; 515 - background: var(--bg-card); 516 - flex-shrink: 0; 517 - overflow: hidden; 518 - display: flex; 519 - align-items: center; 520 - justify-content: center; 521 - } 522 - .history-row .h-thumb img { 523 - width: 100%; 524 - height: 100%; 525 - object-fit: cover; 526 - image-rendering: pixelated; 527 - } 528 - .history-row .h-thumb .h-none { color: var(--text-dim); font-size: 1em; } 529 - .history-row .h-main { flex: 1; min-width: 0; } 530 - .history-row .h-piece { font-weight: bold; font-size: 0.9em; } 531 - .history-row .h-piece a { color: inherit; text-decoration: none; } 532 - .history-row .h-piece a:hover { text-decoration: underline; } 533 - .history-row .h-meta { color: var(--text-muted); font-size: 0.78em; margin-top: 2px; display: flex; gap: 10px; flex-wrap: wrap; } 534 - .history-row .h-meta span { white-space: nowrap; } 535 - .history-row .h-error { color: var(--error); font-size: 0.78em; margin-top: 2px; opacity: 0.8; } 536 - .history-row .h-links { margin-top: 2px; display: flex; gap: 8px; } 537 - .history-row .h-links a { color: var(--accent); font-size: 0.78em; text-decoration: none; } 538 - .history-row .h-links a:hover { text-decoration: underline; } 539 - .history-row .h-right { 540 - flex-shrink: 0; 541 - text-align: right; 542 - font-size: 0.8em; 543 - } 544 - .history-row .h-status-done { color: var(--success); } 545 - .history-row .h-status-failed { color: var(--error); } 546 - .history-row .h-status-other { color: var(--text-muted); } 547 - .history-row .h-ago { color: var(--text-dim); font-size: 0.85em; } 548 - 549 - @keyframes card-enter { 550 - from { opacity: 0; transform: translateX(-120px) scale(0.9); } 551 - to { opacity: 1; transform: translateX(0) scale(1); } 552 - } 553 - @keyframes card-exit { 554 - from { opacity: 1; transform: translateX(0) scale(1); } 555 - to { opacity: 0; transform: translateX(120px) scale(0.9); } 556 - } 557 - .hero-card { 558 - animation: card-enter 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards; 559 - } 560 - .hero-card.exiting { 561 - animation: card-exit 0.5s cubic-bezier(0.55, 0, 1, 0.45) forwards; 562 - pointer-events: none; 563 - } 564 - 565 - .capture-bar { display: none; } 566 - 567 - .log-overlay { 568 - display: none; 569 - position: fixed; 570 - top: 0; left: 0; right: 0; bottom: 0; 571 - background: var(--overlay-bg); 572 - z-index: 200; 573 - padding: 32px 16px 16px; 574 - overflow-y: auto; 575 - } 576 - .log-overlay.open { display: block; } 577 - .log-overlay .close { 578 - position: fixed; 579 - top: 6px; 580 - right: 12px; 581 - background: none; 582 - border: none; 583 - color: var(--accent); 584 - font-size: 1.3em; 585 - cursor: pointer; 586 - font-family: inherit; 587 - } 588 - .log-entry { 589 - padding: 1px 0; 590 - font-size: 0.82em; 591 - color: var(--text-secondary); 592 - white-space: nowrap; 593 - overflow: hidden; 594 - text-overflow: ellipsis; 595 - } 596 - .log-entry .time { color: var(--text-muted); } 597 - .log-entry.error { color: var(--error); } 598 - .log-entry.success { color: var(--success); } 599 - </style> 600 - </head> 601 - <body> 602 - 603 - <div class="status-bar"> 604 - <span class="title">oven</span> 605 - <div class="stats"> 606 - <span class="active" id="stat-active">0/6 active</span> 607 - <span class="queued" id="stat-queued">0 queued</span> 608 - <span id="stat-uptime">--</span> 609 - <span id="stat-version">--</span> 610 - </div> 611 - <div style="display:flex;gap:4px"> 612 - <a href="/tools" class="sb-btn">Tools</a> 613 - <button class="sb-btn" id="log-btn" onclick="document.getElementById('log-overlay').classList.toggle('open')">Log</button> 614 - </div> 615 - </div> 616 - 617 - <div class="hero idle" id="hero">Waiting for grabs...</div> 618 - 619 - <div class="strip" id="queue-strip"> 620 - <div class="strip-label">Up Next</div> 621 - <div class="strip-items" id="queue-items"> 622 - <span class="strip-empty">No items queued</span> 623 - </div> 624 - </div> 625 - 626 - <div class="history" id="history"> 627 - <div class="strip-label">Recent</div> 628 - <div id="history-items"> 629 - <span class="strip-empty">No recent grabs</span> 630 - </div> 631 - </div> 632 - 633 - <div class="log-overlay" id="log-overlay"> 634 - <button class="close" onclick="this.parentElement.classList.remove('open')">x</button> 635 - <div id="log-entries"></div> 636 - </div> 637 - 638 - <script> 639 - let serverVersion = null; 640 - let ws = null; 641 - let reconnectTimer = null; 642 - 643 - function connect() { 644 - const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; 645 - ws = new WebSocket(proto + '//' + location.host + '/ws'); 646 - 647 - ws.onopen = () => { 648 - document.getElementById('stat-version').textContent = 'connected'; 649 - document.getElementById('stat-version').style.color = 'var(--success)'; 650 - if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } 651 - }; 652 - 653 - ws.onclose = () => { 654 - document.getElementById('stat-version').textContent = 'disconnected'; 655 - document.getElementById('stat-version').style.color = 'var(--error)'; 656 - reconnectTimer = setTimeout(connect, 2000); 657 - }; 658 - 659 - ws.onmessage = (event) => { 660 - const data = JSON.parse(event.data); 661 - 662 - if (data.logEntry) { addLog(data.logEntry); return; } 663 - if (serverVersion && data.version && data.version !== serverVersion) { 664 - location.reload(); 665 - return; 666 - } 667 - serverVersion = data.version; 668 - 669 - if (data.recentLogs) { 670 - data.recentLogs.forEach(addLog); 671 - } 672 - 673 - updateStatusBar(data); 674 - renderHero(data.grabProgress || {}); 675 - renderQueue(data.grabs?.queue || []); 676 - renderHistory(data.grabs?.recent || []); 677 - }; 678 - } 679 - 680 - function updateStatusBar(data) { 681 - const c = data.concurrency || {}; 682 - document.getElementById('stat-active').textContent = (c.active || 0) + '/' + (c.max || 6) + ' active'; 683 - document.getElementById('stat-queued').textContent = (c.queueDepth || 0) + ' queued'; 684 - 685 - if (data.uptime) { 686 - const s = Math.floor(data.uptime / 1000); 687 - const m = Math.floor(s / 60); 688 - const h = Math.floor(m / 60); 689 - const d = Math.floor(h / 24); 690 - let upStr; 691 - if (d > 0) upStr = d + 'd ' + (h % 24) + 'h'; 692 - else if (h > 0) upStr = h + 'h ' + (m % 60) + 'm'; 693 - else upStr = m + 'm ' + (s % 60) + 's'; 694 - document.getElementById('stat-uptime').textContent = 'up ' + upStr; 695 - } 696 - if (data.version) { 697 - document.getElementById('stat-version').textContent = data.version; 698 - document.getElementById('stat-version').style.color = 'var(--text-muted)'; 699 - } 700 - } 701 - 702 - let heroCards = {}; // grabId → DOM element 703 - let exitingCards = new Set(); // grabIds currently animating out 704 - 705 - function ago(ms) { 706 - const s = Math.floor(ms / 1000); 707 - if (s < 60) return s + 's ago'; 708 - const m = Math.floor(s / 60); 709 - return m + 'm ' + (s % 60) + 's ago'; 710 - } 711 - 712 - function shortDate(iso) { 713 - if (!iso) return ''; 714 - const d = new Date(iso); 715 - if (isNaN(d)) return ''; 716 - const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; 717 - return months[d.getMonth()] + ' ' + d.getDate(); 718 - } 719 - 720 - function renderHero(grabProgress) { 721 - const hero = document.getElementById('hero'); 722 - const entries = Object.entries(grabProgress).filter(([, p]) => p.stage); 723 - 724 - // Animate out cards no longer in progress 725 - const activeIds = new Set(entries.map(([id]) => id)); 726 - for (const id of Object.keys(heroCards)) { 727 - if (!activeIds.has(id) && !exitingCards.has(id)) { 728 - exitingCards.add(id); 729 - const card = heroCards[id]; 730 - card.classList.add('exiting'); 731 - card.addEventListener('animationend', () => { 732 - card.remove(); 733 - delete heroCards[id]; 734 - exitingCards.delete(id); 735 - // Check if hero should go idle after last card exits 736 - if (Object.keys(heroCards).length === 0 && exitingCards.size === 0) { 737 - hero.className = 'hero idle'; 738 - hero.textContent = 'Waiting for grabs\\u2026'; 739 - } 740 - }, { once: true }); 741 - } 742 - } 743 - 744 - if (entries.length === 0 && Object.keys(heroCards).length === 0 && exitingCards.size === 0) { 745 - if (!hero.classList.contains('idle')) { 746 - hero.className = 'hero idle'; 747 - hero.textContent = 'Waiting for grabs\\u2026'; 748 - } 749 - return; 750 - } 751 - 752 - // Clear idle text when transitioning to active 753 - if (hero.classList.contains('idle')) { 754 - hero.innerHTML = ''; 755 - } 756 - hero.className = 'hero'; 757 - 758 - // Update or create cards 759 - entries.forEach(([grabId, p]) => { 760 - const previewSrc = p.previewFrame 761 - ? 'data:image/jpeg;base64,' + p.previewFrame 762 - : ''; 763 - const stageLabel = p.stage ? (p.stage.charAt(0).toUpperCase() + p.stage.slice(1)) : ''; 764 - const detail = p.stageDetail || ''; 765 - const pct = p.percent || 0; 766 - const now = Date.now(); 767 - const reqAgo = p.requestedAt ? ago(now - p.requestedAt) : ''; 768 - const authorStr = p.author || ''; 769 - const createdStr = shortDate(p.pieceCreatedAt); 770 - const sourceStr = p.source || ''; 771 - const originStr = p.requestOrigin ? p.requestOrigin.replace(/^https?:\\/\\//, '').split('/')[0] : ''; 772 - 773 - let metaParts = []; 774 - if (authorStr) metaParts.push('<span class="author">' + esc(authorStr) + '</span>'); 775 - if (sourceStr) metaParts.push(sourceStr); 776 - if (originStr) metaParts.push(originStr); 777 - if (createdStr) metaParts.push('created ' + createdStr); 778 - if (reqAgo) metaParts.push(reqAgo); 779 - const metaHtml = metaParts.join(' · '); 780 - 781 - let card = heroCards[grabId]; 782 - if (!card) { 783 - card = document.createElement('div'); 784 - card.className = 'hero-card' + (p.stage === 'capturing' ? ' capturing' : ''); 785 - card.innerHTML = 786 - '<div class="preview">' + (previewSrc ? '<img src="' + previewSrc + '" alt="">' : '<span class="placeholder">···</span>') + '</div>' + 787 - '<div class="info">' + 788 - '<div class="piece-name">' + esc(p.piece || grabId) + '</div>' + 789 - '<div class="meta">' + metaHtml + '</div>' + 790 - '<div class="stage">' + esc(stageLabel + (detail ? ' — ' + detail : '')) + '</div>' + 791 - '<div class="progress-bar"><div class="fill" style="width:' + pct + '%"></div></div>' + 792 - '</div>'; 793 - hero.appendChild(card); 794 - heroCards[grabId] = card; 795 - } else { 796 - card.className = 'hero-card' + (p.stage === 'capturing' ? ' capturing' : ''); 797 - const img = card.querySelector('.preview img'); 798 - if (previewSrc && img) { 799 - if (img.src !== previewSrc) img.src = previewSrc; 800 - } else if (previewSrc && !img) { 801 - card.querySelector('.preview').innerHTML = '<img src="' + previewSrc + '" alt="">'; 802 - } 803 - card.querySelector('.piece-name').textContent = p.piece || grabId; 804 - card.querySelector('.meta').innerHTML = metaHtml; 805 - card.querySelector('.stage').textContent = stageLabel + (detail ? ' — ' + detail : ''); 806 - card.querySelector('.fill').style.width = pct + '%'; 807 - } 808 - }); 809 - } 810 - 811 - let lastQueueKey = ''; 812 - function renderQueue(queue) { 813 - const el = document.getElementById('queue-items'); 814 - if (!queue || queue.length === 0) { 815 - if (lastQueueKey !== 'empty') { 816 - el.innerHTML = '<span class="strip-empty">No items queued</span>'; 817 - el.classList.remove('train'); 818 - lastQueueKey = 'empty'; 819 - } 820 - return; 821 - } 822 - // Only re-render if queue contents changed (avoids restarting CSS animation) 823 - const queueKey = queue.map(q => q.piece).join(','); 824 - if (queueKey === lastQueueKey) return; 825 - lastQueueKey = queueKey; 826 - 827 - const items = queue.map((item, i) => 828 - '<div class="strip-item queue">' + 829 - '#' + (i + 1) + ' ' + esc(item.piece || '?') + 830 - ' <span style="color:var(--text-muted)">(' + esc(item.format || '?') + ')</span>' + 831 - (item.estimatedWait ? ' <span style="color:var(--text-dim)">~' + Math.ceil(item.estimatedWait / 1000) + 's</span>' : '') + 832 - '</div>' 833 - ).join(''); 834 - // Duplicate items for seamless looping train effect 835 - if (queue.length > 4) { 836 - el.innerHTML = items + items; 837 - el.classList.add('train'); 838 - el.style.animationDuration = Math.max(10, queue.length * 2) + 's'; 839 - } else { 840 - el.innerHTML = items; 841 - el.classList.remove('train'); 842 - } 843 - } 844 - 845 - let lastHistoryKey = ''; 846 - function renderHistory(recent) { 847 - const el = document.getElementById('history-items'); 848 - if (!recent || recent.length === 0) { 849 - if (lastHistoryKey !== 'empty') { 850 - el.innerHTML = '<span class="strip-empty" style="padding:5px 12px">No recent grabs</span>'; 851 - lastHistoryKey = 'empty'; 852 - } 853 - return; 854 - } 855 - const historyKey = recent.slice(0, 30).map(g => (g.id || g.piece) + ':' + g.status).join(','); 856 - if (historyKey === lastHistoryKey) return; 857 - lastHistoryKey = historyKey; 858 - el.innerHTML = recent.slice(0, 30).map(grab => { 859 - const thumbImg = grab.cdnUrl 860 - ? '<img src="' + esc(grab.cdnUrl) + '" alt="">' 861 - : '<span class="h-none">--</span>'; 862 - 863 - const pieceClass = grab.status === 'failed' ? 'h-status-failed' : ''; 864 - 865 - const dur = grab.duration ? Math.round(grab.duration / 1000) + 's' : ''; 866 - const dim = grab.dimensions ? grab.dimensions.width + 'x' + grab.dimensions.height : ''; 867 - const fmt = (grab.format || '').toUpperCase(); 868 - const size = grab.size ? (grab.size > 1024*1024 ? (grab.size/1024/1024).toFixed(1)+'MB' : Math.round(grab.size/1024)+'KB') : ''; 869 - 870 - const metaParts = [fmt, dim, dur, size].filter(Boolean); 871 - const metaHTML = metaParts.map(m => '<span>' + esc(m) + '</span>').join(''); 872 - 873 - const errorHTML = grab.error 874 - ? '<div class="h-error">' + esc(grab.error) + '</div>' 875 - : ''; 876 - 877 - let linksHTML = ''; 878 - if (grab.cdnUrl) { 879 - linksHTML = '<div class="h-links">' + 880 - '<a href="' + esc(grab.cdnUrl) + '" target="_blank">Open</a>' + 881 - '<a href="' + esc(grab.cdnUrl) + '" download>Download</a>' + 882 - '</div>'; 883 - } 884 - 885 - const statusClass = grab.status === 'complete' ? 'h-status-done' : 886 - grab.status === 'failed' ? 'h-status-failed' : 'h-status-other'; 887 - const statusLabel = grab.status === 'complete' ? 'done' : 888 - grab.status === 'failed' ? 'failed' : 889 - esc(grab.status || '?'); 890 - 891 - const ago = grab.completedAt ? timeAgo(grab.completedAt) : ''; 892 - 893 - const pieceName = esc(grab.piece || grab.id || '?'); 894 - const pieceLink = grab.cdnUrl 895 - ? '<a href="' + esc(grab.cdnUrl) + '" target="_blank">' + pieceName + '</a>' 896 - : pieceName; 897 - 898 - return '<div class="history-row">' + 899 - '<div class="h-thumb">' + thumbImg + '</div>' + 900 - '<div class="h-main">' + 901 - '<div class="h-piece ' + pieceClass + '">' + pieceLink + '</div>' + 902 - '<div class="h-meta">' + metaHTML + '</div>' + 903 - errorHTML + 904 - linksHTML + 905 - '</div>' + 906 - '<div class="h-right">' + 907 - '<div class="' + statusClass + '">' + statusLabel + '</div>' + 908 - '<div class="h-ago">' + esc(ago) + '</div>' + 909 - '</div>' + 910 - '</div>'; 911 - }).join(''); 912 - } 913 - 914 - function timeAgo(ts) { 915 - const s = Math.floor((Date.now() - ts) / 1000); 916 - if (s < 60) return s + 's ago'; 917 - const m = Math.floor(s / 60); 918 - if (m < 60) return m + 'm ago'; 919 - const h = Math.floor(m / 60); 920 - if (h < 24) return h + 'h ago'; 921 - return Math.floor(h / 24) + 'd ago'; 922 - } 923 - 924 - function addLog(entry) { 925 - if (!entry) return; 926 - const el = document.getElementById('log-entries'); 927 - const div = document.createElement('div'); 928 - div.className = 'log-entry' + (entry.type === 'error' ? ' error' : entry.type === 'success' ? ' success' : ''); 929 - const time = entry.time ? new Date(entry.time).toLocaleTimeString() : ''; 930 - div.innerHTML = '<span class="time">' + time + '</span> ' + esc((entry.icon || '') + ' ' + (entry.msg || '')); 931 - el.prepend(div); 932 - while (el.children.length > 200) el.removeChild(el.lastChild); 933 - } 934 - 935 - function esc(s) { 936 - if (!s) return ''; 937 - return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); 938 - } 939 - 940 - connect(); 941 - </script> 942 - </body> 943 - </html>`; 944 - 945 - // Tools submenu — links to OG images, app screenshots, bundles, status pages 946 - app.get('/tools', (req, res) => { 947 - res.setHeader('Content-Type', 'text/html'); 948 - res.send(`<!DOCTYPE html> 949 - <html> 950 - <head> 951 - <meta charset="utf-8"> 952 - <title>oven / tools</title> 953 - <meta name="viewport" content="width=device-width, initial-scale=1"> 954 - <style> 955 - :root { 956 - --bg: #f7f7f7; --text: #111; --text-muted: #888; --text-dim: #aaa; 957 - --accent: rgb(205, 92, 155); --border: #ddd; 958 - } 959 - @media (prefers-color-scheme: dark) { 960 - :root { 961 - --bg: #1e1e1e; --text: #d4d4d4; --text-muted: #666; --text-dim: #444; 962 - --accent: rgb(205, 92, 155); --border: #3e3e42; 963 - } 964 - } 965 - * { box-sizing: border-box; margin: 0; padding: 0; } 966 - body { font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace; font-size: 12px; background: var(--bg); color: var(--text); padding: 20px; } 967 - a { color: var(--accent); text-decoration: none; } 968 - a:hover { text-decoration: underline; } 969 - h1 { color: var(--accent); margin-bottom: 20px; font-size: 1.1em; } 970 - h1 a { color: var(--text-muted); } 971 - h2 { color: var(--text-muted); margin: 16px 0 6px; font-size: 0.9em; text-transform: uppercase; letter-spacing: 1px; } 972 - .links { display: flex; flex-direction: column; gap: 4px; margin-left: 10px; } 973 - .links a { padding: 2px 0; } 974 - .desc { color: var(--text-dim); font-size: 0.85em; margin-left: 8px; } 975 - .panel { 976 - margin: 8px 0 0 10px; 977 - padding: 8px; 978 - border: 1px solid var(--border); 979 - max-width: 920px; 980 - border-radius: 4px; 981 - background: rgba(0,0,0,0.02); 982 - } 983 - .row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-bottom: 6px; } 984 - input, button { 985 - font: inherit; 986 - font-size: 0.9em; 987 - border: 1px solid var(--border); 988 - background: transparent; 989 - color: var(--text); 990 - padding: 4px 6px; 991 - border-radius: 3px; 992 - } 993 - input { min-width: 160px; } 994 - button { cursor: pointer; } 995 - button:hover { border-color: var(--accent); color: var(--accent); } 996 - pre { 997 - border: 1px solid var(--border); 998 - padding: 8px; 999 - overflow: auto; 1000 - max-height: 240px; 1001 - white-space: pre-wrap; 1002 - word-break: break-word; 1003 - } 1004 - hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; } 1005 - </style> 1006 - </head> 1007 - <body> 1008 - <h1><a href="/">oven</a> / tools</h1> 1009 - 1010 - <h2>OG Images</h2> 1011 - <div class="links"> 1012 - <div><a href="/kidlisp-og.png">/kidlisp-og.png</a><span class="desc">KidLisp OG image</span></div> 1013 - <div><a href="/kidlisp-og">/kidlisp-og</a><span class="desc">KidLisp OG HTML page</span></div> 1014 - <div><a href="/kidlisp-og/status">/kidlisp-og/status</a><span class="desc">OG cache status</span></div> 1015 - <div><a href="/kidlisp-og/preview">/kidlisp-og/preview</a><span class="desc">OG preview</span></div> 1016 - <div><a href="/og-preview">/og-preview</a><span class="desc">OG preview (alt)</span></div> 1017 - <div><a href="/notepat-og.png">/notepat-og.png</a><span class="desc">Notepat OG image</span></div> 1018 - <div><a href="/kidlisp-backdrop.webp">/kidlisp-backdrop.webp</a><span class="desc">KidLisp backdrop animation</span></div> 1019 - <div><a href="/kidlisp-backdrop">/kidlisp-backdrop</a><span class="desc">KidLisp backdrop page</span></div> 1020 - </div> 1021 - 1022 - <h2>App Screenshots</h2> 1023 - <div class="links"> 1024 - <div><a href="/app-screenshots">/app-screenshots</a><span class="desc">Screenshot dashboard</span></div> 1025 - </div> 1026 - 1027 - <h2>Bundles</h2> 1028 - <div class="links"> 1029 - <div><a href="/bundle-status">/bundle-status</a><span class="desc">Bundle cache status</span></div> 1030 - <div><a href="/bundle-html?piece=prompt">/bundle-html?piece=...</a><span class="desc">Generate HTML bundle (SSE)</span></div> 1031 - </div> 1032 - 1033 - <h2>Grabs</h2> 1034 - <div class="links"> 1035 - <div><a href="/grab-status">/grab-status</a><span class="desc">Active grabs + queue (JSON)</span></div> 1036 - <div><a href="/api/frozen">/api/frozen</a><span class="desc">Frozen pieces list</span></div> 1037 - <div><a href="/keeps/all">/keeps/all</a><span class="desc">All latest IPFS uploads</span></div> 1038 - <div><a href="/keeps/latest">/keeps/latest</a><span class="desc">Latest keep thumbnail</span></div> 1039 - </div> 1040 - 1041 - <h2>Status</h2> 1042 - <div class="links"> 1043 - <div><a href="/health">/health</a><span class="desc">Health check</span></div> 1044 - <div><a href="/status">/status</a><span class="desc">Server status + recent bakes</span></div> 1045 - </div> 1046 - 1047 - <h2>OS Base Builds</h2> 1048 - <div class="links"> 1049 - <div><a href="/os-base-build">/os-base-build</a><span class="desc">Background FedOS base-image jobs</span></div> 1050 - </div> 1051 - <div class="panel"> 1052 - <div class="row"> 1053 - <input id="os-admin-key" type="password" placeholder="admin key (x-oven-os-key)"> 1054 - <select id="os-flavor" style="width:100px"><option value="alpine" selected>Alpine</option><option value="fedora">Fedora</option><option value="native">Native</option></select> 1055 - <input id="os-image-size" type="number" min="1" max="32" value="1" style="width:90px"> 1056 - <button id="os-start-btn" type="button">Start Base Build</button> 1057 - <button id="os-refresh-btn" type="button">Refresh</button> 1058 - </div> 1059 - <div id="os-job-meta" class="desc">Loading base-build status...</div> 1060 - <pre id="os-job-log">No active base-image job</pre> 1061 - </div> 1062 - 1063 - <script> 1064 - const osMetaEl = document.getElementById('os-job-meta'); 1065 - const osLogEl = document.getElementById('os-job-log'); 1066 - const osKeyEl = document.getElementById('os-admin-key'); 1067 - const osSizeEl = document.getElementById('os-image-size'); 1068 - const osFlavorEl = document.getElementById('os-flavor'); 1069 - const osStartBtn = document.getElementById('os-start-btn'); 1070 - const osRefreshBtn = document.getElementById('os-refresh-btn'); 1071 - osFlavorEl.addEventListener('change', function() { 1072 - osSizeEl.value = osFlavorEl.value === 'alpine' ? '1' : '4'; 1073 - }); 1074 - let osPollTimer = null; 1075 - 1076 - function esc(str) { 1077 - return String(str || '').replace(/[&<>"']/g, function (ch) { 1078 - return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch]; 1079 - }); 1080 - } 1081 - 1082 - async function fetchJSON(url, opts) { 1083 - const res = await fetch(url, opts || {}); 1084 - const text = await res.text(); 1085 - let data = {}; 1086 - try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; } 1087 - if (!res.ok) { 1088 - throw new Error((data && data.error) || ('HTTP ' + res.status)); 1089 - } 1090 - return data; 1091 - } 1092 - 1093 - function jobLabel(job) { 1094 - if (!job) return 'none'; 1095 - const pct = Number.isFinite(job.percent) ? (' ' + job.percent + '%') : ''; 1096 - return job.id + ' | ' + job.status + pct + ' | ' + (job.stage || '--'); 1097 - } 1098 - 1099 - async function refreshOSBaseBuild() { 1100 - try { 1101 - const summary = await fetchJSON('/os-base-build'); 1102 - const active = summary.active || null; 1103 - const recent = Array.isArray(summary.recent) ? summary.recent : []; 1104 - if (active) { 1105 - osMetaEl.innerHTML = 'Active: <strong>' + esc(jobLabel(active)) + '</strong>'; 1106 - const detail = await fetchJSON('/os-base-build/' + encodeURIComponent(active.id) + '?logs=1&tail=100'); 1107 - const lines = Array.isArray(detail.logs) ? detail.logs : []; 1108 - osLogEl.textContent = lines.map(function (l) { 1109 - return '[' + (l.ts || '') + '][' + (l.stream || 'out') + '] ' + (l.line || ''); 1110 - }).join('\n') || 'No logs yet'; 1111 - return; 1112 - } 1113 - 1114 - const latest = recent.length > 0 ? recent[0] : null; 1115 - osMetaEl.innerHTML = 'Active: none' + (latest ? ' | Latest: <strong>' + esc(jobLabel(latest)) + '</strong>' : ''); 1116 - osLogEl.textContent = latest 1117 - ? ((latest.message || '(no message)') + '\n\nUse /os-base-build/' + latest.id + '?logs=1&tail=200 for full logs.') 1118 - : 'No base-image jobs yet'; 1119 - } catch (error) { 1120 - osMetaEl.textContent = 'Status error: ' + error.message; 1121 - } 1122 - } 1123 - 1124 - async function startOSBaseBuild() { 1125 - const key = osKeyEl.value.trim(); 1126 - const flavor = osFlavorEl.value || 'alpine'; 1127 - const defaultSize = flavor === 'alpine' ? 1 : 4; 1128 - const imageSizeGB = Math.max(1, parseInt(osSizeEl.value || String(defaultSize), 10) || defaultSize); 1129 - osStartBtn.disabled = true; 1130 - try { 1131 - const data = await fetchJSON('/os-base-build', { 1132 - method: 'POST', 1133 - headers: { 1134 - 'Content-Type': 'application/json', 1135 - 'x-oven-os-key': key, 1136 - }, 1137 - body: JSON.stringify({ imageSizeGB, publish: true, flavor }), 1138 - }); 1139 - osMetaEl.textContent = 'Started ' + flavor + ' base build job ' + data.id; 1140 - } catch (error) { 1141 - osMetaEl.textContent = 'Start failed: ' + error.message; 1142 - } finally { 1143 - osStartBtn.disabled = false; 1144 - refreshOSBaseBuild(); 1145 - } 1146 - } 1147 - 1148 - osStartBtn.addEventListener('click', startOSBaseBuild); 1149 - osRefreshBtn.addEventListener('click', refreshOSBaseBuild); 1150 - refreshOSBaseBuild(); 1151 - osPollTimer = setInterval(refreshOSBaseBuild, 3000); 1152 - window.addEventListener('beforeunload', function () { 1153 - if (osPollTimer) clearInterval(osPollTimer); 1154 - }); 1155 - </script> 1156 - </body> 1157 - </html>`); 1158 - }); 1159 - 1160 - // API endpoints 1161 - app.get('/health', healthHandler); 1162 - 1163 - // Override status to include grabs 1164 - app.get('/status', async (req, res) => { 1165 - await cleanupStaleBakes(); 1166 - res.json({ 1167 - version: GIT_VERSION, 1168 - serverStartTime: SERVER_START_TIME, 1169 - uptime: Date.now() - SERVER_START_TIME, 1170 - incoming: Array.from(getIncomingBakes().values()), 1171 - active: Array.from(getActiveBakes().values()), 1172 - recent: getRecentBakes(), 1173 - grabs: { 1174 - active: getActiveGrabs(), 1175 - recent: getRecentGrabs(), 1176 - ipfsThumbs: getAllLatestIPFSUploads() 1177 - }, 1178 - osBaseBuilds: getOSBaseBuildsSummary(), 1179 - }); 1180 - }); 1181 - 1182 - app.post('/bake', bakeHandler); 1183 - app.post('/bake-complete', bakeCompleteHandler); 1184 - app.post('/bake-status', bakeStatusHandler); 1185 - 1186 - // Icon endpoint - small square thumbnails (compatible with grab.aesthetic.computer) 1187 - // GET /icon/{width}x{height}/{piece}.png 1188 - // Uses 24h Spaces cache to avoid regenerating on every request 1189 - app.get('/icon/:size/:piece.png', async (req, res) => { 1190 - const { size, piece } = req.params; 1191 - const [width, height] = size.split('x').map(n => parseInt(n) || 128); 1192 - const w = Math.min(width, 512); 1193 - const h = Math.min(height, 512); 1194 - 1195 - try { 1196 - const { cdnUrl, fromCache, buffer } = await getCachedOrGenerate('icons', piece, w, h, async () => { 1197 - const result = await grabPiece(piece, { 1198 - format: 'png', 1199 - width: w, 1200 - height: h, 1201 - density: 1, 1202 - }); 1203 - if (!result.success) throw new Error(result.error); 1204 - // Handle case where grabPiece returns from its own cache (cdnUrl but no buffer) 1205 - if (result.cached && result.cdnUrl && !result.buffer) { 1206 - // Fetch the buffer from the CDN URL 1207 - const response = await fetch(result.cdnUrl); 1208 - if (!response.ok) throw new Error(`Failed to fetch cached icon: ${response.status}`); 1209 - return Buffer.from(await response.arrayBuffer()); 1210 - } 1211 - return result.buffer; 1212 - }); 1213 - 1214 - if (fromCache && cdnUrl) { 1215 - res.setHeader('X-Cache', 'HIT'); 1216 - res.setHeader('Cache-Control', 'public, max-age=86400'); 1217 - return res.redirect(302, cdnUrl); 1218 - } 1219 - 1220 - res.setHeader('Content-Type', 'image/png'); 1221 - res.setHeader('Content-Length', buffer.length); 1222 - res.setHeader('Cache-Control', 'public, max-age=3600'); 1223 - res.setHeader('X-Cache', 'MISS'); 1224 - res.send(buffer); 1225 - } catch (error) { 1226 - console.error('Icon handler error:', error); 1227 - res.status(500).json({ error: error.message }); 1228 - } 1229 - }); 1230 - 1231 - // Animated WebP Icon endpoint - small animated square favicons 1232 - // GET /icon/{width}x{height}/{piece}.webp 1233 - // Uses 7-day Spaces cache since animated icons are expensive to generate 1234 - app.get('/icon/:size/:piece.webp', async (req, res) => { 1235 - const { size, piece } = req.params; 1236 - const [width, height] = size.split('x').map(n => parseInt(n) || 128); 1237 - // Keep animated icons small for performance (max 128x128) 1238 - const w = Math.min(width, 128); 1239 - const h = Math.min(height, 128); 1240 - 1241 - // Query params for customization 1242 - const frames = Math.min(parseInt(req.query.frames) || 30, 60); // Default 30 frames, max 60 1243 - const fps = Math.min(parseInt(req.query.fps) || 15, 30); // Default 15 fps, max 30 1244 - 1245 - try { 1246 - const cacheKey = `${piece}-${w}x${h}-f${frames}-fps${fps}`; 1247 - const { cdnUrl, fromCache, buffer } = await getCachedOrGenerate('animated-icons', cacheKey, w, h, async () => { 1248 - const result = await grabPiece(piece, { 1249 - format: 'webp', 1250 - width: w, 1251 - height: h, 1252 - density: 1, 1253 - frames: frames, 1254 - fps: fps, 1255 - }); 1256 - if (!result.success) throw new Error(result.error); 1257 - // Handle case where grabPiece returns from its own cache (cdnUrl but no buffer) 1258 - if (result.cached && result.cdnUrl && !result.buffer) { 1259 - const response = await fetch(result.cdnUrl); 1260 - if (!response.ok) throw new Error(`Failed to fetch cached icon: ${response.status}`); 1261 - return Buffer.from(await response.arrayBuffer()); 1262 - } 1263 - return result.buffer; 1264 - }, 'webp'); 1265 - 1266 - if (fromCache && cdnUrl) { 1267 - res.setHeader('X-Cache', 'HIT'); 1268 - res.setHeader('Cache-Control', 'public, max-age=604800'); // 7 days 1269 - return res.redirect(302, cdnUrl); 1270 - } 1271 - 1272 - res.setHeader('Content-Type', 'image/webp'); 1273 - res.setHeader('Content-Length', buffer.length); 1274 - res.setHeader('Cache-Control', 'public, max-age=86400'); // 1 day for fresh 1275 - res.setHeader('X-Cache', 'MISS'); 1276 - res.send(buffer); 1277 - } catch (error) { 1278 - console.error('Animated icon handler error:', error); 1279 - res.status(500).json({ error: error.message }); 1280 - } 1281 - }); 1282 - 1283 - // Preview endpoint - larger social media images (compatible with grab.aesthetic.computer) 1284 - // GET /preview/{width}x{height}/{piece}.png 1285 - // Uses 24h Spaces cache to avoid regenerating on every request 1286 - app.get('/preview/:size/:piece.png', async (req, res) => { 1287 - const { size, piece } = req.params; 1288 - const [width, height] = size.split('x').map(n => parseInt(n) || 1200); 1289 - const w = Math.min(width, 1920); 1290 - const h = Math.min(height, 1080); 1291 - 1292 - try { 1293 - const { cdnUrl, fromCache, buffer } = await getCachedOrGenerate('previews', piece, w, h, async () => { 1294 - const result = await grabPiece(piece, { 1295 - format: 'png', 1296 - width: w, 1297 - height: h, 1298 - density: 4, 1299 - viewportScale: 1, 1300 - }); 1301 - if (!result.success) throw new Error(result.error); 1302 - // Handle case where grabPiece returns from its own cache (cdnUrl but no buffer) 1303 - if (result.cached && result.cdnUrl && !result.buffer) { 1304 - const response = await fetch(result.cdnUrl); 1305 - if (!response.ok) throw new Error(`Failed to fetch cached preview: ${response.status}`); 1306 - return Buffer.from(await response.arrayBuffer()); 1307 - } 1308 - return result.buffer; 1309 - }); 1310 - 1311 - if (fromCache && cdnUrl) { 1312 - res.setHeader('X-Cache', 'HIT'); 1313 - res.setHeader('Cache-Control', 'public, max-age=86400'); 1314 - return res.redirect(302, cdnUrl); 1315 - } 1316 - 1317 - res.setHeader('Content-Type', 'image/png'); 1318 - res.setHeader('Content-Length', buffer.length); 1319 - res.setHeader('Cache-Control', 'public, max-age=3600'); 1320 - res.setHeader('X-Cache', 'MISS'); 1321 - res.send(buffer); 1322 - } catch (error) { 1323 - console.error('Preview handler error:', error); 1324 - res.status(500).json({ error: error.message }); 1325 - } 1326 - }); 1327 - 1328 - // Product images — static assets for AC hardware/products 1329 - // GET /product/{name}.png — redirects to Spaces CDN: products/{name}.png 1330 - const PRODUCT_CDN = process.env.ART_CDN_BASE || 'https://art.aesthetic.computer'; 1331 - app.get('/product/:name.png', (req, res) => { 1332 - const { name } = req.params; 1333 - if (!/^[a-z0-9-]+$/.test(name)) return res.status(400).json({ error: 'Invalid product name' }); 1334 - res.setHeader('Cache-Control', 'public, max-age=604800'); // 7 days 1335 - res.redirect(302, `${PRODUCT_CDN}/products/${name}.png`); 1336 - }); 1337 - 1338 - // Grab endpoint - capture screenshots/GIFs from KidLisp pieces 1339 - app.post('/grab', grabHandler); 1340 - app.get('/grab/:format/:width/:height/:piece', grabGetHandler); 1341 - app.post('/grab-ipfs', grabIPFSHandler); 1342 - 1343 - // Grab status endpoint 1344 - app.get('/grab-status', (req, res) => { 1345 - res.json({ 1346 - active: getActiveGrabs(), 1347 - recent: getRecentGrabs(), 1348 - queue: getQueueStatus(), 1349 - progress: getCurrentProgress(), 1350 - grabProgress: getAllProgress(), 1351 - concurrency: getConcurrencyStatus(), 1352 - osBaseBuilds: getOSBaseBuildsSummary(), 1353 - }); 1354 - }); 1355 - 1356 - // Cleanup stale grabs (grabs stuck for > 5 minutes) 1357 - app.post('/grab-cleanup', (req, res) => { 1358 - const result = cleanupStaleGrabs(); 1359 - addServerLog('cleanup', '🧹', `Manual cleanup: ${result.cleaned} stale grabs removed`); 1360 - res.json({ 1361 - success: true, 1362 - ...result 1363 - }); 1364 - }); 1365 - 1366 - // Emergency clear all active grabs (admin only) 1367 - app.post('/grab-clear', (req, res) => { 1368 - const result = clearAllActiveGrabs(); 1369 - addServerLog('cleanup', '🗑️', `Emergency clear: ${result.cleared} grabs force-cleared`); 1370 - res.json({ 1371 - success: true, 1372 - ...result 1373 - }); 1374 - }); 1375 - 1376 - // Frozen pieces API - get list of frozen pieces 1377 - app.get('/api/frozen', (req, res) => { 1378 - res.json({ 1379 - frozen: getFrozenPieces() 1380 - }); 1381 - }); 1382 - 1383 - // Clear a piece from the frozen list 1384 - app.delete('/api/frozen/:piece', async (req, res) => { 1385 - const piece = decodeURIComponent(req.params.piece); 1386 - const result = await clearFrozenPiece(piece); 1387 - addServerLog('cleanup', '✅', `Cleared frozen piece: ${piece}`); 1388 - res.json(result); 1389 - }); 1390 - 1391 - // Live collection thumbnail endpoint - redirects to most recent kept WebP 1392 - // Use this as the collection imageUri for a dynamic thumbnail 1393 - app.get('/keeps/latest', async (req, res) => { 1394 - let latest = getLatestKeepThumbnail(); 1395 - if (!latest) { 1396 - latest = await ensureLatestKeepThumbnail(); 1397 - } 1398 - if (!latest) { 1399 - return res.status(404).json({ 1400 - error: 'No keeps have been captured yet', 1401 - hint: 'No minted keep thumbnail found in oven or kidlisp records yet' 1402 - }); 1403 - } 1404 - 1405 - // Redirect to IPFS gateway 1406 - const gatewayUrl = `${IPFS_GATEWAY}/ipfs/${latest.ipfsCid}`; 1407 - res.redirect(302, gatewayUrl); 1408 - }); 1409 - 1410 - // Get latest thumbnail for a specific piece 1411 - app.get('/keeps/latest/:piece', (req, res) => { 1412 - const latest = getLatestIPFSUpload(req.params.piece); 1413 - if (!latest) { 1414 - return res.status(404).json({ 1415 - error: `No keeps captured for piece: ${req.params.piece}`, 1416 - hint: `Mint ${req.params.piece} with --thumbnail flag to populate this endpoint` 1417 - }); 1418 - } 1419 - 1420 - const gatewayUrl = `${IPFS_GATEWAY}/ipfs/${latest.ipfsCid}`; 1421 - res.redirect(302, gatewayUrl); 1422 - }); 1423 - 1424 - // Get all latest thumbnails as JSON (for debugging/monitoring) 1425 - app.get('/keeps/all', (req, res) => { 1426 - res.json({ 1427 - latest: getLatestKeepThumbnail(), 1428 - byPiece: getAllLatestIPFSUploads() 1429 - }); 1430 - }); 1431 - 1432 - // ============================================================================= 1433 - // KidLisp.com OG Preview Image Endpoint 1434 - // ============================================================================= 1435 - 1436 - // Fast static PNG endpoint - redirects instantly to CDN (for social media crawlers) 1437 - // Use this URL in og:image and twitter:image meta tags 1438 - app.get('/kidlisp-og.png', async (req, res) => { 1439 - try { 1440 - const layout = req.query.layout || 'mosaic'; 1441 - 1442 - // Get cached URL without triggering generation (fast!) 1443 - const url = await getLatestOGImageUrl(layout); 1444 - 1445 - if (url) { 1446 - // Redirect to CDN - instant response 1447 - res.setHeader('Cache-Control', 'public, max-age=3600'); 1448 - res.setHeader('X-Cache', 'CDN'); 1449 - return res.redirect(301, url); 1450 - } 1451 - 1452 - // No cached image yet - trigger background regeneration and serve a recent fallback 1453 - addServerLog('warn', '⚠️', `OG cache miss for ${layout}, triggering regen`); 1454 - 1455 - // Trigger async regeneration (don't await) 1456 - regenerateOGImagesBackground().catch(err => { 1457 - addServerLog('error', '❌', `Async OG regen failed: ${err.message}`); 1458 - }); 1459 - 1460 - // Use yesterday's image as fallback (likely exists) 1461 - const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]; 1462 - const fallbackUrl = `https://art.aesthetic.computer/og/kidlisp/${yesterday}-${layout}.png`; 1463 - 1464 - res.setHeader('Cache-Control', 'public, max-age=300'); // Short cache for fallback 1465 - return res.redirect(302, fallbackUrl); 1466 - 1467 - } catch (error) { 1468 - console.error('KidLisp OG PNG error:', error); 1469 - // Ultimate fallback - yesterday's mosaic 1470 - const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]; 1471 - return res.redirect(302, `https://art.aesthetic.computer/og/kidlisp/${yesterday}-mosaic.png`); 1472 - } 1473 - }); 1474 - 1475 - // ─── TzKT dapp images ─────────────────────────────────────────────────────── 1476 - app.get('/kidlisp-og/tzkt-cover.jpg', async (req, res) => { 1477 - try { 1478 - addServerLog('info', '🖼️', 'TzKT cover (640x360)'); 1479 - const result = await generateKidlispOGImage('mosaic', true, { noDotCom: true }); 1480 - const jpg = await sharp(result.buffer).resize(640, 360, { fit: 'cover' }).jpeg({ quality: 90 }).toBuffer(); 1481 - res.setHeader('Content-Type', 'image/jpeg'); 1482 - res.setHeader('Content-Length', jpg.length); 1483 - res.setHeader('Cache-Control', 'public, max-age=3600'); 1484 - res.send(jpg); 1485 - } catch (error) { 1486 - console.error('TzKT cover error:', error); 1487 - res.status(500).json({ error: error.message }); 1488 - } 1489 - }); 1490 - 1491 - app.get('/kidlisp-og/tzkt-logo.jpg', async (req, res) => { 1492 - try { 1493 - addServerLog('info', '🖼️', 'TzKT logo (200x200)'); 1494 - // Render $ in Comic Relief via Puppeteer (lightweight, ~3s) 1495 - const puppeteer = await import('puppeteer'); 1496 - const browser = await puppeteer.default.launch({ headless: 'new', args: ['--no-sandbox', '--disable-dev-shm-usage'] }); 1497 - const page = await browser.newPage(); 1498 - try { 1499 - await page.setViewport({ width: 200, height: 200, deviceScaleFactor: 2 }); 1500 - await page.setContent(`<!DOCTYPE html><html><head> 1501 - <link href="https://fonts.googleapis.com/css2?family=Comic+Relief:wght@700&display=swap" rel="stylesheet"> 1502 - <style> 1503 - *{margin:0;padding:0} 1504 - body{width:200px;height:200px;display:flex;align-items:center;justify-content:center;background:#9370DB} 1505 - .d{font-family:'Comic Relief',cursive;font-size:170px;font-weight:700;color:limegreen;text-shadow:6px 6px 0 rgba(0,0,0,0.5);margin-top:-10px} 1506 - </style></head> 1507 - <body><div class="d">$</div></body></html>`, { waitUntil: 'networkidle0' }); 1508 - await page.evaluate(() => document.fonts.ready); 1509 - await new Promise(r => setTimeout(r, 300)); 1510 - const png = await page.screenshot({ type: 'png' }); 1511 - const jpg = await sharp(png).resize(200, 200).jpeg({ quality: 90 }).toBuffer(); 1512 - res.setHeader('Content-Type', 'image/jpeg'); 1513 - res.setHeader('Content-Length', jpg.length); 1514 - res.setHeader('Cache-Control', 'public, max-age=86400'); 1515 - res.send(jpg); 1516 - } finally { await page.close(); await browser.close(); } 1517 - } catch (error) { 1518 - console.error('TzKT logo error:', error); 1519 - res.status(500).json({ error: error.message }); 1520 - } 1521 - }); 1522 - 1523 - // ─── Site-specific OG images ───────────────────────────────────────────────── 1524 - app.get('/kidlisp-og/site/:site.png', async (req, res) => { 1525 - const site = req.params.site; 1526 - if (!['keeps', 'buy'].includes(site)) return res.status(400).json({ error: 'Invalid site', valid: ['keeps', 'buy'] }); 1527 - try { 1528 - addServerLog('info', '🖼️', `Site OG: ${site}.kidlisp.com`); 1529 - const result = await generateKidlispOGImage('mosaic', true); 1530 - const bg = await sharp(result.buffer).blur(6).toBuffer(); 1531 - const darkOverlay = Buffer.from(`<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg"><rect width="100%" height="100%" fill="rgba(0,0,0,0.4)"/></svg>`); 1532 - const prefixLetters = site === 'keeps' 1533 - ? 'keeps'.split('').map(c => `<tspan fill="#00ff41">${c}</tspan>`).join('') 1534 - : [['b','#FF6B6B'],['u','#4ECDC4'],['y','#FFE66D']].map(([c,col]) => `<tspan fill="${col}">${c}</tspan>`).join(''); 1535 - const kidlispLetters = [['K','#FF6B6B'],['i','#4ECDC4'],['d','#FFE66D'],['L','#A8E6CF'],['i','#FF8B94'],['s','#F7DC6F'],['p','#BB8FCE']].map(([c,col]) => `<tspan fill="${col}">${c}</tspan>`).join(''); 1536 - const tspans = `${prefixLetters}<tspan fill="#70D6FF">.</tspan>${kidlispLetters}<tspan fill="#70D6FF">.com</tspan>`; 1537 - const brandingSvg = Buffer.from(`<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg"><text x="600" y="340" font-family="Comic Sans MS, cursive" font-size="90" font-weight="700" text-anchor="middle" style="paint-order: stroke; stroke: black; stroke-width: 6px;">${tspans}</text></svg>`); 1538 - const composited = await sharp(bg).composite([{ input: darkOverlay }, { input: brandingSvg }]).png().toBuffer(); 1539 - res.setHeader('Content-Type', 'image/png'); 1540 - res.setHeader('Content-Length', composited.length); 1541 - res.setHeader('Cache-Control', 'public, max-age=3600'); 1542 - res.send(composited); 1543 - } catch (error) { 1544 - console.error(`Site OG error (${site}):`, error); 1545 - res.status(500).json({ error: error.message }); 1546 - } 1547 - }); 1548 - 1549 - // Dynamic OG image for kidlisp.com - rotates daily based on top hits 1550 - // Supports multiple layout options: featured, mosaic, filmstrip, code-split 1551 - app.get('/kidlisp-og', async (req, res) => { 1552 - try { 1553 - const layout = req.query.layout || 'featured'; 1554 - const force = req.query.force === 'true'; 1555 - 1556 - // Validate layout 1557 - const validLayouts = ['featured', 'mosaic', 'filmstrip', 'code-split']; 1558 - if (!validLayouts.includes(layout)) { 1559 - return res.status(400).json({ 1560 - error: 'Invalid layout', 1561 - valid: validLayouts, 1562 - }); 1563 - } 1564 - 1565 - addServerLog('info', '🖼️', `KidLisp OG request: ${layout}${force ? ' (force)' : ''}`); 1566 - 1567 - const result = await generateKidlispOGImage(layout, force); 1568 - 1569 - if (result.cached && result.url) { 1570 - // Redirect to CDN URL for cached images 1571 - addServerLog('success', '📦', `OG cache hit → ${result.url.split('/').pop()}`); 1572 - res.setHeader('X-Cache', 'HIT'); 1573 - res.setHeader('Cache-Control', 'public, max-age=3600'); 1574 - return res.redirect(302, result.url); 1575 - } 1576 - 1577 - // Fresh generation - return the buffer directly 1578 - addServerLog('success', '🎨', `OG generated: ${layout} (${result.featuredPiece?.code || 'mosaic'})`); 1579 - res.setHeader('Content-Type', 'image/png'); 1580 - res.setHeader('Content-Length', result.buffer.length); 1581 - res.setHeader('Cache-Control', 'public, max-age=86400'); // 24hr cache 1582 - res.setHeader('X-Cache', 'MISS'); 1583 - res.setHeader('X-OG-Layout', layout); 1584 - res.setHeader('X-OG-Generated', result.generatedAt); 1585 - if (result.featuredPiece) { 1586 - res.setHeader('X-OG-Featured', result.featuredPiece.code); 1587 - } 1588 - res.send(result.buffer); 1589 - 1590 - } catch (error) { 1591 - console.error('KidLisp OG error:', error); 1592 - addServerLog('error', '❌', `OG error: ${error.message}`); 1593 - res.status(500).json({ 1594 - error: 'Failed to generate OG image', 1595 - message: error.message 1596 - }); 1597 - } 1598 - }); 1599 - 1600 - // OG image cache status endpoint 1601 - app.get('/kidlisp-og/status', (req, res) => { 1602 - res.json({ 1603 - ...getOGImageCacheStatus(), 1604 - availableLayouts: ['featured', 'mosaic', 'filmstrip', 'code-split'], 1605 - usage: { 1606 - recommended: '/kidlisp-og.png (instant, for og:image tags)', 1607 - withLayout: '/kidlisp-og.png?layout=mosaic', 1608 - dynamic: '/kidlisp-og (may regenerate on-demand)', 1609 - forceRegenerate: '/kidlisp-og?force=true', 1610 - }, 1611 - note: 'Use /kidlisp-og.png for social media meta tags - it redirects instantly to cached CDN images' 1612 - }); 1613 - }); 1614 - 1615 - // Preview all OG images (generalized for kidlisp, notepat, etc) 1616 - app.get('/og-preview', (req, res) => { 1617 - const baseUrl = req.protocol + '://' + req.get('host'); 1618 - 1619 - const ogImages = [ 1620 - { 1621 - name: 'KidLisp', 1622 - slug: 'kidlisp-og', 1623 - prodUrls: [ 1624 - 'https://kidlisp.com', 1625 - 'https://aesthetic.computer/kidlisp' 1626 - ], 1627 - layouts: ['featured', 'mosaic', 'filmstrip', 'code-split'], 1628 - description: 'Dynamic layouts featuring recent KidLisp pieces' 1629 - }, 1630 - { 1631 - name: 'Notepat', 1632 - slug: 'notepat-og', 1633 - prodUrls: [ 1634 - 'https://notepat.com', 1635 - 'https://aesthetic.computer/notepat' 1636 - ], 1637 - layouts: null, // Single layout 1638 - description: 'Split-layout chromatic piano interface' 1639 - } 1640 - ]; 1641 - 1642 - res.setHeader('Content-Type', 'text/html'); 1643 - res.send(`<!DOCTYPE html> 1644 - <html> 1645 - <head> 1646 - <title>OG Image Preview</title> 1647 - <style> 1648 - body { font-family: monospace; background: #1a1a2e; color: white; padding: 20px; max-width: 1400px; margin: 0 auto; } 1649 - h1 { color: #88ff88; } 1650 - h2 { color: #ffaa00; margin-top: 40px; } 1651 - h3 { color: #ff88aa; margin-top: 20px; } 1652 - .note { background: #2a2a4e; padding: 16px; border-radius: 8px; margin: 20px 0; line-height: 1.6; } 1653 - .note code { background: #3a3a5e; padding: 2px 6px; border-radius: 4px; color: #88ffaa; } 1654 - .og-section { border: 2px solid #333; padding: 20px; border-radius: 8px; margin: 30px 0; background: #16162e; } 1655 - .prod-urls { margin: 15px 0; } 1656 - .prod-urls a { 1657 - display: inline-block; 1658 - color: #88ccff; 1659 - text-decoration: none; 1660 - background: #2a2a4e; 1661 - padding: 6px 12px; 1662 - border-radius: 4px; 1663 - margin: 4px 4px 4px 0; 1664 - } 1665 - .prod-urls a:hover { background: #3a3a5e; } 1666 - .layout { margin: 20px 0; padding: 15px; background: #0a0a1e; border-radius: 6px; } 1667 - .layout h4 { color: #ffcc66; margin: 0 0 10px 0; } 1668 - .layout img { max-width: 100%; border: 2px solid #444; border-radius: 4px; } 1669 - .layout .actions { margin: 10px 0; } 1670 - .layout .actions a { 1671 - color: #88ccff; 1672 - margin-right: 15px; 1673 - text-decoration: none; 1674 - } 1675 - .layout .actions a:hover { text-decoration: underline; } 1676 - .single-image { margin: 20px 0; } 1677 - .single-image img { max-width: 100%; border: 2px solid #444; border-radius: 4px; } 1678 - .back-link { display: inline-block; margin-top: 40px; color: #888; text-decoration: none; } 1679 - .back-link:hover { color: #aaa; } 1680 - </style> 1681 - </head> 1682 - <body> 1683 - <h1>🖼️ OG Image Preview Dashboard</h1> 1684 - <div class="note"> 1685 - <strong>About:</strong> This page shows all Open Graph (OG) images used for social media previews.<br> 1686 - <strong>Usage:</strong> Use the <code>.png</code> endpoints in meta tags for instant CDN redirects (no timeouts).<br> 1687 - <strong>Testing:</strong> Click production URLs below to verify OG tags are working correctly. 1688 - </div> 1689 - 1690 - ${ogImages.map(og => ` 1691 - <div class="og-section"> 1692 - <h2>${og.name}</h2> 1693 - <p style="color: #aaa; margin: 10px 0;">${og.description}</p> 1694 - 1695 - <div class="prod-urls"> 1696 - <strong style="color: #88ff88;">Production URLs:</strong><br> 1697 - ${og.prodUrls.map(url => `<a href="${url}" target="_blank">${url} →</a>`).join(' ')} 1698 - </div> 1699 - 1700 - <div class="note"> 1701 - <strong>OG Endpoint:</strong> <code>${baseUrl}/${og.slug}.png</code> 1702 - </div> 1703 - 1704 - ${og.layouts ? ` 1705 - <h3>Layouts:</h3> 1706 - ${og.layouts.map(layout => ` 1707 - <div class="layout"> 1708 - <h4>${layout.charAt(0).toUpperCase() + layout.slice(1)}</h4> 1709 - <div class="actions"> 1710 - <a href="${baseUrl}/${og.slug}?layout=${layout}&force=true">Force Regenerate</a> 1711 - <a href="${baseUrl}/${og.slug}/status">Cache Status</a> 1712 - </div> 1713 - <img src="${baseUrl}/${og.slug}?layout=${layout}" alt="${layout} layout" loading="lazy"> 1714 - </div> 1715 - `).join('')} 1716 - ` : ` 1717 - <div class="single-image"> 1718 - <div class="actions"> 1719 - <a href="${baseUrl}/${og.slug}.png?force=true">Force Regenerate</a> 1720 - </div> 1721 - <img src="${baseUrl}/${og.slug}.png" alt="${og.name} OG image" loading="lazy"> 1722 - </div> 1723 - `} 1724 - </div> 1725 - `).join('')} 1726 - 1727 - <a href="/" class="back-link">← Back to Oven Dashboard</a> 1728 - </body> 1729 - </html>`); 1730 - }); 1731 - 1732 - // Legacy redirect for old kidlisp preview URL 1733 - app.get('/kidlisp-og/preview', (req, res) => { 1734 - res.redirect(302, '/og-preview'); 1735 - }); 1736 - 1737 - // Notepat branded OG image for notepat.com 1738 - app.get('/notepat-og.png', async (req, res) => { 1739 - try { 1740 - const force = req.query.force === 'true'; 1741 - 1742 - addServerLog('info', '🎹', `Notepat OG request${force ? ' (force)' : ''}`); 1743 - 1744 - const result = await generateNotepatOGImage(force); 1745 - 1746 - if (result.cached && result.url) { 1747 - // Proxy the image back instead of redirecting (iOS crawlers won't follow 301s on og:image) 1748 - addServerLog('success', '📦', `Notepat OG cache hit → proxying`); 1749 - try { 1750 - const cdnResponse = await fetch(result.url); 1751 - if (!cdnResponse.ok) throw new Error(`CDN fetch failed: ${cdnResponse.status}`); 1752 - const buffer = Buffer.from(await cdnResponse.arrayBuffer()); 1753 - res.setHeader('Content-Type', 'image/png'); 1754 - res.setHeader('Content-Length', buffer.length); 1755 - res.setHeader('Cache-Control', 'public, max-age=604800'); // 7-day cache 1756 - res.setHeader('X-Cache', 'HIT'); 1757 - return res.send(buffer); 1758 - } catch (fetchErr) { 1759 - // Fall back to redirect if proxy fails 1760 - addServerLog('warn', '⚠️', `Notepat OG proxy failed, falling back to redirect: ${fetchErr.message}`); 1761 - return res.redirect(301, result.url); 1762 - } 1763 - } 1764 - 1765 - // Fresh generation - return the buffer directly 1766 - addServerLog('success', '🎨', `Notepat OG generated`); 1767 - res.setHeader('Content-Type', 'image/png'); 1768 - res.setHeader('Content-Length', result.buffer.length); 1769 - res.setHeader('Cache-Control', 'public, max-age=604800'); // 7-day cache 1770 - res.setHeader('X-Cache', 'MISS'); 1771 - res.send(result.buffer); 1772 - 1773 - } catch (error) { 1774 - console.error('Notepat OG error:', error); 1775 - addServerLog('error', '❌', `Notepat OG error: ${error.message}`); 1776 - res.status(500).json({ 1777 - error: 'Failed to generate Notepat OG image', 1778 - message: error.message 1779 - }); 1780 - } 1781 - }); 1782 - 1783 - // ============================================================================= 1784 - // KidLisp Backdrop - Animated WebP for login screens, Auth0, etc. 1785 - // ============================================================================= 1786 - 1787 - // Fast redirect to CDN-cached 2048px animated webp 1788 - app.get('/kidlisp-backdrop.webp', async (req, res) => { 1789 - try { 1790 - // Get cached URL without triggering generation (fast!) 1791 - const url = await getLatestBackdropUrl(); 1792 - 1793 - if (url) { 1794 - res.setHeader('Cache-Control', 'public, max-age=3600'); 1795 - res.setHeader('X-Cache', 'CDN'); 1796 - return res.redirect(301, url); 1797 - } 1798 - 1799 - // No cached backdrop - generate synchronously (first request will be slow) 1800 - addServerLog('warn', '⚠️', 'Backdrop cache miss, generating...'); 1801 - 1802 - const result = await generateKidlispBackdrop(false); 1803 - if (result.url) { 1804 - res.setHeader('Cache-Control', 'public, max-age=3600'); 1805 - res.setHeader('X-Cache', 'MISS'); 1806 - return res.redirect(302, result.url); 1807 - } 1808 - 1809 - res.status(503).json({ error: 'Backdrop generation in progress, try again shortly' }); 1810 - 1811 - } catch (error) { 1812 - console.error('Backdrop error:', error); 1813 - res.status(500).json({ error: 'Failed to get backdrop', message: error.message }); 1814 - } 1815 - }); 1816 - 1817 - // Dynamic backdrop generation (may regenerate on-demand) 1818 - app.get('/kidlisp-backdrop', async (req, res) => { 1819 - try { 1820 - const force = req.query.force === 'true'; 1821 - 1822 - addServerLog('info', '🖼️', `Backdrop request${force ? ' (force)' : ''}`); 1823 - 1824 - const result = await generateKidlispBackdrop(force); 1825 - 1826 - if (result.url) { 1827 - addServerLog('success', '🎨', `Backdrop: ${result.piece} → ${result.cached ? 'cached' : 'generated'}`); 1828 - res.setHeader('Cache-Control', 'public, max-age=3600'); 1829 - res.setHeader('X-Cache', result.cached ? 'HIT' : 'MISS'); 1830 - res.setHeader('X-Backdrop-Piece', result.piece || 'unknown'); 1831 - return res.redirect(302, result.url); 1832 - } 1833 - 1834 - res.status(500).json({ error: 'Failed to generate backdrop' }); 1835 - 1836 - } catch (error) { 1837 - console.error('Backdrop error:', error); 1838 - addServerLog('error', '❌', `Backdrop error: ${error.message}`); 1839 - res.status(500).json({ error: 'Failed to generate backdrop', message: error.message }); 1840 - } 1841 - }); 1842 - 1843 - // ============================================================================= 1844 - // App Store Screenshots - Generate screenshots for Google Play / App Store 1845 - // ============================================================================= 1846 - 1847 - // App screenshots dashboard 1848 - app.get('/app-screenshots', (req, res) => { 1849 - const piece = req.query.piece || 'prompt'; 1850 - const presets = Object.entries(APP_SCREENSHOT_PRESETS); 1851 - 1852 - res.setHeader('Content-Type', 'text/html'); 1853 - res.send(`<!DOCTYPE html> 1854 - <html> 1855 - <head> 1856 - <meta charset="utf-8"> 1857 - <title>📱 App Store Screenshots - Oven</title> 1858 - <meta name="viewport" content="width=device-width, initial-scale=1"> 1859 - <style> 1860 - * { box-sizing: border-box; margin: 0; padding: 0; } 1861 - body { 1862 - font-family: monospace; 1863 - font-size: 14px; 1864 - background: #0a0a12; 1865 - color: #fff; 1866 - min-height: 100vh; 1867 - padding: 20px; 1868 - } 1869 - header { 1870 - display: flex; 1871 - align-items: center; 1872 - justify-content: space-between; 1873 - flex-wrap: wrap; 1874 - gap: 1em; 1875 - padding-bottom: 20px; 1876 - border-bottom: 2px solid #333; 1877 - margin-bottom: 20px; 1878 - } 1879 - h1 { color: #88ff88; font-size: 1.5em; } 1880 - .controls { 1881 - display: flex; 1882 - gap: 1em; 1883 - align-items: center; 1884 - flex-wrap: wrap; 1885 - } 1886 - input, select, button { 1887 - font-family: monospace; 1888 - font-size: 14px; 1889 - padding: 8px 12px; 1890 - border: 1px solid #444; 1891 - background: #1a1a2e; 1892 - color: #fff; 1893 - border-radius: 4px; 1894 - } 1895 - button { 1896 - cursor: pointer; 1897 - background: #2a2a4e; 1898 - } 1899 - button:hover { background: #3a3a5e; border-color: #88ff88; } 1900 - button:disabled { opacity: 0.5; cursor: not-allowed; } 1901 - .btn-primary { background: #226622; border-color: #88ff88; } 1902 - .btn-primary:hover { background: #338833; } 1903 - 1904 - .requirements { 1905 - background: #1a1a2e; 1906 - padding: 15px; 1907 - border-radius: 8px; 1908 - margin-bottom: 20px; 1909 - border: 1px solid #333; 1910 - } 1911 - .requirements h3 { color: #ffaa00; margin-bottom: 10px; } 1912 - .requirements ul { list-style: none; } 1913 - .requirements li { margin: 5px 0; padding-left: 20px; position: relative; } 1914 - .requirements li::before { content: '✓'; position: absolute; left: 0; color: #88ff88; } 1915 - 1916 - .category { margin-bottom: 30px; } 1917 - .category h2 { 1918 - color: #ffaa00; 1919 - margin-bottom: 15px; 1920 - padding-bottom: 10px; 1921 - border-bottom: 1px solid #333; 1922 - } 1923 - .screenshots { 1924 - display: grid; 1925 - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); 1926 - gap: 20px; 1927 - } 1928 - .screenshot { 1929 - background: #1a1a2e; 1930 - border: 1px solid #333; 1931 - border-radius: 8px; 1932 - overflow: hidden; 1933 - } 1934 - .screenshot:hover { border-color: #88ff88; } 1935 - .screenshot-preview { 1936 - background: #000; 1937 - display: flex; 1938 - align-items: center; 1939 - justify-content: center; 1940 - min-height: 200px; 1941 - position: relative; 1942 - } 1943 - .screenshot-preview img { 1944 - max-width: 100%; 1945 - max-height: 300px; 1946 - object-fit: contain; 1947 - } 1948 - .screenshot-preview .loading { 1949 - position: absolute; 1950 - color: #888; 1951 - text-align: center; 1952 - padding: 10px; 1953 - display: flex; 1954 - flex-direction: column; 1955 - align-items: center; 1956 - justify-content: center; 1957 - } 1958 - .screenshot-preview .loading .preview-img { 1959 - width: 80px; 1960 - height: 80px; 1961 - image-rendering: pixelated; 1962 - border: 1px solid #333; 1963 - margin-bottom: 8px; 1964 - display: none; 1965 - } 1966 - .screenshot-preview .loading .progress-text { 1967 - font-size: 11px; 1968 - margin-top: 8px; 1969 - color: #88ff88; 1970 - font-family: monospace; 1971 - max-width: 150px; 1972 - word-break: break-word; 1973 - } 1974 - .screenshot-preview .loading .progress-bar { 1975 - width: 80%; 1976 - max-width: 150px; 1977 - height: 4px; 1978 - background: #333; 1979 - border-radius: 2px; 1980 - margin: 8px auto 0; 1981 - overflow: hidden; 1982 - } 1983 - .screenshot-preview .loading .progress-bar-fill { 1984 - height: 100%; 1985 - background: #88ff88; 1986 - width: 0%; 1987 - transition: width 0.3s ease; 1988 - } 1989 - .screenshot-preview .error { 1990 - color: #ff4444; 1991 - padding: 20px; 1992 - text-align: center; 1993 - } 1994 - .screenshot-info { 1995 - padding: 15px; 1996 - } 1997 - .screenshot-info h4 { margin-bottom: 8px; } 1998 - .screenshot-info .dims { 1999 - color: #888; 2000 - font-size: 12px; 2001 - margin-bottom: 10px; 2002 - } 2003 - .screenshot-info .actions { 2004 - display: flex; 2005 - gap: 8px; 2006 - flex-wrap: wrap; 2007 - } 2008 - .screenshot-info .actions a, .screenshot-info .actions button { 2009 - font-size: 12px; 2010 - padding: 6px 10px; 2011 - text-decoration: none; 2012 - color: #88ccff; 2013 - } 2014 - .status { 2015 - position: fixed; 2016 - bottom: 20px; 2017 - right: 20px; 2018 - background: #2a2a4e; 2019 - padding: 15px 20px; 2020 - border-radius: 8px; 2021 - border: 1px solid #444; 2022 - display: none; 2023 - } 2024 - .status.show { display: block; } 2025 - .status.success { border-color: #88ff88; } 2026 - .status.error { border-color: #ff4444; } 2027 - 2028 - .back-link { 2029 - color: #88ccff; 2030 - text-decoration: none; 2031 - margin-bottom: 20px; 2032 - display: inline-block; 2033 - } 2034 - .back-link:hover { text-decoration: underline; } 2035 - </style> 2036 - </head> 2037 - <body> 2038 - <a href="/" class="back-link">← Back to Oven Dashboard</a> 2039 - 2040 - <header> 2041 - <h1>📱 App Store Screenshots</h1> 2042 - <div class="controls"> 2043 - <label>Piece: <input type="text" id="piece-input" value="${piece}" placeholder="prompt"></label> 2044 - <button onclick="changePiece()">Load</button> 2045 - <button onclick="regenerateAll()" class="btn-primary">🔄 Regenerate All</button> 2046 - <button onclick="downloadZip()" class="btn-primary">📦 Download ZIP</button> 2047 - </div> 2048 - </header> 2049 - 2050 - <div class="requirements"> 2051 - <h3>📋 Google Play Requirements</h3> 2052 - <ul> 2053 - <li>PNG or JPEG, max 8MB each</li> 2054 - <li>16:9 or 9:16 aspect ratio</li> 2055 - <li>Phone: 320-3840px per side, 1080px min for promotion</li> 2056 - <li>7" Tablet: 320-3840px per side</li> 2057 - <li>10" Tablet: 1080-7680px per side</li> 2058 - <li>2-8 screenshots per category required</li> 2059 - </ul> 2060 - </div> 2061 - 2062 - <div class="category"> 2063 - <h2>📱 Phone Screenshots</h2> 2064 - <div class="screenshots"> 2065 - ${presets.filter(([k, v]) => v.category === 'phone').map(([key, preset]) => ` 2066 - <div class="screenshot" data-preset="${key}"> 2067 - <div class="screenshot-preview"> 2068 - <span class="loading" data-loading="${key}"> 2069 - <img class="preview-img" alt="preview"> 2070 - <span class="loading-text">🔥 Loading...</span> 2071 - <div class="progress-text" data-progress-text="${key}"></div> 2072 - <div class="progress-bar"><div class="progress-bar-fill" data-progress-bar="${key}"></div></div> 2073 - </span> 2074 - <img src="/app-screenshots/${key}/${piece}.png" 2075 - alt="${preset.label}" 2076 - data-img="${key}" 2077 - onload="this.previousElementSibling.style.display='none'" 2078 - onerror="this.style.display='none'; this.previousElementSibling.innerHTML='❌ Failed to load'"> 2079 - </div> 2080 - <div class="screenshot-info"> 2081 - <h4>${preset.label}</h4> 2082 - <div class="dims">${preset.width} × ${preset.height}px</div> 2083 - <div class="actions"> 2084 - <a href="/app-screenshots/${key}/${piece}.png" download="${piece}-${key}.png">⬇️ Download</a> 2085 - <button onclick="regenerate('${key}')">🔄 Regenerate</button> 2086 - </div> 2087 - </div> 2088 - </div> 2089 - `).join('')} 2090 - </div> 2091 - </div> 2092 - 2093 - <div class="category"> 2094 - <h2>📱 7-inch Tablet Screenshots</h2> 2095 - <div class="screenshots"> 2096 - ${presets.filter(([k, v]) => v.category === 'tablet7').map(([key, preset]) => ` 2097 - <div class="screenshot" data-preset="${key}"> 2098 - <div class="screenshot-preview"> 2099 - <span class="loading" data-loading="${key}"> 2100 - <img class="preview-img" alt="preview"> 2101 - <span class="loading-text">🔥 Loading...</span> 2102 - <div class="progress-text" data-progress-text="${key}"></div> 2103 - <div class="progress-bar"><div class="progress-bar-fill" data-progress-bar="${key}"></div></div> 2104 - </span> 2105 - <img src="/app-screenshots/${key}/${piece}.png" 2106 - alt="${preset.label}" 2107 - data-img="${key}" 2108 - onload="this.previousElementSibling.style.display='none'" 2109 - onerror="this.style.display='none'; this.previousElementSibling.innerHTML='❌ Failed to load'"> 2110 - </div> 2111 - <div class="screenshot-info"> 2112 - <h4>${preset.label}</h4> 2113 - <div class="dims">${preset.width} × ${preset.height}px</div> 2114 - <div class="actions"> 2115 - <a href="/app-screenshots/${key}/${piece}.png" download="${piece}-${key}.png">⬇️ Download</a> 2116 - <button onclick="regenerate('${key}')">🔄 Regenerate</button> 2117 - </div> 2118 - </div> 2119 - </div> 2120 - `).join('')} 2121 - </div> 2122 - </div> 2123 - 2124 - <div class="category"> 2125 - <h2>📱 10-inch Tablet Screenshots</h2> 2126 - <div class="screenshots"> 2127 - ${presets.filter(([k, v]) => v.category === 'tablet10').map(([key, preset]) => ` 2128 - <div class="screenshot" data-preset="${key}"> 2129 - <div class="screenshot-preview"> 2130 - <span class="loading" data-loading="${key}"> 2131 - <img class="preview-img" alt="preview"> 2132 - <span class="loading-text">🔥 Loading...</span> 2133 - <div class="progress-text" data-progress-text="${key}"></div> 2134 - <div class="progress-bar"><div class="progress-bar-fill" data-progress-bar="${key}"></div></div> 2135 - </span> 2136 - <img src="/app-screenshots/${key}/${piece}.png" 2137 - alt="${preset.label}" 2138 - data-img="${key}" 2139 - onload="this.previousElementSibling.style.display='none'" 2140 - onerror="this.style.display='none'; this.previousElementSibling.innerHTML='❌ Failed to load'"> 2141 - </div> 2142 - <div class="screenshot-info"> 2143 - <h4>${preset.label}</h4> 2144 - <div class="dims">${preset.width} × ${preset.height}px</div> 2145 - <div class="actions"> 2146 - <a href="/app-screenshots/${key}/${piece}.png" download="${piece}-${key}.png">⬇️ Download</a> 2147 - <button onclick="regenerate('${key}')">🔄 Regenerate</button> 2148 - </div> 2149 - </div> 2150 - </div> 2151 - `).join('')} 2152 - </div> 2153 - </div> 2154 - 2155 - <div id="status" class="status"></div> 2156 - 2157 - <script> 2158 - const currentPiece = '${piece}'; 2159 - 2160 - function showStatus(msg, type = 'info') { 2161 - const el = document.getElementById('status'); 2162 - el.textContent = msg; 2163 - el.className = 'status show ' + type; 2164 - setTimeout(() => el.className = 'status', 3000); 2165 - } 2166 - 2167 - function changePiece() { 2168 - const piece = document.getElementById('piece-input').value.trim() || 'prompt'; 2169 - window.location.href = '/app-screenshots?piece=' + encodeURIComponent(piece); 2170 - } 2171 - 2172 - document.getElementById('piece-input').addEventListener('keydown', (e) => { 2173 - if (e.key === 'Enter') changePiece(); 2174 - }); 2175 - 2176 - async function regenerate(preset) { 2177 - showStatus('Regenerating ' + preset + '... (this takes ~30s)'); 2178 - console.log('🔄 Starting regeneration for:', preset); 2179 - 2180 - // Show loading indicator and hide current image 2181 - const card = document.querySelector('[data-preset="' + preset + '"]'); 2182 - const img = card.querySelector('[data-img]'); 2183 - const loading = card.querySelector('.loading'); 2184 - 2185 - img.style.display = 'none'; 2186 - loading.style.display = 'flex'; 2187 - loading.innerHTML = '<img class="preview-img" alt="preview"><span class="loading-text">🔄 Regenerating...</span><div class="progress-text"></div><div class="progress-bar"><div class="progress-bar-fill" style="width:0%"></div></div>'; 2188 - 2189 - const startTime = Date.now(); 2190 - 2191 - try { 2192 - const controller = new AbortController(); 2193 - const timeoutId = setTimeout(() => controller.abort(), 120000); // 2 min timeout 2194 - 2195 - console.log('📡 Fetching with force=true...'); 2196 - const res = await fetch('/app-screenshots/' + preset + '/' + currentPiece + '.png?force=true&t=' + Date.now(), { 2197 - signal: controller.signal, 2198 - cache: 'no-store', 2199 - headers: { 'Cache-Control': 'no-cache' } 2200 - }); 2201 - clearTimeout(timeoutId); 2202 - 2203 - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); 2204 - console.log('📡 Response received after ' + elapsed + 's, status:', res.status); 2205 - 2206 - if (res.ok) { 2207 - // Force reload the image with cache-busting 2208 - const newSrc = '/app-screenshots/' + preset + '/' + currentPiece + '.png?t=' + Date.now(); 2209 - console.log('🖼️ Setting new image src:', newSrc); 2210 - img.src = newSrc; 2211 - img.style.display = 'block'; 2212 - loading.style.display = 'none'; 2213 - showStatus('✅ ' + preset + ' regenerated in ' + elapsed + 's!', 'success'); 2214 - } else { 2215 - const error = await res.text(); 2216 - console.error('❌ Regeneration failed:', res.status, error); 2217 - loading.innerHTML = '❌ Failed: ' + (error || res.status); 2218 - showStatus('❌ Failed to regenerate: ' + res.status, 'error'); 2219 - } 2220 - } catch (err) { 2221 - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); 2222 - console.error('❌ Regeneration error after ' + elapsed + 's:', err); 2223 - if (err.name === 'AbortError') { 2224 - loading.innerHTML = '⏱️ Timeout - still processing?'; 2225 - showStatus('⏱️ Request timed out - try refreshing', 'error'); 2226 - } else { 2227 - loading.innerHTML = '❌ ' + err.message; 2228 - showStatus('❌ ' + err.message, 'error'); 2229 - } 2230 - } 2231 - } 2232 - 2233 - async function regenerateAll() { 2234 - const presets = ${JSON.stringify(Object.keys(APP_SCREENSHOT_PRESETS))}; 2235 - showStatus('Regenerating all screenshots...'); 2236 - 2237 - for (const preset of presets) { 2238 - showStatus('Regenerating ' + preset + '...'); 2239 - try { 2240 - await fetch('/app-screenshots/' + preset + '/' + currentPiece + '.png?force=true'); 2241 - } catch (err) { 2242 - console.error('Failed:', preset, err); 2243 - } 2244 - } 2245 - 2246 - showStatus('✅ All screenshots regenerated! Reloading...', 'success'); 2247 - setTimeout(() => window.location.reload(), 1000); 2248 - } 2249 - 2250 - function downloadZip() { 2251 - showStatus('Preparing ZIP download...'); 2252 - window.location.href = '/app-screenshots/download/' + currentPiece; 2253 - } 2254 - 2255 - // WebSocket for real-time progress updates 2256 - const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; 2257 - let ws = null; 2258 - let reconnectAttempts = 0; 2259 - 2260 - function connectWebSocket() { 2261 - ws = new WebSocket(protocol + '//' + location.host + '/ws'); 2262 - 2263 - ws.onopen = () => { 2264 - console.log('📡 WebSocket connected'); 2265 - reconnectAttempts = 0; 2266 - }; 2267 - 2268 - ws.onclose = () => { 2269 - console.log('📡 WebSocket disconnected, reconnecting...'); 2270 - const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000); 2271 - reconnectAttempts++; 2272 - setTimeout(connectWebSocket, delay); 2273 - }; 2274 - 2275 - ws.onerror = () => ws.close(); 2276 - 2277 - ws.onmessage = (event) => { 2278 - try { 2279 - const data = JSON.parse(event.data); 2280 - 2281 - // Check if there's active grab progress for our piece 2282 - if (data.grabs && data.grabs.active) { 2283 - const activeGrab = data.grabs.active.find(g => 2284 - g.piece === currentPiece || g.piece === '$' + currentPiece 2285 - ); 2286 - 2287 - if (activeGrab) { 2288 - // Find which preset this matches (by dimensions) 2289 - for (const [preset, config] of Object.entries(${JSON.stringify(APP_SCREENSHOT_PRESETS)})) { 2290 - if (activeGrab.dimensions && 2291 - activeGrab.dimensions.width === config.width && 2292 - activeGrab.dimensions.height === config.height) { 2293 - updateProgressUI(preset, activeGrab.status, null); 2294 - } 2295 - } 2296 - } 2297 - } 2298 - } catch (err) { 2299 - console.error('WebSocket parse error:', err); 2300 - } 2301 - }; 2302 - } 2303 - 2304 - // Poll for detailed progress since grabs report to /grab-status 2305 - async function pollProgress() { 2306 - try { 2307 - const res = await fetch('/grab-status'); 2308 - const data = await res.json(); 2309 - 2310 - if (data.progress && data.progress.piece) { 2311 - const piece = data.progress.piece; 2312 - if (piece === currentPiece || piece === '$' + currentPiece) { 2313 - // Check queue position for this piece 2314 - let queuePosition = null; 2315 - if (data.queue && data.queue.length > 0) { 2316 - const queueItem = data.queue.find(q => q.piece === piece); 2317 - if (queueItem) queuePosition = queueItem.position; 2318 - } 2319 - 2320 - // Find matching preset by checking dimensions in active grabs 2321 - if (data.active && data.active.length > 0) { 2322 - const activeGrab = data.active.find(g => 2323 - g.piece === currentPiece || g.piece === '$' + currentPiece 2324 - ); 2325 - if (activeGrab && activeGrab.dimensions) { 2326 - for (const [preset, config] of Object.entries(${JSON.stringify(APP_SCREENSHOT_PRESETS)})) { 2327 - if (activeGrab.dimensions.width === config.width && 2328 - activeGrab.dimensions.height === config.height) { 2329 - updateProgressUI(preset, data.progress.stage, data.progress.percent, data.progress.stageDetail, null, queuePosition); 2330 - break; 2331 - } 2332 - } 2333 - } 2334 - } 2335 - 2336 - // Fallback: update all visible loading indicators with generic progress 2337 - document.querySelectorAll('.loading[data-loading]').forEach(el => { 2338 - if (el.style.display !== 'none') { 2339 - const progressText = el.querySelector('.progress-text'); 2340 - const progressBar = el.querySelector('.progress-bar-fill'); 2341 - const previewImg = el.querySelector('.preview-img'); 2342 - 2343 - if (progressText && data.progress.stageDetail) { 2344 - progressText.textContent = data.progress.stageDetail; 2345 - } 2346 - if (progressBar && data.progress.percent) { 2347 - progressBar.style.width = data.progress.percent + '%'; 2348 - } 2349 - // Display streaming preview if available 2350 - if (previewImg && data.progress.previewFrame) { 2351 - previewImg.src = 'data:image/jpeg;base64,' + data.progress.previewFrame; 2352 - previewImg.style.display = 'block'; 2353 - } 2354 - } 2355 - }); 2356 - } 2357 - } 2358 - } catch (err) { 2359 - // Ignore polling errors 2360 - } 2361 - } 2362 - 2363 - function updateProgressUI(preset, stage, percent, detail, previewFrame, queuePosition) { 2364 - const loading = document.querySelector('[data-loading="' + preset + '"]'); 2365 - if (!loading || loading.style.display === 'none') return; 2366 - 2367 - const progressText = loading.querySelector('.progress-text'); 2368 - const progressBar = loading.querySelector('.progress-bar-fill'); 2369 - const previewImg = loading.querySelector('.preview-img'); 2370 - 2371 - // Map stage to friendly text 2372 - const stageText = { 2373 - 'loading': '🚀 Loading piece...', 2374 - 'waiting-content': '⏳ Waiting for render...', 2375 - 'settling': '⏸️ Settling...', 2376 - 'capturing': '📸 Capturing...', 2377 - 'encoding': '🔄 Processing...', 2378 - 'uploading': '☁️ Uploading...', 2379 - 'queued': queuePosition ? '⏳ In queue (#' + queuePosition + ')...' : '⏳ In queue...', 2380 - }; 2381 - 2382 - if (progressText) { 2383 - progressText.textContent = detail || stageText[stage] || stage || ''; 2384 - } 2385 - if (progressBar && percent != null) { 2386 - progressBar.style.width = percent + '%'; 2387 - } 2388 - // Show streaming preview 2389 - if (previewImg && previewFrame) { 2390 - previewImg.src = 'data:image/jpeg;base64,' + previewFrame; 2391 - previewImg.style.display = 'block'; 2392 - } 2393 - } 2394 - 2395 - // Start WebSocket and polling 2396 - connectWebSocket(); 2397 - const pollInterval = setInterval(pollProgress, 150); // Poll fast for smooth previews 2398 - 2399 - // Cleanup on page unload 2400 - window.addEventListener('beforeunload', () => { 2401 - clearInterval(pollInterval); 2402 - if (ws) ws.close(); 2403 - }); 2404 - </script> 2405 - </body> 2406 - </html>`); 2407 - }); 2408 - 2409 - // Individual app screenshot endpoint 2410 - app.get('/app-screenshots/:preset/:piece.png', async (req, res) => { 2411 - const { preset, piece } = req.params; 2412 - const force = req.query.force === 'true'; 2413 - 2414 - const presetConfig = APP_SCREENSHOT_PRESETS[preset]; 2415 - if (!presetConfig) { 2416 - return res.status(400).json({ 2417 - error: 'Invalid preset', 2418 - valid: Object.keys(APP_SCREENSHOT_PRESETS) 2419 - }); 2420 - } 2421 - 2422 - const { width, height } = presetConfig; 2423 - 2424 - try { 2425 - addServerLog('capture', '📱', `App screenshot: ${piece} (${preset} ${width}×${height}${force ? ' FORCE' : ''})`); 2426 - 2427 - const { cdnUrl, fromCache, buffer } = await getCachedOrGenerate( 2428 - 'app-screenshots', 2429 - `${piece}-${preset}`, 2430 - width, 2431 - height, 2432 - async () => { 2433 - const result = await grabPiece(piece, { 2434 - format: 'png', 2435 - width, 2436 - height, 2437 - density: 4, // Pixel art - larger art pixels (4x) 2438 - viewportScale: 1, // Capture at exact output size 2439 - skipCache: force, 2440 - }); 2441 - 2442 - if (!result.success) throw new Error(result.error); 2443 - 2444 - // Handle cached result (cdnUrl but no buffer) 2445 - if (result.cached && result.cdnUrl && !result.buffer) { 2446 - const response = await fetch(result.cdnUrl); 2447 - if (!response.ok) throw new Error(`Failed to fetch cached screenshot: ${response.status}`); 2448 - return Buffer.from(await response.arrayBuffer()); 2449 - } 2450 - 2451 - return result.buffer; 2452 - }, 2453 - 'png', // ext 2454 - force // skipCache - pass force flag to skip CDN cache 2455 - ); 2456 - 2457 - if (fromCache && cdnUrl && !force) { 2458 - res.setHeader('X-Cache', 'HIT'); 2459 - res.setHeader('Cache-Control', 'public, max-age=604800'); // 7 days 2460 - return res.redirect(302, cdnUrl); 2461 - } 2462 - 2463 - res.setHeader('Content-Type', 'image/png'); 2464 - res.setHeader('Content-Length', buffer.length); 2465 - // When force=true, prevent caching 2466 - res.setHeader('Cache-Control', force ? 'no-store, no-cache, must-revalidate' : 'public, max-age=86400'); 2467 - res.setHeader('X-Cache', force ? 'REGENERATED' : 'MISS'); 2468 - res.setHeader('X-Screenshot-Preset', preset); 2469 - res.setHeader('X-Screenshot-Dimensions', `${width}x${height}`); 2470 - res.send(buffer); 2471 - 2472 - } catch (error) { 2473 - console.error('App screenshot error:', error); 2474 - addServerLog('error', '❌', `App screenshot failed: ${piece} ${preset} - ${error.message}`); 2475 - res.status(500).json({ error: error.message }); 2476 - } 2477 - }); 2478 - 2479 - // Bulk ZIP download endpoint 2480 - app.get('/app-screenshots/download/:piece', async (req, res) => { 2481 - const { piece } = req.params; 2482 - const presets = Object.entries(APP_SCREENSHOT_PRESETS); 2483 - 2484 - addServerLog('info', '📦', `Generating ZIP for ${piece} (${presets.length} screenshots)`); 2485 - 2486 - res.setHeader('Content-Type', 'application/zip'); 2487 - res.setHeader('Content-Disposition', `attachment; filename="${piece}-app-screenshots.zip"`); 2488 - 2489 - const archive = archiver('zip', { zlib: { level: 9 } }); 2490 - archive.pipe(res); 2491 - 2492 - for (const [presetKey, preset] of presets) { 2493 - try { 2494 - const { cdnUrl, buffer } = await getCachedOrGenerate( 2495 - 'app-screenshots', 2496 - `${piece}-${presetKey}`, 2497 - preset.width, 2498 - preset.height, 2499 - async () => { 2500 - const result = await grabPiece(piece, { 2501 - format: 'png', 2502 - width: preset.width, 2503 - height: preset.height, 2504 - density: 4, // Pixel art - larger art pixels (4x) 2505 - viewportScale: 1, // Capture at exact output size 2506 - }); 2507 - 2508 - if (!result.success) throw new Error(result.error); 2509 - 2510 - if (result.cached && result.cdnUrl && !result.buffer) { 2511 - const response = await fetch(result.cdnUrl); 2512 - if (!response.ok) throw new Error(`Failed to fetch: ${response.status}`); 2513 - return Buffer.from(await response.arrayBuffer()); 2514 - } 2515 - 2516 - return result.buffer; 2517 - } 2518 - ); 2519 - 2520 - // Get buffer from CDN if we only have URL 2521 - let imageBuffer = buffer; 2522 - if (!imageBuffer && cdnUrl) { 2523 - const response = await fetch(cdnUrl); 2524 - if (response.ok) { 2525 - imageBuffer = Buffer.from(await response.arrayBuffer()); 2526 - } 2527 - } 2528 - 2529 - if (imageBuffer) { 2530 - const filename = `${preset.category}/${piece}-${presetKey}.png`; 2531 - archive.append(imageBuffer, { name: filename }); 2532 - addServerLog('success', '✅', `Added to ZIP: ${filename}`); 2533 - } 2534 - } catch (err) { 2535 - console.error(`Failed to add ${presetKey} to ZIP:`, err); 2536 - addServerLog('error', '❌', `ZIP: Failed ${presetKey} - ${err.message}`); 2537 - } 2538 - } 2539 - 2540 - archive.finalize(); 2541 - }); 2542 - 2543 - // JSON API for app screenshots status 2544 - app.get('/api/app-screenshots/:piece', async (req, res) => { 2545 - const { piece } = req.params; 2546 - const screenshots = {}; 2547 - 2548 - for (const [key, preset] of Object.entries(APP_SCREENSHOT_PRESETS)) { 2549 - screenshots[key] = { 2550 - ...preset, 2551 - url: `/app-screenshots/${key}/${piece}.png`, 2552 - downloadUrl: `/app-screenshots/${key}/${piece}.png?download=true`, 2553 - }; 2554 - } 2555 - 2556 - res.json({ 2557 - piece, 2558 - presets: screenshots, 2559 - zipUrl: `/app-screenshots/download/${piece}`, 2560 - dashboardUrl: `/app-screenshots?piece=${piece}`, 2561 - }); 2562 - }); 2563 - 2564 - // ─── Pack HTML endpoint (alias: /bundle-html) ────────────────────── 2565 - 2566 - app.get(['/pack-html', '/bundle-html'], async (req, res) => { 2567 - const code = req.query.code; 2568 - const piece = req.query.piece; 2569 - const format = req.query.format || 'html'; 2570 - const nocache = req.query.nocache === '1' || req.query.nocache === 'true'; 2571 - const nocompress = req.query.nocompress === '1' || req.query.nocompress === 'true'; 2572 - const nominify = req.query.nominify === '1' || req.query.nominify === 'true'; 2573 - const brotli = req.query.brotli === '1' || req.query.brotli === 'true'; 2574 - const inline = req.query.inline === '1' || req.query.inline === 'true'; 2575 - const noboxart = req.query.noboxart === '1' || req.query.noboxart === 'true'; 2576 - const keeplabel = req.query.keeplabel === '1' || req.query.keeplabel === 'true'; 2577 - const density = parseInt(req.query.density) || null; 2578 - const mode = req.query.mode; 2579 - 2580 - // Device mode: simple iframe wrapper (fast path) 2581 - if (mode === 'device') { 2582 - const pieceCode = code || piece; 2583 - if (!pieceCode) return res.status(400).send('Missing code or piece parameter'); 2584 - return res.set({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=60' }).send(generateDeviceHTML(pieceCode, density)); 2585 - } 2586 - 2587 - setSkipMinification(nominify); 2588 - 2589 - const isJSPiece = !!piece; 2590 - const bundleTarget = piece || code; 2591 - if (!bundleTarget) { 2592 - return res.status(400).json({ error: "Missing 'code' or 'piece' parameter.", usage: { kidlisp: "/pack-html?code=39j", javascript: "/pack-html?piece=notepat" } }); 2593 - } 2594 - 2595 - // M4D mode: .amxd binary 2596 - if (format === 'm4d') { 2597 - try { 2598 - const onProgress = (p) => console.log(`[bundler] m4d ${p.stage}: ${p.message}`); 2599 - const { binary, filename } = await createM4DBundle(bundleTarget, isJSPiece, onProgress, density); 2600 - res.set({ 'Content-Type': 'application/octet-stream', 'Content-Disposition': `attachment; filename="${filename}"`, 'Cache-Control': 'no-cache' }); 2601 - return res.send(binary); 2602 - } catch (error) { 2603 - console.error('M4D bundle failed:', error); 2604 - return res.status(500).json({ error: error.message }); 2605 - } 2606 - } 2607 - 2608 - // SSE streaming mode 2609 - if (format === 'stream') { 2610 - res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' }); 2611 - res.flushHeaders(); 2612 - 2613 - const sendEvent = (type, data) => { 2614 - res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`); 2615 - if (typeof res.flush === 'function') res.flush(); 2616 - }; 2617 - 2618 - try { 2619 - const onProgress = (p) => sendEvent('progress', p); 2620 - const { html, filename, sizeKB } = isJSPiece 2621 - ? await createJSPieceBundle(bundleTarget, onProgress, nocompress, density, brotli, noboxart, keeplabel) 2622 - : await createBundle(bundleTarget, onProgress, nocompress, density, brotli, noboxart, keeplabel); 2623 - sendEvent('complete', { filename, content: Buffer.from(html).toString('base64'), sizeKB }); 2624 - } catch (error) { 2625 - console.error('Bundle failed:', error); 2626 - sendEvent('error', { error: error.message }); 2627 - } 2628 - return res.end(); 2629 - } 2630 - 2631 - // Non-streaming modes (json, html download, inline) 2632 - try { 2633 - const progressLog = []; 2634 - const onProgress = (p) => { progressLog.push(p.message); console.log(`[bundler] ${p.stage}: ${p.message}`); }; 2635 - const result = isJSPiece 2636 - ? await createJSPieceBundle(bundleTarget, onProgress, nocompress, density, brotli, noboxart, keeplabel) 2637 - : await createBundle(bundleTarget, onProgress, nocompress, density, brotli, noboxart, keeplabel); 2638 - const { html, filename, sizeKB, mainSource, authorHandle, userCode, packDate, depCount } = result; 2639 - 2640 - if (format === 'json' || format === 'base64') { 2641 - return res.json({ filename, content: Buffer.from(html).toString('base64'), sizeKB, progress: progressLog, sourceCode: mainSource, authorHandle, userCode, packDate, depCount }); 2642 - } 2643 - 2644 - const headers = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=3600' }; 2645 - if (!inline) headers['Content-Disposition'] = `attachment; filename="${filename}"`; 2646 - return res.set(headers).send(html); 2647 - } catch (error) { 2648 - console.error('Bundle failed:', error); 2649 - return res.status(500).json({ error: error.message }); 2650 - } 2651 - }); 2652 - 2653 - // Prewarm the core bundle cache (called by deploy.sh after restart) 2654 - app.post(['/pack-prewarm', '/bundle-prewarm'], async (req, res) => { 2655 - try { 2656 - addServerLog('info', '📦', 'Bundle prewarm started...'); 2657 - const result = await prewarmCache(); 2658 - addServerLog('success', '📦', `Bundle cache ready: ${result.fileCount} files in ${result.elapsed}ms (${result.commit})`); 2659 - res.json(result); 2660 - } catch (error) { 2661 - addServerLog('error', '❌', `Bundle prewarm failed: ${error.message}`); 2662 - res.status(500).json({ error: error.message }); 2663 - } 2664 - }); 2665 - 2666 - // Cache status 2667 - app.get(['/pack-status', '/bundle-status'], (req, res) => { 2668 - res.json(getCacheStatus()); 2669 - }); 2670 - 2671 - // ===== OS IMAGE BUILDER ===== 2672 - // Assembles bootable FedAC OS artifacts with a piece injected into the FEDAC-PIECE partition. 2673 - // Requires: pre-baked base image on CDN + e2fsprogs (debugfs) on server. 2674 - 2675 - app.get('/os', async (req, res) => { 2676 - const code = req.query.code; 2677 - const piece = req.query.piece; 2678 - const format = req.query.format || 'download'; 2679 - const density = parseInt(req.query.density) || 8; 2680 - const flavor = (req.query.flavor || 'alpine').toLowerCase(); 2681 - const nocache = req.query.nocache === '1' || req.query.nocache === 'true'; 2682 - 2683 - if (!['alpine', 'fedora', 'native'].includes(flavor)) { 2684 - return res.status(400).json({ error: "Invalid flavor. Use 'alpine', 'fedora', or 'native'." }); 2685 - } 2686 - 2687 - // Native flavor: pre-built bare-metal kernel images on CDN (no dynamic assembly) 2688 - if (flavor === 'native') { 2689 - const nativePiece = piece || code || 'notepat'; 2690 - const cdnUrl = `https://releases.aesthetic.computer/os/native-${nativePiece}-latest.img.gz`; 2691 - const filename = `${nativePiece}-native.img.gz`; 2692 - addServerLog('info', '💿', `OS native redirect: ${nativePiece}`); 2693 - 2694 - if (format === 'stream') { 2695 - res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' }); 2696 - res.flushHeaders(); 2697 - const sendEvent = (type, data) => { res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`); if (typeof res.flush === 'function') res.flush(); }; 2698 - sendEvent('progress', { stage: 'native', message: 'Native image ready on CDN', percent: 100 }); 2699 - sendEvent('complete', { message: 'Native OS image ready', downloadUrl: cdnUrl, filename, cached: true, flavor: 'native', elapsed: 0 }); 2700 - return res.end(); 2701 - } 2702 - return res.redirect(cdnUrl); 2703 - } 2704 - 2705 - const isJSPiece = !!piece; 2706 - const target = piece || code; 2707 - if (!target) { 2708 - return res.status(400).json({ 2709 - error: "Missing 'code' or 'piece' parameter.", 2710 - usage: { kidlisp: "/os?code=39j", javascript: "/os?piece=notepat" }, 2711 - }); 2712 - } 2713 - 2714 - addServerLog('info', '💿', `OS ISO build started: ${target} (${flavor})${nocache ? ' [nocache]' : ''}`); 2715 - 2716 - // SSE streaming progress mode (for UI) 2717 - if (format === 'stream') { 2718 - res.set({ 2719 - 'Content-Type': 'text/event-stream', 2720 - 'Cache-Control': 'no-cache', 2721 - 'Connection': 'keep-alive', 2722 - 'X-Accel-Buffering': 'no', 2723 - }); 2724 - res.flushHeaders(); 2725 - 2726 - const sendEvent = (type, data) => { 2727 - res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`); 2728 - if (typeof res.flush === 'function') res.flush(); 2729 - }; 2730 - 2731 - try { 2732 - const result = await streamOSImage(null, target, isJSPiece, density, (p) => sendEvent('progress', p), flavor, { nocache }); 2733 - const downloadParam = isJSPiece ? `piece=${encodeURIComponent(target)}` : `code=${encodeURIComponent(target)}`; 2734 - // Prefer CDN URL for fast download; fall back to oven direct. 2735 - const downloadUrl = result.cdnUrl || `/os?${downloadParam}&density=${density}&flavor=${flavor}`; 2736 - sendEvent('complete', { 2737 - message: result.cached ? 'OS ISO ready (CDN cached)' : 'OS ISO ready', 2738 - downloadUrl, 2739 - elapsed: result.elapsed, 2740 - filename: result.filename, 2741 - timings: result.timings, 2742 - cached: result.cached || false, 2743 - flavor, 2744 - }); 2745 - } catch (err) { 2746 - console.error('[os] SSE build failed:', err); 2747 - sendEvent('error', { error: err.message }); 2748 - } 2749 - return res.end(); 2750 - } 2751 - 2752 - // Direct download mode 2753 - try { 2754 - const result = await streamOSImage(res, target, isJSPiece, density, (p) => { 2755 - console.log(`[os] ${p.stage}: ${p.message}`); 2756 - }, flavor, { nocache }); 2757 - addServerLog('success', '💿', `OS ISO build complete: ${target}/${flavor} (${Math.round(result.elapsed / 1000)}s)`); 2758 - } catch (err) { 2759 - console.error('[os] Build failed:', err); 2760 - addServerLog('error', '❌', `OS build failed: ${err.message}`); 2761 - if (!res.headersSent) { 2762 - res.status(500).json({ error: err.message }); 2763 - } 2764 - } 2765 - }); 2766 - 2767 - app.get('/os-status', (req, res) => { 2768 - res.json(getOSBuildStatus()); 2769 - }); 2770 - 2771 - // Proxy releases.json with CORS for the web os.mjs piece. 2772 - app.get('/os-releases', async (req, res) => { 2773 - try { 2774 - const r = await fetch(`${RELEASES_BASE}/releases.json`); 2775 - if (!r.ok) return res.status(r.status).json({ error: 'Failed to fetch releases' }); 2776 - const data = await r.json(); 2777 - res.json(data); 2778 - } catch (err) { 2779 - res.status(502).json({ error: err.message }); 2780 - } 2781 - }); 2782 - 2783 - // Flush the cached OS template so the next download gets the fresh one. 2784 - app.post('/os-cache-flush', (req, res) => { 2785 - templateCache = null; 2786 - templateCacheTime = 0; 2787 - console.log('[os-image] Template cache flushed'); 2788 - res.json({ flushed: true }); 2789 - }); 2790 - 2791 - // Personalized FedAC OS .iso download for authenticated AC users. 2792 - // Downloads the template .iso from DO Spaces, patches config.json in-place, 2793 - // and streams back. Compatible with Fedora Media Writer, Balena Etcher, dd. 2794 - const RELEASES_BASE = 'https://releases-aesthetic-computer.sfo3.digitaloceanspaces.com/os'; 2795 - const TEMPLATE_ISO_URL = `${RELEASES_BASE}/native-notepat-latest.iso`; 2796 - const TEMPLATE_GZ_URL = `${RELEASES_BASE}/native-notepat-latest.img.gz`; // legacy fallback 2797 - const CONFIG_MARKER_LEGACY = '{"handle":"","piece":"notepat","sub":"","email":""}'; 2798 - const CONFIG_PAD_SIZE_LEGACY = 4096; 2799 - const IDENTITY_MARKER = 'AC_IDENTITY_BLOCK_V1'; 2800 - const IDENTITY_BLOCK_SIZE = 32768; 2801 - 2802 - // Cache the decompressed template in memory 2803 - let templateCache = null; 2804 - let templateCacheTime = 0; 2805 - const TEMPLATE_CACHE_TTL = 60 * 60 * 1000; // 1 hour 2806 - 2807 - async function getTemplate() { 2808 - if (templateCache && Date.now() - templateCacheTime < TEMPLATE_CACHE_TTL) { 2809 - return templateCache; 2810 - } 2811 - // Try .iso first, fall back to legacy .img.gz 2812 - let raw; 2813 - const isoRes = await fetch(TEMPLATE_ISO_URL); 2814 - if (isoRes.ok) { 2815 - console.log('[os-image] Downloading template .iso...'); 2816 - raw = Buffer.from(await isoRes.arrayBuffer()); 2817 - } else { 2818 - console.log('[os-image] No .iso found, trying legacy .img.gz fallback...'); 2819 - const gzRes = await fetch(TEMPLATE_GZ_URL); 2820 - if (gzRes.ok) { 2821 - const compressed = Buffer.from(await gzRes.arrayBuffer()); 2822 - console.log(`[os-image] Decompressing ${(compressed.length / 1048576).toFixed(1)}MB...`); 2823 - raw = gunzipSync(compressed); 2824 - } else { 2825 - throw new Error(`Template download failed (no .iso or .img.gz available)`); 2826 - } 2827 - } 2828 - templateCache = raw; 2829 - templateCacheTime = Date.now(); 2830 - console.log(`[os-image] Template cached: ${(templateCache.length / 1048576).toFixed(1)}MB`); 2831 - return templateCache; 2832 - } 2833 - 2834 - // User config endpoint for edge worker ISO patching 2835 - app.get('/api/user-config', async (req, res) => { 2836 - const authHeader = req.headers.authorization || ''; 2837 - const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''; 2838 - if (!token) return res.status(401).json({ error: 'Authorization required' }); 2839 - 2840 - let userInfo; 2841 - try { 2842 - const uiRes = await fetch('https://hi.aesthetic.computer/userinfo', { 2843 - headers: { Authorization: `Bearer ${token}` }, 2844 - }); 2845 - if (!uiRes.ok) throw new Error(`Auth0 ${uiRes.status}`); 2846 - userInfo = await uiRes.json(); 2847 - } catch (err) { 2848 - return res.status(401).json({ error: `Authentication failed: ${err.message}` }); 2849 - } 2850 - 2851 - const sub = userInfo.sub || ''; 2852 - let handle = ''; 2853 - try { 2854 - const handleRes = await fetch(`https://aesthetic.computer/handle?for=${encodeURIComponent(sub)}`); 2855 - if (handleRes.ok) { 2856 - const data = await handleRes.json(); 2857 - handle = data.handle || ''; 2858 - } 2859 - } catch (_) {} 2860 - 2861 - if (!handle) return res.status(403).json({ error: 'No handle found' }); 2862 - 2863 - let claudeToken = '', githubPat = ''; 2864 - try { 2865 - const mongoUri = process.env.MONGODB_CONNECTION_STRING; 2866 - const dbName = process.env.MONGODB_NAME; 2867 - if (mongoUri) { 2868 - const { MongoClient } = await import('mongodb'); 2869 - const client = new MongoClient(mongoUri); 2870 - await client.connect(); 2871 - const doc = await client.db(dbName).collection('@handles').findOne({ _id: sub }); 2872 - if (doc) { 2873 - claudeToken = doc.claudeCodeToken || ''; 2874 - githubPat = doc.githubPat || ''; 2875 - } 2876 - await client.close(); 2877 - } 2878 - } catch (err) { 2879 - console.warn(`[user-config] Token lookup failed: ${err.message}`); 2880 - } 2881 - 2882 - const reqPiece = req.query.piece || 'notepat'; 2883 - const ALLOWED_PIECES = ['notepat', 'prompt', 'chat', 'laer-klokken']; 2884 - const bootPiece = ALLOWED_PIECES.includes(reqPiece) ? reqPiece : 'notepat'; 2885 - const wifiParam = req.query.wifi; 2886 - const wifiEnabled = wifiParam !== '0' && wifiParam !== 'false'; 2887 - 2888 - const config = { handle, piece: bootPiece, sub, email: userInfo.email || '', token }; 2889 - if (claudeToken) config.claudeToken = claudeToken; 2890 - if (githubPat) config.githubPat = githubPat; 2891 - if (!wifiEnabled) config.wifi = false; 2892 - 2893 - res.json(config); 2894 - }); 2895 - 2896 - app.get('/os-image', async (req, res) => { 2897 - // Auth: verify AC token 2898 - const authHeader = req.headers.authorization || ''; 2899 - const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''; 2900 - if (!token) { 2901 - return res.status(401).json({ error: 'Authorization required. Log in at aesthetic.computer first.' }); 2902 - } 2903 - 2904 - let userInfo; 2905 - try { 2906 - const uiRes = await fetch('https://hi.aesthetic.computer/userinfo', { 2907 - headers: { Authorization: `Bearer ${token}` }, 2908 - }); 2909 - if (!uiRes.ok) throw new Error(`Auth0 ${uiRes.status}`); 2910 - userInfo = await uiRes.json(); 2911 - } catch (err) { 2912 - return res.status(401).json({ error: `Authentication failed: ${err.message}` }); 2913 - } 2914 - 2915 - // Look up handle by sub (avoids stale /user cache for new handles) 2916 - let handle = ''; 2917 - const sub = userInfo.sub || ''; 2918 - try { 2919 - const handleRes = await fetch( 2920 - `https://aesthetic.computer/handle?for=${encodeURIComponent(sub)}` 2921 - ); 2922 - if (handleRes.ok) { 2923 - const data = await handleRes.json(); 2924 - handle = data.handle || ''; 2925 - } 2926 - } catch (_) {} 2927 - 2928 - if (!handle) { 2929 - return res.status(403).json({ error: 'You need a handle first. Visit aesthetic.computer/handle to claim one.' }); 2930 - } 2931 - 2932 - // Boot-to piece preference (default: notepat) 2933 - const ALLOWED_PIECES = ['notepat', 'prompt', 'chat', 'laer-klokken']; 2934 - const reqPiece = req.query.piece || 'notepat'; 2935 - const bootPiece = ALLOWED_PIECES.includes(reqPiece) ? reqPiece : 'notepat'; 2936 - 2937 - // WiFi/internet toggle (default: enabled) 2938 - const wifiParam = req.query.wifi; 2939 - const wifiEnabled = wifiParam !== '0' && wifiParam !== 'false'; 2940 - 2941 - // Fetch device tokens (Claude + GitHub) from DB 2942 - let claudeToken = '', githubPat = ''; 2943 - try { 2944 - const mongoUri = process.env.MONGODB_CONNECTION_STRING; 2945 - const dbName = process.env.MONGODB_NAME; 2946 - if (mongoUri) { 2947 - const { MongoClient } = await import('mongodb'); 2948 - const client = new MongoClient(mongoUri); 2949 - await client.connect(); 2950 - const doc = await client.db(dbName).collection('@handles').findOne({ _id: sub }); 2951 - if (doc) { 2952 - claudeToken = doc.claudeCodeToken || ''; 2953 - githubPat = doc.githubPat || ''; 2954 - } 2955 - await client.close(); 2956 - } 2957 - } catch (err) { 2958 - console.warn(`[os-image] Token lookup failed: ${err.message}`); 2959 - } 2960 - 2961 - console.log(`[os-image] Building personalized image for @${handle} (boot: ${bootPiece}, wifi: ${wifiEnabled}, claude: ${!!claudeToken}, git: ${!!githubPat})`); 2962 - 2963 - // Get template (cached in memory) 2964 - let imgData; 2965 - try { 2966 - const template = await getTemplate(); 2967 - imgData = Buffer.from(template); // copy so we don't mutate cache 2968 - } catch (err) { 2969 - return res.status(503).json({ error: `Template not available: ${err.message}` }); 2970 - } 2971 - 2972 - // Build personalized config JSON 2973 - const configObj = { 2974 - handle, 2975 - piece: bootPiece, 2976 - sub: userInfo.sub || '', 2977 - email: userInfo.email || '', 2978 - token: token, 2979 - }; 2980 - if (claudeToken) configObj.claudeToken = claudeToken; 2981 - if (githubPat) configObj.githubPat = githubPat; 2982 - if (!wifiEnabled) configObj.wifi = false; 2983 - const configJson = JSON.stringify(configObj); 2984 - 2985 - // Try new identity block format first (32KB, marker-prefixed) 2986 - const identityMarkerBuf = Buffer.from(IDENTITY_MARKER + '\n'); 2987 - let idx = imgData.indexOf(identityMarkerBuf); 2988 - let patchCount = 0; 2989 - 2990 - if (idx !== -1) { 2991 - // New format: marker + newline + JSON + zero-padding to 32KB 2992 - while (idx !== -1) { 2993 - const block = Buffer.alloc(IDENTITY_BLOCK_SIZE, 0); 2994 - const header = Buffer.from(IDENTITY_MARKER + '\n' + configJson); 2995 - header.copy(block); 2996 - block.copy(imgData, idx); 2997 - patchCount++; 2998 - idx = imgData.indexOf(identityMarkerBuf, idx + IDENTITY_BLOCK_SIZE); 2999 - } 3000 - console.log(`[os-image] Patched ${patchCount} identity block(s) for @${handle} (v1, 32KB)`); 3001 - } else { 3002 - // Legacy format: plain JSON padded to 4KB with spaces 3003 - const legacyMarkerBuf = Buffer.from(CONFIG_MARKER_LEGACY); 3004 - idx = imgData.indexOf(legacyMarkerBuf); 3005 - if (idx === -1) { 3006 - return res.status(500).json({ error: 'Template image missing config placeholder' }); 3007 - } 3008 - const padded = configJson + ' '.repeat(Math.max(0, CONFIG_PAD_SIZE_LEGACY - configJson.length)); 3009 - const configBytes = Buffer.from(padded); 3010 - while (idx !== -1) { 3011 - configBytes.copy(imgData, idx); 3012 - patchCount++; 3013 - idx = imgData.indexOf(legacyMarkerBuf, idx + CONFIG_PAD_SIZE_LEGACY); 3014 - } 3015 - console.log(`[os-image] Patched ${patchCount} config location(s) for @${handle} (legacy, 4KB)`); 3016 - } 3017 - 3018 - // Stream the patched ISO (Fedora Media Writer / Balena Etcher / dd compatible) 3019 - addServerLog('success', '💿', `OS ISO for @${handle} (${(imgData.length / 1048576).toFixed(1)}MB)`); 3020 - res.setHeader('Content-Type', 'application/x-iso9660-image'); 3021 - // Get latest build name for filename 3022 - let releaseName = 'native'; 3023 - try { 3024 - const relRes = await fetch(`${RELEASES_BASE}/releases.json`); 3025 - if (relRes.ok) { 3026 - const relData = await relRes.json(); 3027 - releaseName = relData?.releases?.[0]?.name || releaseName; 3028 - } 3029 - } catch (_) {} 3030 - const coreName = 'AC-' + releaseName; 3031 - const d = new Date(); 3032 - const p = (n) => String(n).padStart(2, '0'); 3033 - const ts = `${d.getFullYear()}.${p(d.getMonth()+1)}.${p(d.getDate())}.${p(d.getHours())}.${p(d.getMinutes())}.${p(d.getSeconds())}`; 3034 - res.setHeader('Content-Disposition', `attachment; filename="@${handle}-os-${bootPiece}-${coreName}-${ts}.iso"`); 3035 - res.setHeader('Content-Length', imgData.length); 3036 - res.end(imgData); 3037 - }); 3038 - 3039 - // Background base image jobs (build + upload) for FedOS pipeline. 3040 - app.get('/os-base-build', (req, res) => { 3041 - res.json(getOSBaseBuildsSummary()); 3042 - }); 3043 - 3044 - app.get('/os-base-build/:jobId', (req, res) => { 3045 - const tail = Math.max(0, Math.min(2000, parseInt(req.query.tail, 10) || 200)); 3046 - const includeLogs = req.query.logs === '1' || req.query.logs === 'true'; 3047 - const job = getOSBaseBuild(req.params.jobId, { includeLogs, tail }); 3048 - if (!job) return res.status(404).json({ error: 'Job not found' }); 3049 - return res.json(job); 3050 - }); 3051 - 3052 - app.get('/os-base-build/:jobId/stream', (req, res) => { 3053 - const jobId = req.params.jobId; 3054 - const initial = getOSBaseBuild(jobId, { includeLogs: true, tail: 500 }); 3055 - if (!initial) return res.status(404).json({ error: 'Job not found' }); 3056 - 3057 - res.set({ 3058 - 'Content-Type': 'text/event-stream', 3059 - 'Cache-Control': 'no-cache', 3060 - 'Connection': 'keep-alive', 3061 - 'X-Accel-Buffering': 'no', 3062 - }); 3063 - res.flushHeaders(); 3064 - 3065 - let sentLogs = 0; 3066 - const sendEvent = (type, data) => { 3067 - res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`); 3068 - if (typeof res.flush === 'function') res.flush(); 3069 - }; 3070 - 3071 - const sendSnapshot = () => { 3072 - const job = getOSBaseBuild(jobId, { includeLogs: true, tail: 2000 }); 3073 - if (!job) { 3074 - sendEvent('error', { error: 'Job not found' }); 3075 - return false; 3076 - } 3077 - 3078 - const logs = Array.isArray(job.logs) ? job.logs : []; 3079 - if (logs.length > sentLogs) { 3080 - sendEvent('logs', { logs: logs.slice(sentLogs) }); 3081 - sentLogs = logs.length; 3082 - } 3083 - sendEvent('status', { 3084 - id: job.id, 3085 - status: job.status, 3086 - stage: job.stage, 3087 - message: job.message, 3088 - percent: job.percent, 3089 - updatedAt: job.updatedAt, 3090 - finishedAt: job.finishedAt, 3091 - error: job.error, 3092 - upload: job.upload, 3093 - }); 3094 - if (job.status === 'success' || job.status === 'failed' || job.status === 'cancelled') { 3095 - sendEvent('complete', { 3096 - status: job.status, 3097 - error: job.error, 3098 - upload: job.upload, 3099 - }); 3100 - return false; 3101 - } 3102 - return true; 3103 - }; 3104 - 3105 - sendEvent('status', { 3106 - id: initial.id, 3107 - status: initial.status, 3108 - stage: initial.stage, 3109 - message: initial.message, 3110 - percent: initial.percent, 3111 - updatedAt: initial.updatedAt, 3112 - }); 3113 - if (Array.isArray(initial.logs) && initial.logs.length > 0) { 3114 - sendEvent('logs', { logs: initial.logs }); 3115 - sentLogs = initial.logs.length; 3116 - } 3117 - 3118 - const timer = setInterval(() => { 3119 - if (!sendSnapshot()) { 3120 - clearInterval(timer); 3121 - res.end(); 3122 - } 3123 - }, 1000); 3124 - 3125 - req.on('close', () => { 3126 - clearInterval(timer); 3127 - }); 3128 - }); 3129 - 3130 - app.post('/os-base-build', requireOSBuildAdmin, async (req, res) => { 3131 - const flavor = (req.body?.flavor || 'alpine').toLowerCase(); 3132 - if (!['alpine', 'fedora'].includes(flavor)) { 3133 - return res.status(400).json({ error: "Invalid flavor. Use 'alpine' or 'fedora'." }); 3134 - } 3135 - const defaultSize = flavor === 'alpine' ? 1 : 4; 3136 - const imageSizeGB = Math.max(1, Math.min(32, parseInt(req.body?.imageSizeGB, 10) || defaultSize)); 3137 - const publish = req.body?.publish !== false; 3138 - const requestedWorkBase = typeof req.body?.workBase === 'string' ? req.body.workBase.trim() : ''; 3139 - const workBase = requestedWorkBase || undefined; 3140 - 3141 - try { 3142 - const job = await startOSBaseBuild( 3143 - { imageSizeGB, publish, flavor, workBase }, 3144 - { 3145 - onStart: (j) => addServerLog('info', '💿', `OS base build started: ${j.id} (${flavor}, ${imageSizeGB}GiB${workBase ? `, workBase=${workBase}` : ''})`), 3146 - onUploadComplete: async (j) => { 3147 - addServerLog('success', '☁️', `OS base upload complete: ${j.upload.imageKey}`); 3148 - invalidateManifest(flavor); 3149 - addServerLog('info', '💿', `OS manifest cache invalidated (${flavor}) after base upload`); 3150 - // Purge all cached per-piece builds for this flavor — the new base image 3151 - // changes the image layout, so old cached builds are stale. 3152 - const purgeResult = await purgeOSBuildCache(flavor); 3153 - addServerLog('info', '🗑️', `Purged ${purgeResult.deleted} cached ${flavor} build(s) from CDN`); 3154 - }, 3155 - onSuccess: (j) => addServerLog('success', '💿', `OS base build complete: ${j.id} (${flavor})`), 3156 - onError: (j) => addServerLog('error', '❌', `OS base build failed: ${j.id} (${j.error})`), 3157 - }, 3158 - ); 3159 - return res.status(202).json(job); 3160 - } catch (error) { 3161 - if (error.code === 'OS_BASE_BUSY') { 3162 - return res.status(409).json({ error: error.message, activeJobId: error.activeJobId }); 3163 - } 3164 - return res.status(500).json({ error: error.message }); 3165 - } 3166 - }); 3167 - 3168 - app.post('/os-base-build/:jobId/cancel', requireOSBuildAdmin, (req, res) => { 3169 - const result = cancelOSBaseBuild(req.params.jobId); 3170 - if (!result.ok) { 3171 - return res.status(400).json(result); 3172 - } 3173 - addServerLog('info', '🛑', `OS base build cancel requested: ${req.params.jobId}`); 3174 - return res.json(result); 3175 - }); 3176 - 3177 - // ── Native OTA Build ────────────────────────────────────────────────────── 3178 - // Builds fedac/native kernel + uploads vmlinuz to DO Spaces CDN. 3179 - // Auth: same OS_BUILD_ADMIN_KEY used for /os-base-build. 3180 - // Auto-triggered by native-git-poller.mjs (polls origin/main every 60s). 3181 - // Can also be triggered manually via POST with admin key. 3182 - 3183 - app.get('/native-build', (req, res) => { 3184 - res.json({ ...getNativeBuildsSummary(), poller: getNativePollerStatus() }); 3185 - }); 3186 - 3187 - app.get('/native-build/:jobId', (req, res) => { 3188 - const tail = Math.max(0, Math.min(2000, parseInt(req.query.tail, 10) || 200)); 3189 - const includeLogs = req.query.logs === '1' || req.query.logs === 'true'; 3190 - const job = getNativeBuild(req.params.jobId, { includeLogs, tail }); 3191 - if (!job) return res.status(404).json({ error: 'Job not found' }); 3192 - return res.json(job); 3193 - }); 3194 - 3195 - app.get('/native-build/:jobId/stream', (req, res) => { 3196 - const jobId = req.params.jobId; 3197 - const initial = getNativeBuild(jobId, { includeLogs: true, tail: 500 }); 3198 - if (!initial) return res.status(404).json({ error: 'Job not found' }); 3199 - 3200 - res.set({ 3201 - 'Content-Type': 'text/event-stream', 3202 - 'Cache-Control': 'no-cache', 3203 - 'Connection': 'keep-alive', 3204 - 'X-Accel-Buffering': 'no', 3205 - }); 3206 - res.flushHeaders(); 3207 - 3208 - let sentLogs = 0; 3209 - const sendEvent = (type, data) => { 3210 - res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`); 3211 - if (typeof res.flush === 'function') res.flush(); 3212 - }; 3213 - 3214 - if (Array.isArray(initial.logs) && initial.logs.length > 0) { 3215 - sendEvent('logs', { logs: initial.logs }); 3216 - sentLogs = initial.logs.length; 3217 - } 3218 - sendEvent('status', { id: initial.id, status: initial.status, stage: initial.stage, percent: initial.percent }); 3219 - 3220 - const timer = setInterval(() => { 3221 - const job = getNativeBuild(jobId, { includeLogs: true, tail: 2000 }); 3222 - if (!job) { clearInterval(timer); res.end(); return; } 3223 - const logs = Array.isArray(job.logs) ? job.logs : []; 3224 - if (logs.length > sentLogs) { 3225 - sendEvent('logs', { logs: logs.slice(sentLogs) }); 3226 - sentLogs = logs.length; 3227 - } 3228 - sendEvent('status', { id: job.id, status: job.status, stage: job.stage, percent: job.percent, error: job.error }); 3229 - if (job.status === 'success' || job.status === 'failed' || job.status === 'cancelled') { 3230 - sendEvent('complete', { status: job.status, error: job.error }); 3231 - clearInterval(timer); 3232 - res.end(); 3233 - } 3234 - }, 1000); 3235 - 3236 - req.on('close', () => clearInterval(timer)); 3237 - }); 3238 - 3239 - app.post('/native-build', requireOSBuildAdmin, async (req, res) => { 3240 - try { 3241 - const job = await startNativeBuild({ 3242 - ref: req.body?.ref || 'unknown', 3243 - changed_paths: req.body?.changed_paths || '', 3244 - }); 3245 - addServerLog('info', '🔨', `Native OTA build started: ${job.id} (ref=${job.ref}, flags=${job.flags.join(' ') || 'none'})`); 3246 - return res.status(202).json(job); 3247 - } catch (err) { 3248 - if (err.code === 'NATIVE_BUILD_BUSY') { 3249 - return res.status(409).json({ error: err.message, activeJobId: err.activeJobId }); 3250 - } 3251 - return res.status(500).json({ error: err.message }); 3252 - } 3253 - }); 3254 - 3255 - app.post('/native-build/:jobId/cancel', requireOSBuildAdmin, (req, res) => { 3256 - const result = cancelNativeBuild(req.params.jobId); 3257 - if (!result.ok) return res.status(400).json(result); 3258 - addServerLog('info', '🛑', `Native build cancel requested: ${req.params.jobId}`); 3259 - return res.json(result); 3260 - }); 3261 - 3262 - // ── Papers PDF Build ────────────────────────────────────────────────────── 3263 - // Builds all AC paper PDFs from LaTeX sources using xelatex. 3264 - // Auth: same OS_BUILD_ADMIN_KEY used for /native-build. 3265 - // Auto-triggered by papers-git-poller.mjs (polls origin/main every 60s). 3266 - // Can also be triggered manually via POST with admin key. 3267 - 3268 - app.get('/papers-build', (req, res) => { 3269 - res.json({ ...getPapersBuildsSummary(), poller: getPapersPollerStatus() }); 3270 - }); 3271 - 3272 - app.get('/papers-build/:jobId', (req, res) => { 3273 - const tail = Math.max(0, Math.min(2000, parseInt(req.query.tail, 10) || 200)); 3274 - const includeLogs = req.query.logs === '1' || req.query.logs === 'true'; 3275 - const job = getPapersBuild(req.params.jobId, { includeLogs, tail }); 3276 - if (!job) return res.status(404).json({ error: 'Job not found' }); 3277 - return res.json(job); 3278 - }); 3279 - 3280 - app.get('/papers-build/:jobId/stream', (req, res) => { 3281 - const jobId = req.params.jobId; 3282 - const initial = getPapersBuild(jobId, { includeLogs: true, tail: 500 }); 3283 - if (!initial) return res.status(404).json({ error: 'Job not found' }); 3284 - 3285 - res.set({ 3286 - 'Content-Type': 'text/event-stream', 3287 - 'Cache-Control': 'no-cache', 3288 - 'Connection': 'keep-alive', 3289 - 'X-Accel-Buffering': 'no', 3290 - }); 3291 - res.flushHeaders(); 3292 - 3293 - let sentLogs = 0; 3294 - const sendEvent = (type, data) => { 3295 - res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`); 3296 - if (typeof res.flush === 'function') res.flush(); 3297 - }; 3298 - 3299 - if (Array.isArray(initial.logs) && initial.logs.length > 0) { 3300 - sendEvent('logs', { logs: initial.logs }); 3301 - sentLogs = initial.logs.length; 3302 - } 3303 - sendEvent('status', { id: initial.id, status: initial.status, stage: initial.stage, percent: initial.percent }); 3304 - 3305 - const timer = setInterval(() => { 3306 - const job = getPapersBuild(jobId, { includeLogs: true, tail: 2000 }); 3307 - if (!job) { clearInterval(timer); res.end(); return; } 3308 - const logs = Array.isArray(job.logs) ? job.logs : []; 3309 - if (logs.length > sentLogs) { 3310 - sendEvent('logs', { logs: logs.slice(sentLogs) }); 3311 - sentLogs = logs.length; 3312 - } 3313 - sendEvent('status', { id: job.id, status: job.status, stage: job.stage, percent: job.percent, error: job.error }); 3314 - if (job.status === 'success' || job.status === 'failed' || job.status === 'cancelled') { 3315 - sendEvent('complete', { status: job.status, error: job.error }); 3316 - clearInterval(timer); 3317 - res.end(); 3318 - } 3319 - }, 1000); 3320 - 3321 - req.on('close', () => clearInterval(timer)); 3322 - }); 3323 - 3324 - app.post('/papers-build', requireOSBuildAdmin, async (req, res) => { 3325 - try { 3326 - const job = await startPapersBuild({ 3327 - ref: req.body?.ref || 'unknown', 3328 - changed_paths: req.body?.changed_paths || '', 3329 - }); 3330 - addServerLog('info', '📄', `Papers PDF build started: ${job.id} (ref=${job.ref})`); 3331 - return res.status(202).json(job); 3332 - } catch (err) { 3333 - if (err.code === 'PAPERS_BUILD_BUSY') { 3334 - return res.status(409).json({ error: err.message, activeJobId: err.activeJobId }); 3335 - } 3336 - return res.status(500).json({ error: err.message }); 3337 - } 3338 - }); 3339 - 3340 - app.post('/papers-build/:jobId/cancel', requireOSBuildAdmin, (req, res) => { 3341 - const result = cancelPapersBuild(req.params.jobId); 3342 - if (!result.ok) return res.status(400).json(result); 3343 - addServerLog('info', '🛑', `Papers build cancel requested: ${req.params.jobId}`); 3344 - return res.json(result); 3345 - }); 3346 - 3347 - // ── OS Release Upload ────────────────────────────────────────────────────── 3348 - // Accepts a vmlinuz binary + metadata, uploads to DO Spaces as OTA release. 3349 - // Auth: AC token (Bearer) verified against Auth0 userinfo. 3350 - // Usage: curl -X POST /os-release-upload \ 3351 - // -H "Authorization: Bearer <ac_token>" \ 3352 - // -H "X-Build-Name: swift-egret" \ 3353 - // -H "X-Git-Hash: abc1234" \ 3354 - // -H "X-Build-Ts: 2026-03-11T12:00" \ 3355 - // --data-binary @build/vmlinuz 3356 - app.post('/os-release-upload', async (req, res) => { 3357 - // Auth: verify AC token 3358 - const authHeader = req.headers.authorization || ''; 3359 - const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''; 3360 - if (!token) { 3361 - return res.status(401).json({ error: 'Missing Authorization: Bearer <ac_token>' }); 3362 - } 3363 - 3364 - // Verify token against Auth0 3365 - const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN || 'hi.aesthetic.computer'; 3366 - let user; 3367 - try { 3368 - const uiRes = await fetch(`https://${AUTH0_DOMAIN}/userinfo`, { 3369 - headers: { Authorization: `Bearer ${token}` }, 3370 - }); 3371 - if (!uiRes.ok) throw new Error(`Auth0 returned ${uiRes.status}`); 3372 - user = await uiRes.json(); 3373 - } catch (err) { 3374 - addServerLog('error', '🔒', `OS release upload auth failed: ${err.message}`); 3375 - return res.status(401).json({ error: 'Invalid or expired token. Run: ac-login' }); 3376 - } 3377 - 3378 - const userSub = user.sub || 'unknown'; 3379 - const userName = user.name || user.nickname || user.email || userSub; 3380 - addServerLog('info', '📦', `OS release upload from ${userName} (${userSub})`); 3381 - 3382 - // Collect binary body 3383 - const chunks = []; 3384 - for await (const chunk of req) chunks.push(chunk); 3385 - const vmlinuz = Buffer.concat(chunks); 3386 - 3387 - if (vmlinuz.length < 1_000_000) { 3388 - return res.status(400).json({ error: `File too small (${vmlinuz.length} bytes). Expected vmlinuz ~35-45MB.` }); 3389 - } 3390 - 3391 - // Metadata from headers 3392 - const buildName = req.headers['x-build-name'] || `upload-${Date.now()}`; 3393 - const gitHash = req.headers['x-git-hash'] || 'unknown'; 3394 - const buildTs = req.headers['x-build-ts'] || new Date().toISOString().slice(0, 16); 3395 - const commitMsg = req.headers['x-commit-msg'] || ''; 3396 - const version = `${buildName} ${gitHash}-${buildTs}`; 3397 - 3398 - // SHA256 3399 - const crypto = await import('crypto'); 3400 - const sha256 = crypto.createHash('sha256').update(vmlinuz).digest('hex'); 3401 - 3402 - // Upload to DO Spaces 3403 - const { S3Client, PutObjectCommand, GetObjectCommand } = await import('@aws-sdk/client-s3'); 3404 - const accessKeyId = process.env.OS_SPACES_KEY || process.env.ART_SPACES_KEY; 3405 - const secretAccessKey = process.env.OS_SPACES_SECRET || process.env.ART_SPACES_SECRET; 3406 - if (!accessKeyId || !secretAccessKey) { 3407 - return res.status(503).json({ error: 'OS Spaces credentials not configured on server.' }); 3408 - } 3409 - 3410 - const spacesEndpoint = process.env.OS_SPACES_ENDPOINT || 'https://sfo3.digitaloceanspaces.com'; 3411 - const spacesBucket = process.env.OS_SPACES_BUCKET || 'releases-aesthetic-computer'; 3412 - const cdnBase = process.env.OS_SPACES_CDN_BASE || `https://${spacesBucket}.sfo3.digitaloceanspaces.com`; 3413 - 3414 - const s3 = new S3Client({ 3415 - region: process.env.OS_SPACES_REGION || 'us-east-1', 3416 - endpoint: spacesEndpoint, 3417 - credentials: { accessKeyId, secretAccessKey }, 3418 - }); 3419 - 3420 - const upload = async (key, body, contentType) => { 3421 - await s3.send(new PutObjectCommand({ 3422 - Bucket: spacesBucket, 3423 - Key: key, 3424 - Body: body, 3425 - ContentType: contentType, 3426 - ACL: 'public-read', 3427 - })); 3428 - }; 3429 - 3430 - try { 3431 - addServerLog('info', '☁️', `Uploading ${buildName}: ${(vmlinuz.length / 1048576).toFixed(1)}MB, sha=${sha256.slice(0, 12)}...`); 3432 - 3433 - // Upload version + sha256 first (canary), then vmlinuz 3434 - // Version file: line 1 = version string, line 2 = kernel size in bytes 3435 - const versionWithSize = `${version}\n${vmlinuz.length}`; 3436 - await upload('os/native-notepat-latest.version', versionWithSize, 'text/plain'); 3437 - await upload('os/native-notepat-latest.sha256', sha256, 'text/plain'); 3438 - await upload('os/native-notepat-latest.vmlinuz', vmlinuz, 'application/octet-stream'); 3439 - 3440 - // Update releases.json 3441 - let releases = { releases: [] }; 3442 - try { 3443 - const existing = await s3.send(new GetObjectCommand({ 3444 - Bucket: spacesBucket, Key: 'os/releases.json', 3445 - })); 3446 - const body = await existing.Body.transformToString(); 3447 - releases = JSON.parse(body); 3448 - } catch { /* first release or missing */ } 3449 - 3450 - releases.releases = releases.releases || []; 3451 - const userHandle = req.headers['x-handle'] || user.nickname || user.name || userName; 3452 - // Mark all existing builds as deprecated 3453 - for (const r of releases.releases) r.deprecated = true; 3454 - 3455 - releases.releases.unshift({ 3456 - version, name: buildName, sha256, size: vmlinuz.length, 3457 - git_hash: gitHash, build_ts: buildTs, commit_msg: commitMsg, 3458 - user: userSub, handle: userHandle, 3459 - url: `${cdnBase}/os/native-notepat-latest.vmlinuz`, 3460 - }); 3461 - releases.releases = releases.releases.slice(0, 50); 3462 - releases.latest = version; 3463 - releases.latest_name = buildName; 3464 - 3465 - await upload('os/releases.json', JSON.stringify(releases, null, 2), 'application/json'); 3466 - 3467 - // Broadcast new build to all connected WebSocket clients (os.mjs pieces) 3468 - if (wss && wss.clients) { 3469 - const buildMsg = JSON.stringify({ type: 'os:new-build', releases }); 3470 - wss.clients.forEach(client => { 3471 - if (client.readyState === 1) client.send(buildMsg); 3472 - }); 3473 - } 3474 - 3475 - addServerLog('success', '🚀', `OS release published: ${buildName} (${gitHash}) by ${userName}`); 3476 - return res.json({ 3477 - ok: true, 3478 - name: buildName, 3479 - version, 3480 - sha256, 3481 - size: vmlinuz.length, 3482 - url: `${cdnBase}/os/native-notepat-latest.vmlinuz`, 3483 - user: userSub, 3484 - }); 3485 - } catch (err) { 3486 - addServerLog('error', '❌', `OS release upload failed: ${err.message}`); 3487 - return res.status(500).json({ error: `Upload failed: ${err.message}` }); 3488 - } 3489 - }); 3490 - 3491 - app.post('/os-invalidate', async (req, res) => { 3492 - const purge = req.body?.purge === true; 3493 - const clearLocal = req.body?.local === true || req.body?.clearLocal === true; 3494 - const flavor = req.body?.flavor; 3495 - invalidateManifest(flavor); 3496 - addServerLog('info', '💿', `OS base image manifest cache invalidated${flavor ? ` (${flavor})` : ''}`); 3497 - 3498 - let localResult = null; 3499 - if (clearLocal) { 3500 - localResult = await clearOSBuildLocalCache(flavor); 3501 - addServerLog('info', '🧹', `Cleared ${localResult.deleted} local base-image cache file(s)${flavor ? ` (${flavor})` : ''}`); 3502 - } 3503 - 3504 - if (purge) { 3505 - const purgeResult = await purgeOSBuildCache(flavor); 3506 - addServerLog('info', '🗑️', `Purged ${purgeResult.deleted} cached build(s) from CDN${flavor ? ` (${flavor})` : ''}`); 3507 - return res.json({ 3508 - ok: true, 3509 - message: clearLocal 3510 - ? 'Manifest invalidated, local base cache cleared, and CDN build cache purged.' 3511 - : 'Manifest + CDN build cache purged.', 3512 - purged: purgeResult.deleted, 3513 - localCleared: localResult?.deleted || 0, 3514 - localDirs: localResult?.dirs || [], 3515 - }); 3516 - } 3517 - 3518 - if (clearLocal) { 3519 - return res.json({ 3520 - ok: true, 3521 - message: 'Manifest invalidated and local base-image cache cleared.', 3522 - localCleared: localResult.deleted, 3523 - localDirs: localResult.dirs, 3524 - }); 3525 - } 3526 - 3527 - res.json({ ok: true, message: 'Manifest cache invalidated — next build will re-fetch.' }); 3528 - }); 3529 - 3530 - // 404 handler 3531 - app.use((req, res) => { 3532 - res.status(404).json({ error: 'Not found' }); 3533 - }); 3534 - 3535 - // Error handler 3536 - app.use((err, req, res, next) => { 3537 - console.error('❌ Server error:', err); 3538 - res.status(500).json({ error: 'Internal server error', message: err.message }); 3539 - }); 3540 - 3541 - // Create server and WebSocket 3542 - let server; 3543 - if (dev) { 3544 - // Load local SSL certs in development mode 3545 - const httpsOptions = { 3546 - key: fs.readFileSync('../ssl-dev/localhost-key.pem'), 3547 - cert: fs.readFileSync('../ssl-dev/localhost.pem'), 3548 - }; 3549 - 3550 - server = https.createServer(httpsOptions, app); 3551 - server.listen(PORT, () => { 3552 - console.log(`🔥 Oven server running on https://localhost:${PORT} (dev mode)`); 3553 - }); 3554 - } else { 3555 - // Production - plain HTTP (Caddy handles SSL) 3556 - server = http.createServer(app); 3557 - server.listen(PORT, () => { 3558 - console.log(`🔥 Oven server running on http://localhost:${PORT}`); 3559 - addServerLog('success', '🔥', `Oven server ready (v${GIT_VERSION.slice(0,8)})`); 3560 - 3561 - // Pre-warm Puppeteer browser so first keep thumbnail bake is fast 3562 - setTimeout(() => { 3563 - addServerLog('info', '🌐', 'Pre-warming grab browser...'); 3564 - prewarmGrabBrowser().then(() => { 3565 - addServerLog('success', '🌐', 'Browser pre-warm complete'); 3566 - }).catch(err => { 3567 - addServerLog('error', '⚠️', `Browser pre-warm failed: ${err.message}`); 3568 - }); 3569 - }, 5000); // Give server 5s to settle first 3570 - 3571 - // Start background OG image regeneration after a short delay 3572 - setTimeout(() => { 3573 - addServerLog('info', '🖼️', 'Starting background OG regeneration...'); 3574 - regenerateOGImagesBackground().then(() => { 3575 - addServerLog('success', '🖼️', 'OG images ready for social sharing'); 3576 - }).catch(err => { 3577 - addServerLog('error', '❌', `OG regen failed: ${err.message}`); 3578 - }); 3579 - }, 10000); // Wait 10s for server to fully initialize 3580 - 3581 - // Schedule periodic regeneration (every 6 hours) 3582 - setInterval(() => { 3583 - addServerLog('info', '🖼️', 'Scheduled OG regeneration starting...'); 3584 - regenerateOGImagesBackground().catch(err => { 3585 - addServerLog('error', '❌', `Scheduled OG regen failed: ${err.message}`); 3586 - }); 3587 - }, 6 * 60 * 60 * 1000); // 6 hours 3588 - 3589 - // Start native OTA git poller — watches for fedac/native/ changes 3590 - startNativeGitPoller({ startNativeBuild, addServerLog }); 3591 - 3592 - // Start papers PDF git poller — watches for papers/ changes 3593 - startPapersGitPoller({ startPapersBuild, addServerLog }); 3594 - }); 3595 - } 3596 - 3597 - // WebSocket server 3598 - wss = new WebSocketServer({ server, path: '/ws' }); 3599 - 3600 - // Wire up grabber notifications to broadcast to all WebSocket clients 3601 - setNotifyCallback(() => { 3602 - wss.clients.forEach((client) => { 3603 - if (client.readyState === 1) { // OPEN 3604 - client.send(JSON.stringify({ 3605 - version: GIT_VERSION, 3606 - serverStartTime: SERVER_START_TIME, 3607 - uptime: Date.now() - SERVER_START_TIME, 3608 - incoming: Array.from(getIncomingBakes().values()), 3609 - active: Array.from(getActiveBakes().values()), 3610 - recent: getRecentBakes(), 3611 - grabs: { 3612 - active: getActiveGrabs(), 3613 - recent: getRecentGrabs(), 3614 - queue: getQueueStatus(), 3615 - ipfsThumbs: getAllLatestIPFSUploads() 3616 - }, 3617 - grabProgress: getAllProgress(), 3618 - concurrency: getConcurrencyStatus(), 3619 - osBaseBuilds: getOSBaseBuildsSummary(), 3620 - })); 3621 - } 3622 - }); 3623 - }); 3624 - 3625 - // Wire up grabber log messages to broadcast to clients 3626 - setLogCallback((type, icon, msg) => { 3627 - addServerLog(type, icon, msg); 3628 - }); 3629 - 3630 - wss.on('connection', async (ws) => { 3631 - console.log('📡 WebSocket client connected'); 3632 - addServerLog('info', '📡', 'Dashboard client connected'); 3633 - 3634 - // Clean up stale bakes before sending initial state 3635 - await cleanupStaleBakes(); 3636 - 3637 - // Send initial state with recent logs 3638 - ws.send(JSON.stringify({ 3639 - version: GIT_VERSION, 3640 - serverStartTime: SERVER_START_TIME, 3641 - uptime: Date.now() - SERVER_START_TIME, 3642 - incoming: Array.from(getIncomingBakes().values()), 3643 - active: Array.from(getActiveBakes().values()), 3644 - recent: getRecentBakes(), 3645 - grabs: { 3646 - active: getActiveGrabs(), 3647 - recent: getRecentGrabs(), 3648 - queue: getQueueStatus(), 3649 - ipfsThumbs: getAllLatestIPFSUploads() 3650 - }, 3651 - grabProgress: getAllProgress(), 3652 - concurrency: getConcurrencyStatus(), 3653 - osBaseBuilds: getOSBaseBuildsSummary(), 3654 - frozen: getFrozenPieces(), 3655 - recentLogs: activityLogBuffer.slice(0, 50) // Send last 50 log entries 3656 - })); 3657 - 3658 - // Subscribe to updates 3659 - const unsubscribe = subscribeToUpdates((update) => { 3660 - if (ws.readyState === 1) { // OPEN 3661 - ws.send(JSON.stringify({ 3662 - version: GIT_VERSION, 3663 - serverStartTime: SERVER_START_TIME, 3664 - uptime: Date.now() - SERVER_START_TIME, 3665 - incoming: Array.from(getIncomingBakes().values()), 3666 - active: Array.from(getActiveBakes().values()), 3667 - recent: getRecentBakes(), 3668 - grabs: { 3669 - active: getActiveGrabs(), 3670 - recent: getRecentGrabs(), 3671 - queue: getQueueStatus(), 3672 - ipfsThumbs: getAllLatestIPFSUploads() 3673 - }, 3674 - grabProgress: getAllProgress(), 3675 - concurrency: getConcurrencyStatus(), 3676 - osBaseBuilds: getOSBaseBuildsSummary(), 3677 - frozen: getFrozenPieces() 3678 - })); 3679 - } 3680 - }); 3681 - 3682 - ws.on('close', () => { 3683 - console.log('📡 WebSocket client disconnected'); 3684 - unsubscribe(); 3685 - }); 3686 - }); 3687 - 3688 - // Graceful shutdown handling 3689 - async function shutdown(signal) { 3690 - console.log(`\n🛑 Received ${signal}, shutting down gracefully...`); 3691 - 3692 - // Close WebSocket connections 3693 - wss.clients.forEach(ws => ws.close()); 3694 - 3695 - // Close HTTP server 3696 - server.close(() => { 3697 - console.log('✅ HTTP server closed'); 3698 - }); 3699 - 3700 - // Close browser if open 3701 - try { 3702 - const { closeBrowser } = await import('./grabber.mjs'); 3703 - await closeBrowser?.(); 3704 - console.log('✅ Browser closed'); 3705 - } catch (e) { 3706 - // Browser close is optional 3707 - } 3708 - 3709 - // Exit after a short delay 3710 - setTimeout(() => { 3711 - console.log('👋 Goodbye!'); 3712 - process.exit(0); 3713 - }, 500); 3714 - } 3715 - 3716 - process.on('SIGTERM', () => shutdown('SIGTERM')); 3717 - process.on('SIGINT', () => shutdown('SIGINT'));