Monorepo for Aesthetic.Computer aesthetic.computer
at main 832 lines 32 kB view raw
1// os.mjs — AC Native OS management piece 2// Shows current version, checks for updates, downloads + flashes + reboots. 3// Jumped to from prompt.mjs via "os" command or from notepat OS button. 4 5const OS_BASE_URL = "https://releases-aesthetic-computer.sfo3.digitaloceanspaces.com/os/"; 6const OS_VERSION_URL = OS_BASE_URL + "native-notepat-latest.version"; 7const OS_VMLINUZ_URL = OS_BASE_URL + "native-notepat-latest.vmlinuz"; 8const OS_INITRAMFS_URL = OS_BASE_URL + "native-notepat-latest.initramfs.cpio.gz"; 9let remoteSize = 0; // parsed from version file (line 2) 10 11// Kernel no longer embeds initramfs (Phase 2 — loaded externally via EFI stub 12// `initrd=\initramfs.cpio.gz` from the ESP root). OTA must download BOTH the 13// kernel and the initramfs and flash them atomically, otherwise the device 14// boots a new kernel against a stale initramfs and code-path drift follows. 15let initramfsDownloaded = false; 16 17// States: idle | checking | up-to-date | available 18// | downloading (kernel) | downloading-initramfs | flashing 19// | confirm-reboot | shutting-down | error 20// | devices | clone-confirm | cloning 21let state = "idle"; 22let currentVersion = ""; 23let remoteVersion = ""; 24let progress = 0; 25let errorMsg = ""; 26let fetchPending = false; 27let checkFrame = 0; 28let flashTargetIdx = 0; 29let frame = 0; 30let shutdownFrame = 0; // frame counter for shutdown animation 31let flashedMB = 0; // verified MB for display during confirm/shutdown 32 33// Telemetry lines that scroll by during flash 34const telemetry = []; 35let telemetryScroll = 0; 36 37// Device manager state 38let deviceIdx = 0; 39let lastTargetCount = -1; // track hot-plug changes 40let cloneTarget = null; 41 42function addTelemetry(msg) { 43 telemetry.push({ text: msg, frame, y: 0 }); 44 if (telemetry.length > 50) telemetry.shift(); 45} 46 47function boot({ system }) { 48 currentVersion = system?.version || "unknown"; 49 // Default flash target: prefer non-boot device (e.g., NVMe when booting from USB) 50 const targets = system?.flashTargets || []; 51 const bootDev = system?.bootDevice; 52 const nonBootIdx = targets.findIndex(t => t.device !== bootDev); 53 if (nonBootIdx >= 0) flashTargetIdx = nonBootIdx; 54 // Auto-check on boot if online 55 if (system?.fetchPending === false) { 56 state = "checking"; 57 fetchPending = true; 58 checkFrame = 0; 59 system?.fetch?.(OS_VERSION_URL); 60 } 61} 62 63function act({ event: e, sound, system }) { 64 if (!e.is("keyboard:down")) return; 65 66 // Escape goes back to prompt (not during flash or shutdown) 67 if (e.is("keyboard:down:escape") || e.is("keyboard:down:backspace")) { 68 if (state !== "flashing" && state !== "shutting-down" && state !== "downloading") { 69 system?.jump?.("prompt"); 70 return; 71 } 72 } 73 74 // Reboot confirmation: y = reboot, n = back to prompt 75 if (state === "confirm-reboot") { 76 if (e.is("keyboard:down:y") || e.is("keyboard:down:enter") || e.is("keyboard:down:return")) { 77 // Block reboot if USB live media still present on a cross-device flash — 78 // firmware will boot the stale USB kernel instead of the freshly-flashed one 79 const targets = system?.flashTargets || []; 80 const tgt = targets[flashTargetIdx]; 81 const flashedToBoot = !tgt || tgt.device === system?.bootDevice; 82 if (!flashedToBoot) { 83 const usbStillAttached = targets.some(t => t.removable && t.device === system?.bootDevice); 84 if (usbStillAttached) { 85 sound?.synth({ type: "square", tone: 180, duration: 0.14, volume: 0.12, attack: 0.003, decay: 0.12 }); 86 return; 87 } 88 } 89 state = "shutting-down"; 90 shutdownFrame = 0; 91 sound?.synth({ type: "triangle", tone: 784, duration: 0.1, volume: 0.15, attack: 0.003, decay: 0.08 }); 92 return; 93 } 94 if (e.is("keyboard:down:n") || e.is("keyboard:down:escape")) { 95 system?.jump?.("prompt"); 96 return; 97 } 98 return; 99 } 100 101 // Touch-like key shortcuts for available state 102 if (state === "available") { 103 if (e.is("keyboard:down:y") || e.is("keyboard:down:enter") || e.is("keyboard:down:return")) { 104 const targets = system?.flashTargets || []; 105 const tgt = targets[flashTargetIdx]; 106 const device = tgt?.device || undefined; 107 globalThis.__osFlashDevice = device; 108 state = "downloading"; 109 progress = 0; 110 telemetry.length = 0; 111 addTelemetry("fetching " + OS_VMLINUZ_URL.split("/").pop()); 112 system?.fetchBinary?.(OS_VMLINUZ_URL, "/tmp/vmlinuz.new", (remoteSize || 93_000_000)); 113 return; 114 } 115 if (e.is("keyboard:down:n")) { 116 system?.jump?.("prompt"); 117 return; 118 } 119 if (e.is("keyboard:down:tab")) { 120 const targets = system?.flashTargets || []; 121 flashTargetIdx = (flashTargetIdx + 1) % Math.max(1, targets.length); 122 sound?.synth({ type: "sine", tone: 440, duration: 0.04, volume: 0.12, attack: 0.002, decay: 0.03 }); 123 return; 124 } 125 } 126 127 // 'd' to enter device manager from idle/up-to-date/available/error 128 if (state === "idle" || state === "up-to-date" || state === "available" || state === "error") { 129 if (e.is("keyboard:down:d")) { 130 state = "devices"; 131 deviceIdx = 0; 132 sound?.synth({ type: "sine", tone: 523, duration: 0.05, volume: 0.1, attack: 0.002, decay: 0.04 }); 133 return; 134 } 135 // 'f' enters the firmware panel — gated on system.firmware.available so 136 // it's a no-op on machines without MrChromebox/coreboot + SPI access. 137 if (e.is("keyboard:down:f") && system?.firmware?.available) { 138 state = "firmware"; 139 sound?.synth({ type: "sine", tone: 523, duration: 0.05, volume: 0.1, attack: 0.002, decay: 0.04 }); 140 return; 141 } 142 } 143 144 // Firmware panel controls 145 if (state === "firmware") { 146 if (e.is("keyboard:down:escape") || e.is("keyboard:down:backspace")) { 147 state = "idle"; 148 return; 149 } 150 // Block further input while the install thread is mid-flight. 151 if (system?.firmware?.pending) return; 152 if (e.is("keyboard:down:y") || e.is("keyboard:down:enter") || e.is("keyboard:down:return")) { 153 system?.firmware?.install?.("install"); 154 sound?.synth({ type: "triangle", tone: 784, duration: 0.1, volume: 0.12, attack: 0.003, decay: 0.08 }); 155 return; 156 } 157 if (e.is("keyboard:down:t")) { 158 system?.firmware?.install?.("dry-run"); 159 sound?.synth({ type: "sine", tone: 659, duration: 0.06, volume: 0.1, attack: 0.002, decay: 0.05 }); 160 return; 161 } 162 if (e.is("keyboard:down:r")) { 163 system?.firmware?.install?.("restore"); 164 sound?.synth({ type: "triangle", tone: 440, duration: 0.1, volume: 0.12, attack: 0.003, decay: 0.08 }); 165 return; 166 } 167 return; 168 } 169 170 // Device manager controls 171 if (state === "devices") { 172 const targets = system?.flashTargets || []; 173 if (e.is("keyboard:down:tab") || e.is("keyboard:down:arrowdown")) { 174 deviceIdx = (deviceIdx + 1) % Math.max(1, targets.length); 175 sound?.synth({ type: "sine", tone: 440, duration: 0.04, volume: 0.1, attack: 0.002, decay: 0.03 }); 176 return; 177 } 178 if (e.is("keyboard:down:arrowup")) { 179 deviceIdx = (deviceIdx - 1 + targets.length) % Math.max(1, targets.length); 180 sound?.synth({ type: "sine", tone: 440, duration: 0.04, volume: 0.1, attack: 0.002, decay: 0.03 }); 181 return; 182 } 183 // 'c' to clone current OS to selected device 184 if (e.is("keyboard:down:c")) { 185 const tgt = targets[deviceIdx]; 186 if (tgt && tgt.device !== system?.bootDevice) { 187 cloneTarget = tgt; 188 state = "clone-confirm"; 189 sound?.synth({ type: "triangle", tone: 660, duration: 0.08, volume: 0.12, attack: 0.003, decay: 0.06 }); 190 } else { 191 sound?.synth({ type: "square", tone: 220, duration: 0.1, volume: 0.08, attack: 0.005, decay: 0.08 }); 192 } 193 return; 194 } 195 // 'u' to update selected device from CDN 196 if (e.is("keyboard:down:u")) { 197 const tgt = targets[deviceIdx]; 198 if (tgt) { 199 flashTargetIdx = deviceIdx; 200 state = "checking"; 201 fetchPending = true; 202 checkFrame = frame; 203 system?.fetch?.(OS_VERSION_URL); 204 } 205 return; 206 } 207 if (e.is("keyboard:down:escape") || e.is("keyboard:down:backspace")) { 208 state = "idle"; 209 return; 210 } 211 return; 212 } 213 214 // Clone confirmation 215 if (state === "clone-confirm") { 216 if (e.is("keyboard:down:y") || e.is("keyboard:down:enter") || e.is("keyboard:down:return")) { 217 state = "cloning"; 218 telemetry.length = 0; 219 addTelemetry("cloning to " + cloneTarget.device); 220 // Clone = flash the currently running kernel to the target device 221 // The running kernel is at /mnt/EFI/BOOT/BOOTX64.EFI or KERNEL.EFI 222 const bootKernel = "/mnt/EFI/BOOT/BOOTX64.EFI"; 223 globalThis.__osFlashDevice = cloneTarget.device; 224 system?.flashUpdate?.(bootKernel, cloneTarget.device); 225 sound?.synth({ type: "triangle", tone: 784, duration: 0.1, volume: 0.12, attack: 0.003, decay: 0.08 }); 226 return; 227 } 228 if (e.is("keyboard:down:n") || e.is("keyboard:down:escape")) { 229 state = "devices"; 230 return; 231 } 232 return; 233 } 234 235 // Tap to retry on error or re-check when up-to-date/idle 236 if (state === "error" || state === "up-to-date" || state === "idle") { 237 if (e.is("keyboard:down:enter") || e.is("keyboard:down:return") || e.is("keyboard:down:space")) { 238 if (!system?.fetchPending) { 239 state = "checking"; 240 fetchPending = true; 241 checkFrame = frame; 242 system?.fetch?.(OS_VERSION_URL); 243 } 244 } 245 } 246} 247 248function paint({ wipe, ink, box, line, write, screen, system, wifi }) { 249 frame++; 250 const T = __theme.update(); 251 const w = screen.width, h = screen.height; 252 const pad = 10; 253 const font = "font_1"; 254 255 // === Shutdown animation (full-screen takeover) === 256 if (state === "shutting-down") { 257 shutdownFrame++; 258 const t = shutdownFrame / 120; // 2 second animation 259 260 // Background: dark blue fading to black 261 const bg = Math.max(0, Math.floor(20 * (1 - t))); 262 wipe(0, bg, Math.floor(bg * 1.5)); 263 264 // Scrolling telemetry lines flying upward 265 for (let i = 0; i < telemetry.length; i++) { 266 const entry = telemetry[i]; 267 const baseY = h - (shutdownFrame - i * 3) * 2; 268 if (baseY < -10 || baseY > h + 10) continue; 269 const alpha = Math.max(0, Math.min(255, Math.floor(200 * (1 - t)))); 270 const green = 80 + (i * 37 % 120); 271 ink(60, green, 100, alpha); 272 write(entry.text, { x: pad + (i % 3) * 2, y: baseY, size: 1, font }); 273 } 274 275 // Central message 276 if (t < 0.8) { 277 const pulse = Math.floor(180 + 75 * Math.sin(shutdownFrame * 0.2)); 278 ink(pulse, 255, pulse, Math.floor(255 * (1 - t / 0.8))); 279 write("launching new os", { x: pad, y: h / 2 - 20, size: 2, font: "matrix" }); 280 281 // Build name 282 const buildName = remoteVersion.split(" ")[0] || "update"; 283 ink(255, 200, 60, Math.floor(200 * (1 - t / 0.8))); 284 write(buildName, { x: pad, y: h / 2 + 4, size: 1, font }); 285 } 286 287 // Verified size 288 if (t < 0.6) { 289 ink(80, 200, 120, Math.floor(180 * (1 - t / 0.6))); 290 write(`verified ${flashedMB}MB`, { x: pad, y: h / 2 + 20, size: 1, font }); 291 } 292 293 // Progress bar shrinking to nothing 294 const barW = Math.max(0, Math.floor((w - pad * 2) * (1 - t))); 295 if (barW > 0) { 296 ink(60, 180, 100, Math.floor(200 * (1 - t))); 297 box(pad, h - 8, barW, 4, true); 298 } 299 300 // Trigger reboot after animation 301 if (shutdownFrame >= 150) { // 2.5 seconds 302 system?.reboot?.(); 303 } 304 return; 305 } 306 307 // === Normal UI === 308 wipe(T.bg[0], T.bg[1], T.bg[2]); 309 310 // Responsive: use half-width columns on wide screens 311 const wide = w > 260; 312 const colW = wide ? Math.floor((w - pad * 3) / 2) : w - pad * 2; 313 const col2X = wide ? pad + colW + pad : pad; 314 315 // Title 316 ink(T.fg, T.fg + 10, T.fg); 317 write("ac/native", { x: pad, y: 10, size: 2, font: "matrix" }); 318 319 // Connection status 320 if (!wifi?.connected) { 321 ink(T.err[0], T.err[1], T.err[2]); 322 write("offline", { x: pad, y: 34, size: 1, font }); 323 ink(T.fgMute, T.fgMute - 10, T.fgMute - 10); 324 write("connect wifi first", { x: pad, y: 46, size: 1, font }); 325 } else { 326 ink(T.fgMute, T.fgMute + 10, T.fgMute); 327 write("current", { x: pad, y: 34, size: 1, font }); 328 ink(T.fgDim, T.fgDim, T.fgDim); 329 const maxChars = Math.floor(colW / 6); 330 write(currentVersion.slice(0, maxChars), { x: pad, y: 46, size: 1, font }); 331 } 332 333 // Machine hint 334 { 335 const sX = wide ? col2X : pad; 336 const sY = wide ? 34 : 58; 337 ink(T.fgMute, T.fgMute + 5, T.fgMute + 10); 338 write("machine: hw + sw info", { x: sX, y: sY, size: 1, font }); 339 } 340 341 const stateY = 66; 342 343 if (state === "checking") { 344 ink(T.warn[0], T.warn[1], T.warn[2]); 345 const dots = ".".repeat((Math.floor(frame / 20) % 3) + 1); 346 write("checking" + dots, { x: pad, y: stateY, size: 1, font }); 347 348 } else if (state === "up-to-date") { 349 ink(T.ok[0], T.ok[1], T.ok[2]); 350 write("up to date!", { x: pad, y: stateY, size: 1, font }); 351 ink(T.fgMute, T.fgMute + 10, T.fgMute); 352 write(remoteVersion, { x: pad, y: stateY + 14, size: 1, font }); 353 ink(T.fgMute, T.fgMute, T.fgMute + 10); 354 write("enter: recheck esc: back", { x: pad, y: stateY + 30, size: 1, font }); 355 356 } else if (state === "available") { 357 ink(T.fgMute, T.fgMute + 10, T.fgMute); 358 write("available", { x: pad, y: stateY, size: 1, font }); 359 ink(T.warn[0], T.warn[1], T.warn[2]); 360 write(remoteVersion, { x: pad, y: stateY + 14, size: 1, font }); 361 362 // Flash target selector 363 const targets = system?.flashTargets || []; 364 if (targets.length > 0) { 365 if (flashTargetIdx >= targets.length) flashTargetIdx = 0; 366 const tgt = targets[flashTargetIdx]; 367 const tgtLabel = (tgt?.label || "?") + " (" + (tgt?.device || "?") + ")"; 368 const isBoot = tgt?.device === system?.bootDevice; 369 ink(80, 100, 120); 370 write("target:", { x: pad, y: stateY + 30, size: 1, font }); 371 ink(isBoot ? 80 : 200, isBoot ? 200 : 200, isBoot ? 255 : 80); 372 write(tgtLabel, { x: pad + 48, y: stateY + 30, size: 1, font }); 373 if (isBoot) { 374 ink(60, 140, 200); 375 write("(current boot)", { x: pad, y: stateY + 42, size: 1, font }); 376 } 377 if (targets.length > 1) { 378 ink(80, 80, 100); 379 write("tab: next target", { x: pad, y: stateY + 56, size: 1, font }); 380 } 381 } 382 383 // Install confirmation prompt 384 const hintY = stateY + (targets.length > 0 ? 72 : 34); 385 const pulse = Math.floor(180 + 75 * Math.sin(frame * 0.08)); 386 ink(pulse, 255, pulse); 387 write("install? y/n", { x: pad, y: hintY, size: 1, font }); 388 ink(80, 80, 100); 389 write("esc: back", { x: pad, y: hintY + 14, size: 1, font }); 390 391 } else if (state === "downloading") { 392 ink(120, 140, 120); 393 const dots = ".".repeat((Math.floor(frame / 15) % 3) + 1); 394 write("downloading" + dots, { x: pad, y: stateY, size: 1, font }); 395 396 // File info 397 const expectedMB = ((remoteSize || 93_000_000) / 1048576).toFixed(0); 398 const dlMB = ((progress || 0) * (remoteSize || 93_000_000) / 1048576).toFixed(1); 399 ink(80, 100, 80); 400 write(`${dlMB} / ${expectedMB} MB`, { x: pad, y: stateY + 14, size: 1, font }); 401 402 // Progress bar 403 const barW = w - pad * 2, barH = 8, barY = stateY + 30; 404 ink(30, 40, 50); 405 box(pad, barY, barW, barH, true); 406 ink(60, 180, 100); 407 box(pad, barY, Math.round(barW * (progress || 0)), barH, true); 408 ink(160); 409 write(Math.round((progress || 0) * 100) + "%", { x: pad, y: barY + 12, size: 1, font }); 410 411 // Target info 412 ink(60, 70, 80); 413 write("-> " + (globalThis.__osFlashDevice || system?.bootDevice || "?"), { x: pad, y: barY + 26, size: 1, font }); 414 415 } else if (state === "flashing") { 416 const phase = system?.flashPhase ?? 0; 417 const phaseIcons = ["...", ">>>", "~~~", "???", "!!!"]; 418 const phaseNames = ["preparing", "writing EFI", "syncing to disk", "verifying", "complete"]; 419 const phaseText = phaseNames[phase] || "preparing"; 420 const icon = phaseIcons[phase] || "..."; 421 422 // Phase indicator with animation 423 const dots = ".".repeat((Math.floor(frame / 10) % 3) + 1); 424 ink(...(phase === 3 ? [100, 200, 255] : phase === 4 ? [80, 255, 120] : [255, 160, 60])); 425 write(`${icon} ${phaseText}${phase < 4 ? dots : ""}`, { x: pad, y: stateY, size: 1, font }); 426 427 // Target device 428 ink(100); 429 write("-> " + (globalThis.__osFlashDevice || system?.bootDevice || "?"), { x: pad, y: stateY + 14, size: 1, font }); 430 431 // Phase progress visualization 432 const phases = ["prepare", "write", "sync", "verify"]; 433 let px = pad; 434 for (let i = 0; i < phases.length; i++) { 435 const active = i === phase || (phase === 4 && i === 3); 436 const done = i < phase || phase === 4; 437 ink(done ? 60 : 30, done ? 140 : 40, done ? 80 : 50); 438 const pw = Math.floor((w - pad * 2 - 12) / 4); 439 box(px, stateY + 30, pw, 6, true); 440 if (active && !done) { 441 // Animated fill 442 const fill = Math.floor(pw * ((frame % 60) / 60)); 443 ink(255, 200, 60); 444 box(px, stateY + 30, fill, 6, true); 445 } 446 px += pw + 4; 447 } 448 449 // Warning 450 ink(140); 451 write("do not power off", { x: pad, y: stateY + 44, size: 1, font }); 452 453 // Scrolling telemetry at bottom 454 const telY = stateY + 60; 455 const maxLines = Math.floor((h - telY - 14) / 10); 456 const startIdx = Math.max(0, telemetry.length - maxLines); 457 for (let i = startIdx; i < telemetry.length; i++) { 458 const lineY = telY + (i - startIdx) * 10; 459 const age = frame - telemetry[i].frame; 460 const brightness = Math.max(40, Math.min(120, 120 - age)); 461 ink(brightness, Math.floor(brightness * 1.2), brightness); 462 write(telemetry[i].text, { x: pad, y: lineY, size: 1, font }); 463 } 464 465 } else if (state === "confirm-reboot") { 466 // Flash complete — ask user to reboot 467 const mb = flashedMB; 468 ink(80, 255, 120); 469 write("update installed!", { x: pad, y: stateY, size: 2, font: "matrix" }); 470 471 ink(120, 200, 140); 472 write(`verified ${mb}MB written`, { x: pad, y: stateY + 24, size: 1, font }); 473 474 ink(200, 180, 100); 475 write(remoteVersion, { x: pad, y: stateY + 38, size: 1, font }); 476 477 // Flash diagnostics 478 const dst = system?.flashDst || "?"; 479 const sameDev = system?.flashSameDevice ? "same-dev" : "cross-dev"; 480 ink(80, 80, 100); 481 write(`${dst} (${sameDev})`, { x: pad, y: stateY + 50, size: 1, font }); 482 483 // Reboot prompt — pulsing 484 const pulse = Math.floor(200 + 55 * Math.sin(frame * 0.1)); 485 ink(pulse, pulse, 255); 486 write("reboot now?", { x: pad, y: stateY + 58, size: 2, font: "matrix" }); 487 488 // Warn if flashed to non-boot device (e.g. USB→NVMe: remove USB first) 489 const targets = system?.flashTargets || []; 490 const tgt = targets[flashTargetIdx]; 491 const flashedToBoot = !tgt || tgt.device === system?.bootDevice; 492 // USB still attached? flashTargets re-enumerates each frame so removal is live 493 const usbStillAttached = !flashedToBoot && 494 targets.some(t => t.removable && t.device === system?.bootDevice); 495 const rebootBlocked = usbStillAttached; 496 if (!flashedToBoot) { 497 if (usbStillAttached) { 498 // Blink warning until USB is removed 499 const blink = Math.floor(frame / 12) % 2 === 0; 500 if (blink) { 501 ink(255, 180, 60); 502 write("⚠ unplug USB before rebooting", { x: pad, y: stateY + 80, size: 1, font }); 503 } else { 504 ink(255, 60, 60); 505 write(" reboot blocked ", { x: pad, y: stateY + 80, size: 1, font }); 506 } 507 } else { 508 // Live media removed — clear the warning, show ready state 509 ink(100, 220, 120); 510 write("✓ USB removed, safe to reboot", { x: pad, y: stateY + 80, size: 1, font }); 511 } 512 } 513 514 const hintY = flashedToBoot ? stateY + 80 : stateY + 94; 515 if (rebootBlocked) { 516 ink(120, 80, 80); 517 write("y: disabled (remove USB)", { x: pad, y: hintY, size: 1, font }); 518 } else { 519 ink(60, 200, 80); 520 write("y: reboot to new os", { x: pad, y: hintY, size: 1, font }); 521 } 522 ink(140, 100, 80); 523 write("n: back to prompt", { x: pad, y: hintY + 14, size: 1, font }); 524 525 // Scrolling telemetry in background 526 const telY = hintY + 32; 527 const maxLines = Math.floor((h - telY - 14) / 10); 528 const startIdx = Math.max(0, telemetry.length - maxLines); 529 for (let i = startIdx; i < telemetry.length; i++) { 530 const lineY = telY + (i - startIdx) * 10; 531 ink(40, 50, 45); 532 write(telemetry[i].text, { x: pad, y: lineY, size: 1, font }); 533 } 534 535 } else if (state === "error") { 536 ink(T.err[0], T.err[1], T.err[2]); 537 write(("error: " + errorMsg).slice(0, Math.floor((w - pad * 2) / 6)), { x: pad, y: stateY, size: 1, font }); 538 ink(T.fgMute); 539 write("enter: retry esc: back", { x: pad, y: stateY + 14, size: 1, font }); 540 541 } else if (state === "devices") { 542 // === Device manager === 543 const targets = system?.flashTargets || []; 544 const bootDev = system?.bootDevice; 545 546 // Hot-plug detection: play sound on change 547 if (targets.length !== lastTargetCount && lastTargetCount >= 0) { 548 // Would play sound here but we don't have sound ref in paint 549 } 550 lastTargetCount = targets.length; 551 552 ink(T.fg, T.fg + 10, T.fg); 553 write("devices", { x: pad, y: stateY, size: 2, font: "matrix" }); 554 555 if (targets.length === 0) { 556 ink(T.fgMute); 557 write("no devices found", { x: pad, y: stateY + 24, size: 1, font }); 558 } else { 559 if (deviceIdx >= targets.length) deviceIdx = 0; 560 const rowH = 20; 561 for (let i = 0; i < targets.length; i++) { 562 const tgt = targets[i]; 563 const ry = stateY + 24 + i * rowH; 564 const isBoot = tgt.device === bootDev; 565 const selected = i === deviceIdx; 566 567 // Selection indicator 568 if (selected) { 569 ink(40, 60, 80); 570 box(pad - 2, ry - 2, w - pad * 2 + 4, rowH - 2, true); 571 } 572 573 // Device label + path 574 ink(selected ? 255 : T.fgMute, selected ? 255 : T.fgMute, selected ? 255 : T.fgMute); 575 write((selected ? "> " : " ") + (tgt.label || "?"), { x: pad, y: ry, size: 1, font }); 576 ink(80, 80, 100); 577 write(tgt.device, { x: pad + 100, y: ry, size: 1, font }); 578 579 // Boot indicator 580 if (isBoot) { 581 ink(60, 200, 120); 582 write("boot", { x: w - pad - 30, y: ry, size: 1, font }); 583 } else if (tgt.removable) { 584 ink(100, 140, 200); 585 write("usb", { x: w - pad - 24, y: ry, size: 1, font }); 586 } 587 } 588 589 // Actions for selected device 590 const actY = stateY + 24 + targets.length * rowH + 8; 591 const sel = targets[deviceIdx]; 592 const isBootDev = sel?.device === bootDev; 593 594 ink(80, 80, 100); 595 write("tab/arrows: select", { x: pad, y: actY, size: 1, font }); 596 597 if (!isBootDev) { 598 ink(100, 200, 140); 599 write("c: clone current os", { x: pad, y: actY + 14, size: 1, font }); 600 } 601 ink(100, 160, 220); 602 write("u: update from cloud", { x: pad, y: actY + 28, size: 1, font }); 603 } 604 605 } else if (state === "clone-confirm") { 606 ink(T.warn[0], T.warn[1], T.warn[2]); 607 write("clone os?", { x: pad, y: stateY, size: 2, font: "matrix" }); 608 609 ink(T.fgMute + 20, T.fgMute + 20, T.fgMute); 610 write("from: " + (system?.bootDevice || "?"), { x: pad, y: stateY + 24, size: 1, font }); 611 write(" to: " + (cloneTarget?.label || "?") + " (" + (cloneTarget?.device || "?") + ")", { x: pad, y: stateY + 38, size: 1, font }); 612 613 ink(T.fg); 614 write(currentVersion, { x: pad, y: stateY + 56, size: 1, font }); 615 616 ink(255, 180, 60); 617 write("this will overwrite the target!", { x: pad, y: stateY + 74, size: 1, font }); 618 619 const pulse = Math.floor(200 + 55 * Math.sin(frame * 0.1)); 620 ink(pulse, 255, pulse); 621 write("y: clone n: cancel", { x: pad, y: stateY + 92, size: 1, font }); 622 623 } else if (state === "cloning") { 624 // Reuse flashing UI 625 const phase = system?.flashPhase ?? 0; 626 const phaseNames = ["preparing", "writing EFI", "syncing", "verifying", "complete"]; 627 const dots = ".".repeat((Math.floor(frame / 10) % 3) + 1); 628 ink(255, 160, 60); 629 write("cloning" + (phase < 4 ? dots : "!"), { x: pad, y: stateY, size: 1, font }); 630 ink(120); 631 write(phaseNames[phase] || "...", { x: pad, y: stateY + 14, size: 1, font }); 632 ink(80); 633 write("-> " + (cloneTarget?.device || "?"), { x: pad, y: stateY + 28, size: 1, font }); 634 ink(140); 635 write("do not power off", { x: pad, y: stateY + 44, size: 1, font }); 636 637 } else if (state === "firmware") { 638 // ── Firmware update panel ── 639 // Only entered from idle when system.firmware.available is true, so by 640 // definition we can surface "update available" without a coreboot check 641 // here. We still sanity-gate the `y` action on .available in case of 642 // hot state (rare: SPI driver unbind, MTD removed). 643 const fw = system?.firmware || {}; 644 ink(T.fg, T.fg + 10, T.fg); 645 write("firmware", { x: pad, y: stateY, size: 2, font: "matrix" }); 646 ink(T.fgMute); 647 write("board: " + (fw.board || "?"), { x: pad, y: stateY + 24, size: 1, font }); 648 write("vendor: " + (fw.biosVendor || "?").slice(0, 32), { x: pad, y: stateY + 38, size: 1, font }); 649 write("current: " + (fw.biosVersion || "?").slice(0, 32), { x: pad, y: stateY + 52, size: 1, font }); 650 651 if (fw.pending) { 652 ink(T.warn[0], T.warn[1], T.warn[2]); 653 const dots = ".".repeat((Math.floor(frame / 12) % 3) + 1); 654 write("flashing firmware" + dots, { x: pad, y: stateY + 74, size: 1, font }); 655 ink(255, 80, 80); 656 write("DO NOT POWER OFF", { x: pad, y: stateY + 88, size: 1, font }); 657 } else if (fw.done) { 658 if (fw.ok) { 659 ink(T.ok[0], T.ok[1], T.ok[2]); 660 write("✓ firmware updated", { x: pad, y: stateY + 74, size: 1, font }); 661 if (fw.backupPath) { 662 ink(T.fgMute); 663 write("backup: " + fw.backupPath.slice(0, 44), { x: pad, y: stateY + 88, size: 1, font }); 664 } 665 ink(T.warn[0], T.warn[1], T.warn[2]); 666 write("reboot to activate (esc → os → y)", { x: pad, y: stateY + 102, size: 1, font }); 667 } else { 668 ink(T.err[0], T.err[1], T.err[2]); 669 write("✗ flash failed — see log", { x: pad, y: stateY + 74, size: 1, font }); 670 } 671 } else { 672 const pulse = Math.floor(180 + 75 * Math.sin(frame * 0.08)); 673 ink(pulse, 255, pulse); 674 write("y: install t: dry-run r: restore", { x: pad, y: stateY + 74, size: 1, font }); 675 ink(T.fgMute); 676 write("swaps bootsplash on MrChromebox ROM", { x: pad, y: stateY + 88, size: 1, font }); 677 } 678 679 // Live log tail — last ~6 lines from the install script 680 const log = fw.log || []; 681 const maxLines = Math.min(6, log.length); 682 const logY = stateY + 120; 683 for (let i = 0; i < maxLines; i++) { 684 const entry = log[log.length - maxLines + i]; 685 ink(60, 120, 80); 686 write(String(entry).slice(0, Math.floor((w - pad * 2) / 6)), { x: pad, y: logY + i * 10, size: 1, font }); 687 } 688 689 } else { 690 // idle 691 ink(T.fgMute); 692 write("enter: check for updates", { x: pad, y: stateY, size: 1, font }); 693 ink(T.fgMute - 20, T.fgMute, T.fgMute + 10); 694 write("d: devices", { x: pad, y: stateY + 14, size: 1, font }); 695 // Only surface the firmware shortcut when the kernel + DMI confirm this 696 // board can actually be flashed — no false promises on stock OEM BIOS. 697 if (system?.firmware?.available) { 698 ink(T.fgMute - 20, T.fgMute + 10, T.fgMute); 699 write("f: firmware", { x: pad, y: stateY + 28, size: 1, font }); 700 } 701 } 702 703 // Bottom hint (not during shutdown) 704 if (state !== "shutting-down") { 705 ink(T.fgMute, T.fgMute + 10, T.fgMute); 706 write(state === "devices" ? "esc: back to os" : "esc: back", { x: pad, y: h - 12, size: 1, font }); 707 } 708 709 // === State machine: poll fetch/flash results === 710 711 // Version check result (from .version file: "name hash-ts\nsize") 712 if (fetchPending && system?.fetchResult !== undefined && system?.fetchResult !== null) { 713 const raw = (typeof system.fetchResult === "string" ? system.fetchResult : "").trim(); 714 fetchPending = false; 715 if (!raw || raw.length < 5) { 716 state = "error"; 717 errorMsg = "bad version response"; 718 } else { 719 const lines = raw.split("\n"); 720 remoteVersion = lines[0].trim(); 721 if (lines[1]) remoteSize = parseInt(lines[1].trim()) || 0; 722 state = (remoteVersion === currentVersion) ? "up-to-date" : "available"; 723 } 724 } 725 if (fetchPending && system?.fetchError) { 726 fetchPending = false; 727 state = "error"; 728 errorMsg = "fetch failed"; 729 } 730 // Timeout 731 if (fetchPending && frame - checkFrame > 600) { 732 fetchPending = false; 733 state = "error"; 734 errorMsg = "timeout"; 735 } 736 737 // Download progress (kernel) 738 if (state === "downloading") { 739 const prevProgress = progress; 740 progress = system?.fetchBinaryProgress ?? progress; 741 if (progress > 0 && Math.floor(progress * 10) > Math.floor(prevProgress * 10)) { 742 const pct = Math.round(progress * 100); 743 addTelemetry(`kernel ${pct}% (${(progress * (remoteSize || 13_000_000) / 1048576).toFixed(1)}MB)`); 744 } 745 if (system?.fetchBinaryDone) { 746 if (system?.fetchBinaryOk) { 747 addTelemetry("kernel downloaded, fetching initramfs..."); 748 state = "downloading-initramfs"; 749 progress = 0; 750 initramfsDownloaded = false; 751 // Initramfs size isn't in the .version file today; use a generous 752 // default so the progress bar doesn't stall at 100% prematurely. 753 system?.fetchBinary?.(OS_INITRAMFS_URL, "/tmp/initramfs.cpio.gz.new", 336_000_000); 754 } else { 755 state = "error"; 756 errorMsg = "kernel download failed"; 757 } 758 } 759 } 760 761 // Download progress (initramfs) — kicks off flash once both files are local 762 if (state === "downloading-initramfs") { 763 const prevProgress = progress; 764 progress = system?.fetchBinaryProgress ?? progress; 765 if (progress > 0 && Math.floor(progress * 10) > Math.floor(prevProgress * 10)) { 766 const pct = Math.round(progress * 100); 767 addTelemetry(`initramfs ${pct}%`); 768 } 769 if (system?.fetchBinaryDone) { 770 if (system?.fetchBinaryOk) { 771 initramfsDownloaded = true; 772 addTelemetry("initramfs downloaded, starting flash..."); 773 state = "flashing"; 774 const dev = globalThis.__osFlashDevice; 775 addTelemetry("target: " + (dev || system?.bootDevice || "auto")); 776 // Four-arg flashUpdate: (kernelSrc, device, initramfsSrc) 777 // Device may be omitted — null lets C auto-detect the boot device. 778 system?.flashUpdate?.("/tmp/vmlinuz.new", dev || null, "/tmp/initramfs.cpio.gz.new"); 779 } else { 780 state = "error"; 781 errorMsg = "initramfs download failed"; 782 } 783 } 784 } 785 786 // Clone progress 787 if (state === "cloning") { 788 if (system?.flashDone) { 789 if (system?.flashOk) { 790 flashedMB = ((system?.flashVerifiedBytes ?? 0) / 1048576).toFixed(1); 791 state = "confirm-reboot"; 792 } else { 793 state = "error"; 794 errorMsg = "clone verify failed"; 795 } 796 } 797 } 798 799 // Flash progress — track phase transitions for telemetry 800 if (state === "flashing") { 801 const phase = system?.flashPhase ?? 0; 802 const prevPhase = globalThis.__osLastFlashPhase ?? -1; 803 if (phase !== prevPhase) { 804 globalThis.__osLastFlashPhase = phase; 805 const names = ["preparing flash", "writing EFI image", "syncing to disk", "verifying bytes", "flash complete"]; 806 addTelemetry(names[phase] || `phase ${phase}`); 807 if (phase === 1) addTelemetry("dst: EFI/BOOT/BOOTX64.EFI"); 808 } 809 if (system?.flashDone) { 810 // Capture flash log from C layer 811 const flog = system?.flashLog || []; 812 for (const line of flog) addTelemetry("[c] " + line); 813 if (system?.flashDst) addTelemetry("wrote: " + system.flashDst); 814 addTelemetry("same_dev=" + (system?.flashSameDevice ? "yes" : "no")); 815 816 if (system?.flashOk) { 817 flashedMB = ((system?.flashVerifiedBytes ?? 0) / 1048576).toFixed(1); 818 addTelemetry(`verified OK: ${flashedMB}MB`); 819 state = "confirm-reboot"; 820 } else { 821 state = "error"; 822 errorMsg = "flash verify failed"; 823 addTelemetry("VERIFY FAILED"); 824 for (const line of flog) addTelemetry("[c] " + line); 825 } 826 } 827 } 828} 829 830function sim() {} 831 832export { boot, paint, act, sim };