Monorepo for Aesthetic.Computer
aesthetic.computer
1// ac-native.c — Sub-second boot AC piece runner
2// Runs as PID 1 in a minimal initramfs.
3// UEFI → EFI stub kernel → this binary → piece.mjs
4
5#include <stdio.h>
6#include <stdlib.h>
7#include <stdarg.h>
8#include <string.h>
9#include <signal.h>
10#include <time.h>
11#include <math.h>
12#include <unistd.h>
13#include <sys/wait.h>
14#include <fcntl.h>
15#include <dirent.h>
16#include <errno.h>
17#include <sys/mount.h>
18#include <sys/statvfs.h>
19#include <sys/stat.h>
20#include <sys/reboot.h>
21#include <linux/reboot.h>
22#include <linux/input.h>
23#include <linux/fs.h> // BLKRRPART for forced partition re-read (install)
24#include <sys/ioctl.h>
25#include <pthread.h>
26
27#include "drm-display.h"
28#include "framebuffer.h"
29#include "graph.h"
30#include "font.h"
31#include "input.h"
32#include "audio.h"
33#include "wifi.h"
34#include "tts.h"
35#include "js-bindings.h"
36#include "machines.h"
37#include "recorder.h"
38#ifdef USE_WAYLAND
39#include "wayland-display.h"
40#endif
41
42static volatile int running = 1;
43static FILE *logfile = NULL;
44static volatile int log_dirty = 0;
45int ac_log_stderr_muted = 0; // When set, ac_log skips stderr (PTY active)
46int voice_off = 1; // Keystroke TTS disabled by default (enable with voice:on in config)
47static int is_removable(const char *blkname);
48static void get_parent_block(const char *part, char *out, int out_sz);
49
50// ── Performance logger (crash-resilient chunked files) ──
51// Writes /mnt/perf/NNNN.csv every 30s, each chunk fsync'd and closed.
52// On hard crash you lose at most 30 seconds. Keeps last 5 minutes (10 chunks).
53#define PERF_CHUNK_SECS 30
54#define PERF_CHUNK_FRAMES (60 * PERF_CHUNK_SECS) // 1800 frames per chunk
55#define PERF_MAX_CHUNKS 10 // 10 × 30s = 5 minutes
56#define PERF_BUF_SIZE PERF_CHUNK_FRAMES
57
58typedef struct {
59 uint32_t frame;
60 uint16_t total_us; // total frame time in microseconds (capped at 65535)
61 uint16_t act_us;
62 uint16_t sim_us;
63 uint16_t paint_us;
64 uint16_t present_us;
65 uint8_t voices; // active synth voices
66 uint8_t events; // input events this frame
67 uint8_t js_heap_mb; // QuickJS heap in MB (capped at 255)
68 uint8_t flags; // bit0=trackpadFX, bit1=cursor_visible
69} PerfRecord;
70
71static PerfRecord *perf_buf = NULL;
72static int perf_buf_count = 0; // records in current chunk buffer
73static int perf_chunk_seq = 0; // monotonic chunk sequence number
74static int perf_flush_frame = 0; // last frame we flushed
75
76static void perf_init(void) {
77 perf_buf = calloc(PERF_BUF_SIZE, sizeof(PerfRecord));
78 if (!perf_buf) fprintf(stderr, "[perf] alloc failed\n");
79 mkdir("/mnt/perf", 0755); // ensure directory exists
80}
81
82static void perf_record(PerfRecord *r) {
83 if (!perf_buf || perf_buf_count >= PERF_BUF_SIZE) return;
84 perf_buf[perf_buf_count++] = *r;
85}
86
87// Write current buffer as a numbered chunk file, fsync, close, then
88// delete the oldest chunk if we exceed PERF_MAX_CHUNKS.
89void perf_flush(void) {
90 if (!perf_buf || perf_buf_count == 0) return;
91
92 char path[128];
93 snprintf(path, sizeof(path), "/mnt/perf/%04d.csv", perf_chunk_seq);
94
95 int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
96 if (fd < 0) return;
97
98 FILE *f = fdopen(fd, "w");
99 if (!f) { close(fd); return; }
100
101 fprintf(f, "frame,total_us,act_us,sim_us,paint_us,present_us,voices,events,heap_mb,flags\n");
102 for (int i = 0; i < perf_buf_count; i++) {
103 PerfRecord *r = &perf_buf[i];
104 fprintf(f, "%u,%u,%u,%u,%u,%u,%u,%u,%u,%u\n",
105 r->frame, r->total_us, r->act_us, r->sim_us,
106 r->paint_us, r->present_us, r->voices, r->events,
107 r->js_heap_mb, r->flags);
108 }
109 fflush(f);
110 fsync(fd);
111 fclose(f); // also closes fd
112
113 perf_buf_count = 0;
114
115 // Delete oldest chunk beyond retention window
116 int old_seq = perf_chunk_seq - PERF_MAX_CHUNKS;
117 if (old_seq >= 0) {
118 char old_path[128];
119 snprintf(old_path, sizeof(old_path), "/mnt/perf/%04d.csv", old_seq);
120 unlink(old_path);
121 }
122
123 perf_chunk_seq++;
124}
125
126static void perf_destroy(void) {
127 perf_flush();
128 free(perf_buf);
129 perf_buf = NULL;
130}
131
132// Forward declaration — defined after init_log_mount()
133extern char g_machine_id[64];
134
135// DRM handoff for xdg-open browser popup
136// SIGUSR1 = release DRM master (browser takes over display)
137// SIGUSR2 = reboot request from cage child (or reclaim DRM in DRM mode)
138static volatile int drm_handoff_release = 0;
139static volatile int drm_handoff_reclaim = 0;
140static volatile int reboot_requested = 0;
141volatile int poweroff_requested = 0; // extern'd in js-bindings.c
142
143static void sigusr_handler(int sig) {
144 if (sig == SIGUSR1) drm_handoff_release = 1;
145 if (sig == SIGUSR2) reboot_requested = 1;
146}
147
148static void sigterm_handler(int sig) {
149 (void)sig;
150 poweroff_requested = 1;
151}
152
153// Shutdown/reboot that works in all three contexts:
154// - PID 1 (bare metal, direct DRM boot): reboot() syscall works directly.
155// - Child of init script (bare metal): exit with special code so the init
156// shell script can invoke `poweroff -f` and the reboot syscall itself.
157// We ALSO try the reboot syscall directly since we're running as root
158// with CAP_SYS_BOOT — that's faster than round-tripping through init.
159// - Under systemd (NixOS): fall back to systemctl.
160static void ac_poweroff(void) {
161 sync();
162 usleep(500000);
163 sync();
164 // Try the kernel syscall first — works as root with CAP_SYS_BOOT, which
165 // we always have on bare metal (PID 1 or child of init). Only non-root
166 // contexts (NixOS under systemd) need to shell out.
167 if (reboot(LINUX_REBOOT_CMD_POWER_OFF) == 0) {
168 // Shouldn't reach here — syscall succeeds → kernel halts.
169 _exit(0);
170 }
171 // Syscall failed (likely EPERM on systemd) — fall back.
172 system("systemctl poweroff || /sbin/poweroff -f || /bin/poweroff -f || poweroff -f");
173 // If the initramfs init script is our parent, exit(0) tells it we've
174 // finished; it will do its own `poweroff -f` + sysrq as a last resort.
175 _exit(0);
176}
177
178static void ac_reboot(void) {
179 sync();
180 usleep(500000);
181 sync();
182 if (reboot(LINUX_REBOOT_CMD_RESTART) == 0) {
183 _exit(2);
184 }
185 system("systemctl reboot || /sbin/reboot -f || /bin/reboot -f || reboot -f");
186 _exit(2);
187}
188
189static void signal_handler(int sig) {
190 running = 0;
191
192 // Best-effort crash report to /mnt/crash.json for next-boot upload
193 if (sig == SIGSEGV || sig == SIGBUS || sig == SIGABRT || sig == SIGFPE) {
194 const char *signame = "UNKNOWN";
195 switch (sig) {
196 case SIGSEGV: signame = "SIGSEGV"; break;
197 case SIGBUS: signame = "SIGBUS"; break;
198 case SIGABRT: signame = "SIGABRT"; break;
199 case SIGFPE: signame = "SIGFPE"; break;
200 }
201 FILE *f = fopen("/mnt/crash.json", "w");
202 if (f) {
203 time_t now = time(NULL);
204 fprintf(f, "{\"signal\":\"%s\",\"machineId\":\"%s\",\"time\":%ld}\n",
205 signame, g_machine_id, (long)now);
206 fclose(f);
207 sync();
208 }
209 // Re-raise to get default behavior (core dump / termination)
210 signal(sig, SIG_DFL);
211 raise(sig);
212 }
213}
214
215// Log to stderr (when unmuted) and logfile
216void ac_log(const char *fmt, ...) {
217 va_list args, args2;
218 va_start(args, fmt);
219 va_copy(args2, args);
220 if (!ac_log_stderr_muted) vfprintf(stderr, fmt, args);
221 va_end(args);
222 if (logfile) {
223 vfprintf(logfile, fmt, args2);
224 fflush(logfile);
225 log_dirty = 1;
226 }
227 va_end(args2);
228}
229
230// Flush log file to disk without closing it
231void ac_log_flush(void) {
232 if (logfile) {
233 fflush(logfile);
234 if (log_dirty) {
235 fsync(fileno(logfile));
236 log_dirty = 0;
237 }
238 }
239}
240
241// Temporarily close the log file (e.g. before flash writes to same partition)
242void ac_log_pause(void) {
243 if (logfile) {
244 fflush(logfile);
245 if (log_dirty) {
246 fsync(fileno(logfile));
247 log_dirty = 0;
248 }
249 fclose(logfile);
250 logfile = NULL;
251 }
252}
253
254// Reopen the log file in append mode after a pause
255void ac_log_resume(void) {
256 if (!logfile) {
257 logfile = fopen("/mnt/ac-native.log", "a");
258 // If reopen fails, logging continues to stderr only
259 }
260}
261
262// Mount minimal filesystems (PID 1 only)
263static void mount_minimal_fs(void) {
264 mkdir("/proc", 0755);
265 mkdir("/sys", 0755);
266 mkdir("/dev", 0755);
267 mkdir("/tmp", 0755);
268
269 mount("proc", "/proc", "proc", 0, NULL);
270 mount("sysfs", "/sys", "sysfs", 0, NULL);
271 // Re-mount devtmpfs — safe here because by DRM fallback time i915 is fully loaded,
272 // so the fresh devtmpfs will have /dev/dri/card0. (Init does NOT re-mount devtmpfs
273 // so cage can see the kernel's original card0 early.)
274 mount("devtmpfs", "/dev", "devtmpfs", 0, NULL);
275 mkdir("/dev/pts", 0755);
276 mount("devpts", "/dev/pts", "devpts", 0, "ptmxmode=0666");
277 mkdir("/dev/shm", 0755);
278 mount("tmpfs", "/dev/shm", "tmpfs", 0, NULL);
279 // Don't re-mount /tmp — init already mounted it and may have put logs there.
280
281 // Enable zram swap (compressed RAM — effectively doubles available memory)
282 // Firefox + GTK needs significant memory beyond the initramfs tmpfs
283 system("(modprobe zram 2>/dev/null || true); "
284 "[ -e /sys/block/zram0/disksize ] && [ -b /dev/zram0 ] && "
285 "echo 1G > /sys/block/zram0/disksize && "
286 "mkswap /dev/zram0 >/dev/null 2>&1 && "
287 "swapon /dev/zram0 2>/dev/null");
288
289 // Bring up loopback interface (needed for Claude OAuth callback server)
290 system("/bin/ip link set lo up 2>/dev/null || /usr/sbin/ip link set lo up 2>/dev/null || ifconfig lo up 2>/dev/null");
291
292 // Wait for display device (up to 10s — Gemini Lake GPUs can be slow)
293 for (int i = 0; i < 1000; i++) {
294 if (access("/dev/dri/card0", F_OK) == 0 ||
295 access("/dev/dri/card1", F_OK) == 0 ||
296 access("/dev/fb0", F_OK) == 0) break;
297 usleep(10000);
298 }
299
300 // Set performance power mode
301 FILE *gov = fopen("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor", "w");
302 if (gov) { fputs("performance", gov); fclose(gov); }
303 // Set all CPUs to performance
304 for (int c = 1; c < 16; c++) {
305 char path[128];
306 snprintf(path, sizeof(path), "/sys/devices/system/cpu/cpu%d/cpufreq/scaling_governor", c);
307 gov = fopen(path, "w");
308 if (gov) { fputs("performance", gov); fclose(gov); }
309 }
310}
311
312// Try to mount boot USB for log writing (non-blocking, best-effort)
313char log_dev[32] = ""; // non-static: accessed by js-bindings.c for flash target check
314static void try_mount_log(void) {
315 mkdir("/mnt", 0755);
316 // Wait for USB block devices to appear (up to 2s after EFI handoff)
317 fprintf(stderr, "[ac-native] Waiting for USB block devices...\n");
318 for (int w = 0; w < 100; w++) {
319 if (access("/dev/sda1", F_OK) == 0 || access("/dev/sda2", F_OK) == 0 ||
320 access("/dev/sdb1", F_OK) == 0 || access("/dev/sdb2", F_OK) == 0) break;
321 usleep(20000);
322 }
323 fprintf(stderr, "[ac-native] sda1=%s sda2=%s sdb1=%s sdb2=%s\n",
324 access("/dev/sda1", F_OK) == 0 ? "yes" : "no",
325 access("/dev/sda2", F_OK) == 0 ? "yes" : "no",
326 access("/dev/sdb1", F_OK) == 0 ? "yes" : "no",
327 access("/dev/sdb2", F_OK) == 0 ? "yes" : "no");
328 const char *devs[] = {
329 "/dev/sda1", "/dev/sda2",
330 "/dev/sdb1", "/dev/sdb2",
331 "/dev/sdc1", "/dev/sdc2",
332 "/dev/sdd1", "/dev/sdd2",
333 "/dev/nvme0n1p1", "/dev/nvme0n1p2",
334 "/dev/nvme1n1p1", "/dev/nvme1n1p2",
335 "/dev/mmcblk0p1", "/dev/mmcblk0p2",
336 "/dev/mmcblk1p1", "/dev/mmcblk1p2",
337 NULL
338 };
339
340 // Pass 0: removable media first (USB install source).
341 // Pass 1: fallback to internal ESP (ensures config.json loads on disk boots).
342 for (int pass = 0; pass < 2; pass++) {
343 for (int i = 0; devs[i]; i++) {
344 if (access(devs[i], F_OK) != 0) continue;
345
346 char blk[32] = "";
347 get_parent_block(devs[i] + 5, blk, sizeof(blk)); // skip "/dev/"
348 int rem = blk[0] ? is_removable(blk) : -1;
349 if (pass == 0 && rem != 1) continue;
350 if (pass == 1 && rem == 1) continue;
351
352 int mr = mount(devs[i], "/mnt", "vfat", 0, NULL);
353 if (mr != 0)
354 fprintf(stderr, "[ac-native] mount %s failed: %s\n", devs[i], strerror(errno));
355 if (mr == 0) {
356 logfile = fopen("/mnt/ac-native.log", "a");
357 if (logfile) {
358 // Separator between boots for multi-boot log history
359 fprintf(logfile, "\n=== BOOT %s ===\n", devs[i]);
360 fprintf(logfile, "[ac-native] Log opened on %s (removable=%d)\n", devs[i], rem);
361 fflush(logfile);
362 fsync(fileno(logfile));
363 strncpy(log_dev, devs[i], sizeof(log_dev) - 1);
364 fprintf(stderr, "[ac-native] Log: %s -> /mnt/ac-native.log (removable=%d)\n", devs[i], rem);
365 // Log available block devices for storage diagnostics
366 {
367 const char *bdevs[] = {"sda","sdb","sdc","sdd","nvme0n1","nvme1n1","mmcblk0","mmcblk1",NULL};
368 for (int b = 0; bdevs[b]; b++) {
369 char bp[48]; snprintf(bp, sizeof(bp), "/sys/block/%s", bdevs[b]);
370 if (access(bp, F_OK) == 0) {
371 int brem = is_removable(bdevs[b]);
372 ac_log("[storage] /dev/%s removable=%d\n", bdevs[b], brem);
373 }
374 }
375 }
376 // Dump init debug lines to USB from /tmp/ac-init.log (written by init)
377 // and also try kmsg as backup
378 {
379 FILE *initlog = fopen("/mnt/init.log", "w");
380 if (initlog) {
381 // Diagnostics: what does /tmp look like?
382 fprintf(initlog, "diag: pid=%d\n", getpid());
383 fprintf(initlog, "diag: /tmp/ac-init.log access=%d\n",
384 access("/tmp/ac-init.log", F_OK));
385 fprintf(initlog, "diag: /tmp/cage-stderr.log access=%d\n",
386 access("/tmp/cage-stderr.log", F_OK));
387 // List /tmp contents
388 DIR *tmpdir = opendir("/tmp");
389 if (tmpdir) {
390 struct dirent *te;
391 while ((te = readdir(tmpdir)) != NULL) {
392 if (te->d_name[0] != '.')
393 fprintf(initlog, "diag: /tmp/%s\n", te->d_name);
394 }
395 closedir(tmpdir);
396 } else {
397 fprintf(initlog, "diag: opendir /tmp failed\n");
398 }
399 // Primary: read tmpfs log written by init script
400 FILE *tmplog = fopen("/tmp/ac-init.log", "r");
401 if (tmplog) {
402 char kbuf[512];
403 while (fgets(kbuf, sizeof(kbuf), tmplog))
404 fputs(kbuf, initlog);
405 fclose(tmplog);
406 }
407 // Also dump cage stderr if it exists
408 FILE *cage_err = fopen("/tmp/cage-stderr.log", "r");
409 if (cage_err) {
410 fprintf(initlog, "--- cage stderr ---\n");
411 char kbuf[512];
412 while (fgets(kbuf, sizeof(kbuf), cage_err))
413 fputs(kbuf, initlog);
414 fclose(cage_err);
415 }
416 // Backup: scan kmsg for ac-init lines
417 int kmsg = open("/dev/kmsg", O_RDONLY | O_NONBLOCK);
418 if (kmsg >= 0) {
419 lseek(kmsg, 0, SEEK_SET);
420 char kbuf[512];
421 ssize_t r;
422 int found = 0;
423 while ((r = read(kmsg, kbuf, sizeof(kbuf) - 1)) > 0) {
424 kbuf[r] = 0;
425 if (strstr(kbuf, "ac-init:")) {
426 if (!found) { fprintf(initlog, "--- kmsg ---\n"); found = 1; }
427 char *msg = strstr(kbuf, "ac-init:");
428 fprintf(initlog, "%s\n", msg);
429 }
430 }
431 close(kmsg);
432 }
433 fflush(initlog);
434 fsync(fileno(initlog));
435 fclose(initlog);
436 }
437 }
438 return;
439 }
440 umount("/mnt");
441 }
442 fprintf(stderr, "[ac-native] Log mount failed: %s\n", devs[i]);
443 }
444 }
445 // Fallback: log to tmpfs (won't survive reboot but stderr goes to console)
446 fprintf(stderr, "[ac-native] No USB log mount available\n");
447}
448
449// ── Persistent machine ID ──
450// Generated on first boot, read back on subsequent boots.
451// Accessible from js-bindings.c via extern.
452char g_machine_id[64] = {0};
453static ACMachines g_machines = {0};
454
455static void init_machine_id(void) {
456 FILE *f = fopen("/mnt/.machine-id", "r");
457 if (f) {
458 if (fgets(g_machine_id, sizeof(g_machine_id), f)) {
459 char *nl = strchr(g_machine_id, '\n');
460 if (nl) *nl = '\0';
461 }
462 fclose(f);
463 ac_log("[machine] ID loaded: %s\n", g_machine_id);
464 } else {
465 unsigned int rval = 0;
466 FILE *urand = fopen("/dev/urandom", "r");
467 if (urand) {
468 fread(&rval, sizeof(rval), 1, urand);
469 fclose(urand);
470 } else {
471 rval = (unsigned int)(time(NULL) ^ getpid());
472 }
473 snprintf(g_machine_id, sizeof(g_machine_id), "ac-%08x", rval);
474 f = fopen("/mnt/.machine-id", "w");
475 if (f) {
476 fprintf(f, "%s\n", g_machine_id);
477 fclose(f);
478 ac_log("[machine] New ID generated: %s\n", g_machine_id);
479 } else {
480 ac_log("[machine] WARNING: Could not write /mnt/.machine-id\n");
481 }
482 }
483}
484
485// Forward declarations for time-of-day functions (defined later)
486static int get_la_offset(void);
487static int get_la_hour(void);
488
489// Global display pointer — exposed to js-bindings for browser DRM handoff
490void *g_display = NULL;
491
492#ifdef USE_WAYLAND
493// Global Wayland display — used by ac_display_present dispatch
494static ACWaylandDisplay *g_wayland_display = NULL;
495#endif
496
497// Unified display present — dispatches to Wayland or DRM backend
498static void ac_display_present(ACDisplay *display, ACFramebuffer *screen, int scale) {
499#ifdef USE_WAYLAND
500 if (g_wayland_display) {
501 wayland_display_present(g_wayland_display, screen, scale);
502 return;
503 }
504#endif
505 display_present(display, screen, scale);
506}
507
508// DRM master release/acquire (defined in drm-display.c)
509extern int drm_release_master(void *display);
510extern int drm_acquire_master(void *display);
511
512// Boot title — defaults to "notepat", overridden by config.json handle
513int wifi_disabled = 0; // set from config.json "wifi":false (extern'd in js-bindings.c)
514static char boot_title[80] = "notepat";
515static ACColor boot_title_colors[80];
516static int boot_title_colors_len = 0;
517
518static uint8_t clamp_u8(int v) {
519 if (v < 0) return 0;
520 if (v > 255) return 255;
521 return (uint8_t)v;
522}
523
524static int parse_config_string(const char *json, const char *key, char *out, int out_sz) {
525 if (!json || !key || !out || out_sz < 2) return 0;
526 const char *kp = strstr(json, key);
527 if (!kp) return 0;
528 const char *colon = strchr(kp, ':');
529 if (!colon) return 0;
530 const char *q1 = strchr(colon, '"');
531 if (!q1) return 0;
532 const char *q2 = strchr(q1 + 1, '"');
533 if (!q2) return 0;
534 int len = (int)(q2 - q1 - 1);
535 if (len <= 0 || len >= out_sz) return 0;
536 memcpy(out, q1 + 1, len);
537 out[len] = 0;
538 return 1;
539}
540
541static int parse_config_bool(const char *json, const char *key, int *out) {
542 if (!json || !key || !out) return 0;
543 const char *kp = strstr(json, key);
544 if (!kp) return 0;
545 const char *colon = strchr(kp, ':');
546 if (!colon) return 0;
547 const char *p = colon + 1;
548 while (*p == ' ' || *p == '\t') p++;
549 if (strncmp(p, "true", 4) == 0) { *out = 1; return 1; }
550 if (strncmp(p, "false", 5) == 0) { *out = 0; return 1; }
551 return 0;
552}
553
554static int parse_json_int_field(const char *start, const char *limit, const char *key, int *out) {
555 if (!start || !limit || !key || !out || start >= limit) return 0;
556 const char *kp = strstr(start, key);
557 if (!kp || kp >= limit) return 0;
558 const char *colon = strchr(kp, ':');
559 if (!colon || colon >= limit) return 0;
560 const char *p = colon + 1;
561 while (p < limit && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')) p++;
562 if (p >= limit) return 0;
563 char *endp = NULL;
564 long v = strtol(p, &endp, 10);
565 if (!endp || endp == p || endp > limit) return 0;
566 *out = (int)v;
567 return 1;
568}
569
570static void parse_boot_title_colors(const char *json) {
571 boot_title_colors_len = 0;
572 if (!json) return;
573
574 // Prefer explicit title_colors, fallback to handle colors from API payload.
575 const char *cp = strstr(json, "\"title_colors\"");
576 if (!cp) cp = strstr(json, "\"colors\"");
577 if (!cp) return;
578
579 const char *arr0 = strchr(cp, '[');
580 if (!arr0) return;
581 const char *arr1 = strchr(arr0 + 1, ']');
582 if (!arr1) return;
583
584 const char *p = arr0 + 1;
585 while (p < arr1 && boot_title_colors_len < (int)(sizeof(boot_title_colors) / sizeof(boot_title_colors[0]))) {
586 const char *obj0 = strchr(p, '{');
587 if (!obj0 || obj0 >= arr1) break;
588 const char *obj1 = strchr(obj0, '}');
589 if (!obj1 || obj1 > arr1) break;
590
591 int r = -1, g = -1, b = -1;
592 if (parse_json_int_field(obj0, obj1, "\"r\"", &r) &&
593 parse_json_int_field(obj0, obj1, "\"g\"", &g) &&
594 parse_json_int_field(obj0, obj1, "\"b\"", &b)) {
595 int i = boot_title_colors_len++;
596 boot_title_colors[i] = (ACColor){clamp_u8(r), clamp_u8(g), clamp_u8(b), 255};
597 }
598 p = obj1 + 1;
599 }
600}
601
602static void load_boot_visual_config(void) {
603 // Try USB/HD config first, fall back to initramfs-baked default
604 FILE *cfg = fopen("/mnt/config.json", "r");
605 if (!cfg) cfg = fopen("/default-config.json", "r");
606 if (!cfg) return;
607
608 char buf[32768] = {0};
609 size_t n = fread(buf, 1, sizeof(buf) - 1, cfg);
610 fclose(cfg);
611 buf[n] = '\0';
612
613 // Skip identity block marker line if present ("AC_IDENTITY_BLOCK_V1\n")
614 char *json = buf;
615 if (strncmp(buf, "AC_IDENTITY_BLOCK_V1", 20) == 0) {
616 char *nl = strchr(buf, '\n');
617 if (nl) json = nl + 1;
618 }
619
620 char handle[64] = {0};
621 if (parse_config_string(json, "\"handle\"", handle, sizeof(handle))) {
622 setenv("AC_HANDLE", handle, 1);
623 if ((int)strlen(handle) < (int)sizeof(boot_title) - 20) {
624 // Time-of-day greeting based on LA time
625 int hour = get_la_hour();
626 const char *greeting;
627 if (hour >= 5 && hour < 12) greeting = "good morning";
628 else if (hour >= 12 && hour < 17) greeting = "good afternoon";
629 else greeting = "good evening";
630 snprintf(boot_title, sizeof(boot_title), "%s @%s", greeting, handle);
631 }
632 }
633 parse_boot_title_colors(json);
634
635 // Read wifi flag (default: enabled)
636 int wifi_val = 1;
637 if (parse_config_bool(json, "\"wifi\"", &wifi_val)) {
638 wifi_disabled = !wifi_val;
639 ac_log("[config] wifi: %s\n", wifi_disabled ? "disabled" : "enabled");
640 }
641
642 // Bake Claude/GitHub tokens early so boot-fade badge check (access())
643 // succeeds while the fade is animating. The duplicate in main() runs
644 // after the fade and was leaving the badges invisible.
645 {
646 char ct[512] = {0}, gp[256] = {0};
647 if (parse_config_string(json, "\"claudeToken\"", ct, sizeof(ct)) && ct[0]) {
648 FILE *tf = fopen("/claude-token", "w");
649 if (tf) { fputs(ct, tf); fclose(tf); }
650 ac_log("[tokens] claude token from config (%d bytes)\n", (int)strlen(ct));
651 }
652 if (parse_config_string(json, "\"githubPat\"", gp, sizeof(gp)) && gp[0]) {
653 FILE *gf = fopen("/github-pat", "w");
654 if (gf) { fputs(gp, gf); fclose(gf); }
655 ac_log("[tokens] github pat from config (%d bytes)\n", (int)strlen(gp));
656 }
657 }
658
659 // Extract claudeCreds JSON and write to /tmp for PTY to pick up
660 const char *cc = strstr(buf, "\"claudeCreds\"");
661 if (cc) {
662 const char *start = strchr(cc, '{');
663 if (start) {
664 int depth = 0;
665 const char *end = start;
666 while (*end) {
667 if (*end == '{') depth++;
668 else if (*end == '}') { depth--; if (depth == 0) { end++; break; } }
669 end++;
670 }
671 if (depth == 0 && end > start) {
672 mkdir("/tmp/.claude", 0755);
673 FILE *cf = fopen("/tmp/.claude/.credentials.json", "w");
674 if (cf) {
675 fwrite(start, 1, end - start, cf);
676 fclose(cf);
677 ac_log("[ac-native] Claude credentials written (%d bytes)\n", (int)(end - start));
678 }
679 }
680 }
681 }
682
683 // Extract claudeState JSON and write to /tmp/.claude.json
684 const char *cs = strstr(buf, "\"claudeState\"");
685 if (cs) {
686 const char *start = strchr(cs, '{');
687 if (start) {
688 int depth = 0;
689 const char *end = start;
690 while (*end) {
691 if (*end == '{') depth++;
692 else if (*end == '}') { depth--; if (depth == 0) { end++; break; } }
693 end++;
694 }
695 if (depth == 0 && end > start) {
696 FILE *sf = fopen("/tmp/.claude.json", "w");
697 if (sf) {
698 fwrite(start, 1, end - start, sf);
699 fclose(sf);
700 ac_log("[ac-native] Claude state written (%d bytes)\n", (int)(end - start));
701 }
702 }
703 }
704 }
705
706 ac_log("[ac-native] Boot title: %s (colors=%d)\n", boot_title, boot_title_colors_len);
707}
708
709static ACColor rainbow_title_color(int ci, int frame, int alpha) {
710 double hue = fmod((double)ci / 7.0 * 360.0 + frame * 2.0, 360.0);
711 double h6 = hue / 60.0;
712 int hi = (int)h6 % 6;
713 double fr = h6 - (int)h6;
714 double sv = 0.7, vv = 1.0;
715 double p = vv * (1.0 - sv), q = vv * (1.0 - sv * fr), tt = vv * (1.0 - sv * (1.0 - fr));
716 double cr, cg, cb;
717 switch (hi) {
718 case 0: cr = vv; cg = tt; cb = p; break;
719 case 1: cr = q; cg = vv; cb = p; break;
720 case 2: cr = p; cg = vv; cb = tt; break;
721 case 3: cr = p; cg = q; cb = vv; break;
722 case 4: cr = tt; cg = p; cb = vv; break;
723 default: cr = vv; cg = p; cb = q; break;
724 }
725 return (ACColor){(uint8_t)(cr * 255), (uint8_t)(cg * 255), (uint8_t)(cb * 255), clamp_u8(alpha)};
726}
727
728static ACColor title_char_color(int ci, int frame, int alpha) {
729 if (boot_title_colors_len <= 0) return rainbow_title_color(ci, frame, alpha);
730
731 int title_len = (int)strlen(boot_title);
732 int idx = ci;
733 // Map palette to the handle portion (everything after @)
734 const char *at = strchr(boot_title, '@');
735 int handle_start = at ? (int)(at - boot_title) + 1 : 0; // char after @
736 if (handle_start > 0 && ci >= handle_start && boot_title_colors_len > 0) {
737 idx = ci - handle_start;
738 } else if (ci < handle_start) {
739 // greeting prefix and "@" get rainbow colors
740 return rainbow_title_color(ci, frame, alpha);
741 }
742 if (idx < 0) idx = 0;
743 if (boot_title_colors_len > 0) idx %= boot_title_colors_len;
744 ACColor c = boot_title_colors[idx];
745
746 // Keep custom colors visible over dark boot backgrounds.
747 int pulse = (int)(18.0 * sin((double)(frame + ci * 6) * 0.08));
748 int r = (c.r * 7 + 255 * 3) / 10 + pulse;
749 int g = (c.g * 7 + 255 * 3) / 10 + pulse;
750 int b = (c.b * 7 + 255 * 3) / 10 + pulse;
751 return (ACColor){clamp_u8(r), clamp_u8(g), clamp_u8(b), clamp_u8(alpha)};
752}
753
754// Forward declarations
755static void draw_boot_status(ACGraph *graph, ACFramebuffer *screen,
756 ACDisplay *display, const char *status, int pixel_scale);
757
758// Check if a block device is removable (USB = 1, internal = 0)
759static int is_removable(const char *blkname) {
760 char path[128];
761 snprintf(path, sizeof(path), "/sys/block/%s/removable", blkname);
762 FILE *f = fopen(path, "r");
763 if (!f) return -1; // unknown
764 int val = 0;
765 if (fscanf(f, "%d", &val) != 1) val = -1;
766 fclose(f);
767 return val;
768}
769
770// Copy a file from src to dst path, returns bytes copied or -1 on error
771static long copy_file(const char *src, const char *dst) {
772 FILE *in = fopen(src, "rb");
773 if (!in) {
774 ac_log("[copy_file] cannot open src %s: errno=%d\n", src, errno);
775 return -1;
776 }
777 FILE *out = fopen(dst, "wb");
778 if (!out) {
779 ac_log("[copy_file] cannot open dst %s: errno=%d\n", dst, errno);
780 fclose(in);
781 return -1;
782 }
783 char buf[65536];
784 long total = 0;
785 size_t n;
786 while ((n = fread(buf, 1, sizeof(buf), in)) > 0) {
787 size_t written = fwrite(buf, 1, n, out);
788 if (written != n) {
789 ac_log("[copy_file] write failed at offset %ld: wanted %zu got %zu errno=%d\n",
790 total, n, written, errno);
791 fclose(out);
792 fclose(in);
793 return -1;
794 }
795 total += n;
796 }
797 if (ferror(in)) {
798 ac_log("[copy_file] read failed from %s: errno=%d\n", src, errno);
799 fclose(out);
800 fclose(in);
801 return -1;
802 }
803 if (fflush(out) != 0) {
804 ac_log("[copy_file] fflush failed: errno=%d\n", errno);
805 fclose(out);
806 fclose(in);
807 return -1;
808 }
809 if (fsync(fileno(out)) != 0) {
810 ac_log("[copy_file] fsync failed: errno=%d\n", errno);
811 fclose(out);
812 fclose(in);
813 return -1;
814 }
815 fclose(out);
816 fclose(in);
817 return total;
818}
819
820// Install failure reason — set by auto_install_to_hd, displayed on failure screen
821static char install_fail_reason[256] = "";
822static char install_fail_detail[256] = "";
823
824// Unmount every entry in /proc/mounts whose SOURCE device matches
825// /dev/<parent> or any of its partitions. Loops until /proc/mounts no longer
826// shows any matching entry — necessary for mount-stacked /mnt where the
827// topmost fs might not be the one we want to unmount. Each iteration finds
828// ONE matching target, umount2()'s it (MNT_DETACH pops the topmost mount at
829// that path, which may or may not be the one we targeted — but after enough
830// iterations the stack empties of any matching entries). Returns total
831// successful umounts.
832static int force_unmount_disk(const char *parent_blk, const char *dlog_path) {
833 int unmounted = 0;
834 FILE *dl = dlog_path ? fopen(dlog_path, "a") : NULL;
835 if (dl) fprintf(dl, "--- force_unmount_disk(%s) start ---\n", parent_blk);
836
837 // First pass: log the full /proc/mounts so we can see the initial stack
838 {
839 FILE *mp = fopen("/proc/mounts", "r");
840 if (mp && dl) {
841 fprintf(dl, "--- initial /proc/mounts ---\n");
842 char line[512];
843 while (fgets(line, sizeof(line), mp)) fputs(line, dl);
844 fprintf(dl, "--- end /proc/mounts ---\n");
845 fclose(mp);
846 } else if (mp) {
847 fclose(mp);
848 }
849 }
850
851 const int MAX_ITER = 16; // belt-and-suspenders against mount-stack depth
852 for (int iter = 0; iter < MAX_ITER; iter++) {
853 // Find ONE target whose source starts with /dev/<parent>
854 char target[128] = "";
855 char source[128] = "";
856 FILE *mp = fopen("/proc/mounts", "r");
857 if (!mp) break;
858 char line[512];
859 while (fgets(line, sizeof(line), mp)) {
860 char src[128], tgt[128], fst[64];
861 if (sscanf(line, "%127s %127s %63s", src, tgt, fst) != 3) continue;
862 if (strncmp(src, "/dev/", 5) != 0) continue;
863 if (strncmp(src + 5, parent_blk, strlen(parent_blk)) != 0) continue;
864 strncpy(target, tgt, sizeof(target) - 1);
865 target[sizeof(target) - 1] = 0;
866 strncpy(source, src, sizeof(source) - 1);
867 source[sizeof(source) - 1] = 0;
868 break;
869 }
870 fclose(mp);
871 if (!target[0]) {
872 if (dl) fprintf(dl, "iter=%d: no matching mounts remain — done\n", iter);
873 break;
874 }
875 // Umount by path (MNT_DETACH pops the topmost mount, which may not be
876 // the one we scanned — that's ok, we loop and re-scan).
877 int r = umount2(target, MNT_DETACH);
878 if (dl) fprintf(dl, "iter=%d: umount2(%s, MNT_DETACH) src=%s = %d errno=%d\n",
879 iter, target, source, r, r < 0 ? errno : 0);
880 ac_log("[install] iter=%d umount2(%s) src=%s rc=%d errno=%d\n",
881 iter, target, source, r, r < 0 ? errno : 0);
882 if (r == 0) {
883 unmounted++;
884 } else {
885 // Try without MNT_DETACH as a fallback (plain umount).
886 r = umount2(target, 0);
887 if (dl) fprintf(dl, "iter=%d: umount2(%s, 0) fallback = %d errno=%d\n",
888 iter, target, r, r < 0 ? errno : 0);
889 if (r != 0) break;
890 unmounted++;
891 }
892 // Tiny yield so the kernel can finish detaching before we re-scan.
893 usleep(50000);
894 }
895
896 if (dl) {
897 fprintf(dl, "--- force_unmount_disk(%s) total=%d ---\n", parent_blk, unmounted);
898 // Log post-unmount /proc/mounts so we can verify the stack is clean.
899 FILE *mp = fopen("/proc/mounts", "r");
900 if (mp) {
901 fprintf(dl, "--- post /proc/mounts ---\n");
902 char line[512];
903 while (fgets(line, sizeof(line), mp)) fputs(line, dl);
904 fprintf(dl, "--- end /proc/mounts ---\n");
905 fclose(mp);
906 }
907 fclose(dl);
908 }
909 return unmounted;
910}
911
912// Try BLKRRPART on a disk in a retry loop. Lazy unmounts take time to release
913// the device fully — the kernel keeps the block device referenced until all
914// superblock cleanups finish. We retry with backoff: 0 → 250ms → 500ms → 1s →
915// 2s. Returns 0 on success, -1 on failure (errno set by last attempt).
916// Also logs each attempt to dlog_path if provided.
917static int blkrrpart_with_retry(const char *disk_path, const char *dlog_path) {
918 int delays_ms[] = {0, 250, 500, 1000, 2000};
919 FILE *dl = dlog_path ? fopen(dlog_path, "a") : NULL;
920 int last_errno = 0;
921 for (int i = 0; i < (int)(sizeof(delays_ms) / sizeof(delays_ms[0])); i++) {
922 if (delays_ms[i] > 0) {
923 sync();
924 usleep(delays_ms[i] * 1000);
925 }
926 int dfd = open(disk_path, O_RDONLY | O_CLOEXEC);
927 if (dfd < 0) {
928 last_errno = errno;
929 if (dl) fprintf(dl, "BLKRRPART try %d: open failed errno=%d\n", i, last_errno);
930 continue;
931 }
932 int ri = ioctl(dfd, BLKRRPART);
933 last_errno = ri < 0 ? errno : 0;
934 close(dfd);
935 if (dl) fprintf(dl, "BLKRRPART try %d (delay=%dms) rc=%d errno=%d (%s)\n",
936 i, delays_ms[i], ri, last_errno,
937 ri < 0 ? strerror(last_errno) : "ok");
938 ac_log("[install] BLKRRPART %s try=%d delay=%dms rc=%d errno=%d\n",
939 disk_path, i, delays_ms[i], ri, last_errno);
940 if (ri == 0) {
941 if (dl) fclose(dl);
942 return 0;
943 }
944 }
945 // Last resort: sysfs rescan (NVMe has /sys/class/nvme/nvme*/rescan_controller
946 // and every block device has /sys/block/<dev>/uevent which triggers a
947 // udev change event that forces partition rescan).
948 char sys_uevent[128];
949 snprintf(sys_uevent, sizeof(sys_uevent), "/sys/block/%s/uevent",
950 disk_path + 5); // skip "/dev/"
951 FILE *ue = fopen(sys_uevent, "w");
952 if (ue) {
953 fprintf(ue, "change\n");
954 fclose(ue);
955 if (dl) fprintf(dl, "wrote 'change' to %s\n", sys_uevent);
956 ac_log("[install] triggered sysfs change on %s\n", sys_uevent);
957 // Give udev time to process
958 usleep(500000);
959 } else if (dl) {
960 fprintf(dl, "sysfs fallback open(%s) failed errno=%d\n", sys_uevent, errno);
961 }
962 if (dl) fclose(dl);
963 errno = last_errno;
964 return -1;
965}
966
967// Score removable install sources by how much of the current boot payload they
968// contain. Higher scores are preferred during W-to-install so we choose the
969// universal ACEFI partition over the simpler ACBOOT fallback when both exist.
970// 3 = universal layout (BOOTX64 + LOADER + KERNEL + initramfs + loader entry)
971// 2 = chainloader layout (BOOTX64 + KERNEL)
972// 1 = monolithic layout (BOOTX64 only)
973// 0 = not a usable install source
974static int install_source_layout_score(const char *mountpoint) {
975 char bootx64[128];
976 char loader[128];
977 char kernel[128];
978 char initramfs_gz[128];
979 char initramfs_lz4[128];
980 char loader_entry[160];
981
982 snprintf(bootx64, sizeof(bootx64), "%s/EFI/BOOT/BOOTX64.EFI", mountpoint);
983 snprintf(loader, sizeof(loader), "%s/EFI/BOOT/LOADER.EFI", mountpoint);
984 snprintf(kernel, sizeof(kernel), "%s/EFI/BOOT/KERNEL.EFI", mountpoint);
985 snprintf(initramfs_gz, sizeof(initramfs_gz), "%s/initramfs.cpio.gz", mountpoint);
986 snprintf(initramfs_lz4, sizeof(initramfs_lz4), "%s/initramfs.cpio.lz4", mountpoint);
987 snprintf(loader_entry, sizeof(loader_entry), "%s/loader/entries/ac-native.conf", mountpoint);
988
989 if (access(bootx64, F_OK) == 0 &&
990 access(loader, F_OK) == 0 &&
991 access(kernel, F_OK) == 0 &&
992 (access(initramfs_gz, F_OK) == 0 || access(initramfs_lz4, F_OK) == 0) &&
993 access(loader_entry, F_OK) == 0) {
994 return 3;
995 }
996 if (access(bootx64, F_OK) == 0 && access(kernel, F_OK) == 0) return 2;
997 if (access(bootx64, F_OK) == 0) return 1;
998 return 0;
999}
1000
1001// Auto-install kernel to internal drive's EFI System Partition
1002// Returns 1 on success, 0 on failure (sets install_fail_reason/detail).
1003static int auto_install_to_hd(ACGraph *graph, ACFramebuffer *screen,
1004 ACDisplay *display, int pixel_scale) {
1005 char source_mount[32] = "/mnt";
1006 char source_dev[32] = "";
1007 int source_mounted_tmp = 0;
1008 char kernel_src[96] = "";
1009 install_fail_reason[0] = '\0';
1010 install_fail_detail[0] = '\0';
1011 char bootloader_src[96] = "";
1012 char loader_src[96] = "";
1013 char chain_kernel_src[96] = "";
1014 char initramfs_src[96] = "";
1015 char install_kernel_src[96] = "";
1016 char config_src[64] = "";
1017 char loader_conf_src[96] = "";
1018 char loader_entry_src[128] = "";
1019
1020 ac_log("[install] auto_install_to_hd starting\n");
1021 if (display)
1022 draw_boot_status(graph, screen, display, "installing to disk...", pixel_scale);
1023
1024 // Detect source layout: monolithic (BOOTX64.EFI is kernel), chainloader
1025 // (BOOTX64.EFI boots KERNEL.EFI), or universal systemd-boot
1026 // (BOOTX64.EFI + LOADER.EFI + KERNEL.EFI + initramfs + loader entry).
1027 int systemd_boot_layout = 0;
1028 int chainloader_layout = 0;
1029 int source_score = 0;
1030
1031 // Prefer current /mnt only when it is removable and actually bootable.
1032 if (log_dev[0]) {
1033 char blk[32] = "";
1034 get_parent_block(log_dev + 5, blk, sizeof(blk));
1035 if (blk[0] && is_removable(blk) == 1) {
1036 source_score = install_source_layout_score("/mnt");
1037 if (source_score > 0) {
1038 ac_log("[install] current /mnt source score=%d (%s)\n", source_score, log_dev);
1039 strncpy(source_dev, log_dev, sizeof(source_dev) - 1);
1040 source_dev[sizeof(source_dev) - 1] = '\0';
1041 }
1042 }
1043 }
1044
1045 // Scan removable partitions and prefer the richest boot layout. This lets
1046 // W-install source from ACEFI on the new hybrid USB instead of blindly
1047 // using whichever removable partition happened to get mounted at /mnt.
1048 {
1049 const char *src_candidates[] = {
1050 "/dev/sda1", "/dev/sda2",
1051 "/dev/sdb1", "/dev/sdb2",
1052 "/dev/sdc1", "/dev/sdc2",
1053 "/dev/sdd1", "/dev/sdd2",
1054 NULL
1055 };
1056 char best_dev[32] = "";
1057 int best_score = source_score;
1058 mkdir("/tmp/src", 0755);
1059 for (int i = 0; src_candidates[i]; i++) {
1060 if (access(src_candidates[i], F_OK) != 0) continue;
1061 if (source_dev[0] && strcmp(src_candidates[i], source_dev) == 0) continue;
1062 char blk[32] = "";
1063 get_parent_block(src_candidates[i] + 5, blk, sizeof(blk));
1064 if (!blk[0] || is_removable(blk) != 1) continue;
1065 if (mount(src_candidates[i], "/tmp/src", "vfat", 0, NULL) != 0) continue;
1066 int score = install_source_layout_score("/tmp/src");
1067 umount("/tmp/src");
1068 if (score > best_score) {
1069 best_score = score;
1070 strncpy(best_dev, src_candidates[i], sizeof(best_dev) - 1);
1071 best_dev[sizeof(best_dev) - 1] = '\0';
1072 }
1073 }
1074 if (best_dev[0]) {
1075 if (mount(best_dev, "/tmp/src", "vfat", 0, NULL) == 0) {
1076 strncpy(source_dev, best_dev, sizeof(source_dev) - 1);
1077 source_dev[sizeof(source_dev) - 1] = '\0';
1078 strncpy(source_mount, "/tmp/src", sizeof(source_mount) - 1);
1079 source_mount[sizeof(source_mount) - 1] = '\0';
1080 source_mounted_tmp = 1;
1081 source_score = best_score;
1082 ac_log("[install] selected richer removable source %s score=%d\n",
1083 source_dev, source_score);
1084 } else {
1085 ac_log("[install] failed to mount preferred source %s errno=%d\n",
1086 best_dev, errno);
1087 }
1088 }
1089 }
1090
1091 systemd_boot_layout = (source_score >= 3);
1092 chainloader_layout = (!systemd_boot_layout && source_score >= 2);
1093
1094 snprintf(bootloader_src, sizeof(bootloader_src), "%s/EFI/BOOT/BOOTX64.EFI", source_mount);
1095 snprintf(loader_src, sizeof(loader_src), "%s/EFI/BOOT/LOADER.EFI", source_mount);
1096 snprintf(chain_kernel_src, sizeof(chain_kernel_src), "%s/EFI/BOOT/KERNEL.EFI", source_mount);
1097 snprintf(kernel_src, sizeof(kernel_src), "%s/EFI/BOOT/BOOTX64.EFI", source_mount);
1098 snprintf(config_src, sizeof(config_src), "%s/config.json", source_mount);
1099 snprintf(loader_conf_src, sizeof(loader_conf_src), "%s/loader/loader.conf", source_mount);
1100 snprintf(loader_entry_src, sizeof(loader_entry_src), "%s/loader/entries/ac-native.conf", source_mount);
1101 if (access(source_dev, F_OK) == 0 && systemd_boot_layout) {
1102 char initramfs_gz[96];
1103 char initramfs_lz4[96];
1104 snprintf(initramfs_gz, sizeof(initramfs_gz), "%s/initramfs.cpio.gz", source_mount);
1105 snprintf(initramfs_lz4, sizeof(initramfs_lz4), "%s/initramfs.cpio.lz4", source_mount);
1106 if (access(initramfs_gz, F_OK) == 0) {
1107 strncpy(initramfs_src, initramfs_gz, sizeof(initramfs_src) - 1);
1108 initramfs_src[sizeof(initramfs_src) - 1] = '\0';
1109 } else if (access(initramfs_lz4, F_OK) == 0) {
1110 strncpy(initramfs_src, initramfs_lz4, sizeof(initramfs_src) - 1);
1111 initramfs_src[sizeof(initramfs_src) - 1] = '\0';
1112 }
1113 }
1114
1115 if (systemd_boot_layout) {
1116 strncpy(install_kernel_src, chain_kernel_src, sizeof(install_kernel_src) - 1);
1117 install_kernel_src[sizeof(install_kernel_src) - 1] = '\0';
1118 ac_log("[install] detected universal systemd-boot layout\n");
1119 } else if (chainloader_layout) {
1120 strncpy(install_kernel_src, chain_kernel_src, sizeof(install_kernel_src) - 1);
1121 install_kernel_src[sizeof(install_kernel_src) - 1] = '\0';
1122 ac_log("[install] detected chainloader layout\n");
1123 } else {
1124 strncpy(install_kernel_src, kernel_src, sizeof(install_kernel_src) - 1);
1125 install_kernel_src[sizeof(install_kernel_src) - 1] = '\0';
1126 if (source_score == 1) ac_log("[install] detected monolithic layout\n");
1127 }
1128
1129 if (!source_dev[0] || source_score == 0 ||
1130 access(install_kernel_src, F_OK) != 0 ||
1131 (systemd_boot_layout &&
1132 (access(loader_src, F_OK) != 0 ||
1133 initramfs_src[0] == '\0' ||
1134 access(loader_entry_src, F_OK) != 0))) {
1135 ac_log("[install] No removable install source with kernel found\n");
1136 snprintf(install_fail_reason, sizeof(install_fail_reason),
1137 "no USB boot source found");
1138 // Log available block devices for diagnostics
1139 ac_log("[install] Block devices:\n");
1140 int dpos = 0;
1141 const char *scan[] = {"sda","sdb","sdc","sdd","nvme0n1","nvme1n1","mmcblk0",NULL};
1142 for (int i = 0; scan[i]; i++) {
1143 char dp[32]; snprintf(dp, sizeof(dp), "/sys/block/%s", scan[i]);
1144 if (access(dp, F_OK) == 0) {
1145 int rem = is_removable(scan[i]);
1146 ac_log("[install] /dev/%s removable=%d\n", scan[i], rem);
1147 dpos += snprintf(install_fail_detail + dpos,
1148 sizeof(install_fail_detail) - dpos,
1149 "/dev/%s %s ", scan[i], rem == 1 ? "(USB)" : rem == 0 ? "(int)" : "(?)");
1150 }
1151 }
1152 if (source_mounted_tmp) umount("/tmp/src");
1153 return 0;
1154 }
1155
1156 mkdir("/tmp/hd", 0755);
1157
1158 // Determine which block device install source is on (skip it as destination)
1159 char usb_blk[16] = "";
1160 if (source_dev[0]) {
1161 const char *p = source_dev + 5; // skip "/dev/"
1162 int len = 0;
1163 while (p[len] && (p[len] < '0' || p[len] > '9')) len++;
1164 // For nvme: "nvme0n1p1" → parent "nvme0n1"
1165 // For sd: "sda1" → parent "sda"
1166 if (len > 0) {
1167 if (len > (int)sizeof(usb_blk) - 1) len = sizeof(usb_blk) - 1;
1168 memcpy(usb_blk, p, len);
1169 usb_blk[len] = '\0';
1170 }
1171 }
1172
1173 // Scan for internal (non-removable) block devices with partitions.
1174 // NVMe first (always internal), then eMMC (always internal — common
1175 // on Chromebooks + budget laptops), then SATA/USB last.
1176 const char *part_candidates[] = {
1177 "nvme0n1", "nvme1n1", // NVMe SSDs
1178 "mmcblk0", "mmcblk1", // eMMC (Chromebooks, budget laptops)
1179 "sda", "sdb", "sdc", "sdd", // SATA/USB
1180 NULL
1181 };
1182
1183 int installed = 0;
1184 for (int i = 0; part_candidates[i] && !installed; i++) {
1185 const char *blk = part_candidates[i];
1186
1187 // Skip the USB boot device
1188 if (usb_blk[0] && strcmp(blk, usb_blk) == 0) continue;
1189
1190 // For sd* devices, skip if removable
1191 if (blk[0] == 's' && blk[1] == 'd') {
1192 int rem = is_removable(blk);
1193 if (rem == 1) continue; // removable = USB
1194 }
1195
1196 // Two-pass partition scan. Pass 0: probe p1..p16 for an existing
1197 // vfat partition (non-destructive) — finds the Chromebook ESP at
1198 // p12 before we would otherwise reformat p1 (stateful/ext4) on
1199 // Chromebooks and clobber user data. Pass 1: fall back to the
1200 // p=1 rescue reformat for stock Linux layouts where no ESP exists.
1201 for (int pass = 0; pass < 2 && !installed; pass++) {
1202 int allow_rescue_mkfs = (pass == 1);
1203 for (int p = 1; p <= 16 && !installed; p++) {
1204 char devpath[32];
1205 // NVMe + eMMC use "p<N>" suffix (name ends in a digit); SATA
1206 // just appends the number to the base name.
1207 if (blk[0] == 'n' || strncmp(blk, "mmcblk", 6) == 0)
1208 snprintf(devpath, sizeof(devpath), "/dev/%sp%d", blk, p);
1209 else // SATA: sda1
1210 snprintf(devpath, sizeof(devpath), "/dev/%s%d", blk, p);
1211
1212 if (access(devpath, F_OK) != 0) continue;
1213
1214 // Try mounting as FAT (ESP is always FAT32)
1215 ac_log("[install] trying %s\n", devpath);
1216 if (mount(devpath, "/tmp/hd", "vfat", 0, NULL) != 0) {
1217 int mount_errno = errno;
1218 ac_log("[install] mount failed: %s (errno=%d)\n", devpath, mount_errno);
1219 // If the partition exists but has no filesystem (e.g. a prior
1220 // sfdisk created it but mkfs never succeeded), try to format
1221 // it now. Only attempt this on the first partition (which is
1222 // the ESP slot) and only if the partition is large enough.
1223 // Gated to pass==1 so we never clobber Chromebook p1 STATE
1224 // when an existing vfat ESP is available elsewhere (e.g. p12).
1225 if (p == 1 && allow_rescue_mkfs) {
1226 long long part_bytes = 0;
1227 int pfd = open(devpath, O_RDONLY | O_CLOEXEC);
1228 if (pfd >= 0) {
1229 unsigned long long sz = 0;
1230 if (ioctl(pfd, BLKGETSIZE64, &sz) == 0) part_bytes = (long long)sz;
1231 close(pfd);
1232 }
1233 long part_mb = (long)(part_bytes / 1048576LL);
1234 ac_log("[install] %s size=%ldMB — try format to rescue unmountable partition\n",
1235 devpath, part_mb);
1236 if (part_mb >= 512) {
1237 // Flush buffer cache, then mkfs
1238 int fpfd = open(devpath, O_RDONLY | O_CLOEXEC);
1239 if (fpfd >= 0) { ioctl(fpfd, BLKFLSBUF); close(fpfd); }
1240 system("echo 3 > /proc/sys/vm/drop_caches 2>/dev/null || true");
1241 sync();
1242 usleep(500000);
1243 char mkfs_cmd[256];
1244 snprintf(mkfs_cmd, sizeof(mkfs_cmd),
1245 "mkfs.vfat -F 32 -n AC-NATIVE %s 2>&1", devpath);
1246 int mkrc = system(mkfs_cmd);
1247 ac_log("[install] rescue mkfs rc=%d\n", mkrc);
1248 if (WIFEXITED(mkrc) && WEXITSTATUS(mkrc) == 0) {
1249 usleep(500000);
1250 if (mount(devpath, "/tmp/hd", "vfat", 0, NULL) == 0) {
1251 ac_log("[install] rescue format + mount OK\n");
1252 // Fall through to normal install flow below
1253 } else {
1254 ac_log("[install] rescue mount still failed\n");
1255 continue;
1256 }
1257 } else {
1258 continue;
1259 }
1260 } else {
1261 continue;
1262 }
1263 } else {
1264 continue;
1265 }
1266 }
1267
1268 // Create EFI boot directories
1269 mkdir("/tmp/hd/EFI", 0755);
1270 mkdir("/tmp/hd/EFI/BOOT", 0755);
1271
1272 // Check free space against the full payload we plan to copy.
1273 {
1274 struct stat src_st;
1275 struct statvfs hd_vfs;
1276 long long install_bytes = 0;
1277 long long free_bytes = 0;
1278 if (stat(install_kernel_src, &src_st) == 0) install_bytes += src_st.st_size;
1279 if (chainloader_layout && stat(bootloader_src, &src_st) == 0) install_bytes += src_st.st_size;
1280 if (systemd_boot_layout) {
1281 if (stat(bootloader_src, &src_st) == 0) install_bytes += src_st.st_size;
1282 if (stat(loader_src, &src_st) == 0) install_bytes += src_st.st_size;
1283 if (stat(initramfs_src, &src_st) == 0) install_bytes += src_st.st_size;
1284 if (stat(loader_conf_src, &src_st) == 0) install_bytes += src_st.st_size;
1285 if (stat(loader_entry_src, &src_st) == 0) install_bytes += src_st.st_size;
1286 }
1287 if (stat(config_src, &src_st) == 0) install_bytes += src_st.st_size;
1288 if (statvfs("/tmp/hd", &hd_vfs) == 0)
1289 free_bytes = (long long)hd_vfs.f_bavail * (long long)hd_vfs.f_bsize;
1290 long need_mb = (long)((install_bytes + (1048576 - 1)) / 1048576) + 10;
1291 long free_mb = (long)(free_bytes / 1048576);
1292 ac_log("[install] payload=%ldMB free=%ldMB on %s\n",
1293 (long)((install_bytes + (1048576 - 1)) / 1048576), free_mb, devpath);
1294 if (install_bytes > 0 && free_bytes < install_bytes + 10LL * 1048576LL) {
1295 ac_log("[install] NOT ENOUGH SPACE — need %ldMB, have %ldMB\n", need_mb, free_mb);
1296 // Check the PARTITION SIZE (not free space). If a previous
1297 // sfdisk already expanded it to 1024MB but mkfs failed, the
1298 // partition is big enough — we just need to unmount + reformat,
1299 // no repartitioning needed (which avoids the EBUSY nightmare).
1300 long long part_size_bytes = 0;
1301 {
1302 int pfd = open(devpath, O_RDONLY | O_CLOEXEC);
1303 if (pfd >= 0) {
1304 unsigned long long sz = 0;
1305 if (ioctl(pfd, BLKGETSIZE64, &sz) == 0)
1306 part_size_bytes = (long long)sz;
1307 close(pfd);
1308 }
1309 }
1310 long part_mb = (long)(part_size_bytes / 1048576LL);
1311 ac_log("[install] partition %s size=%ldMB (need %ldMB)\n", devpath, part_mb, need_mb);
1312
1313 if (part_mb >= need_mb) {
1314 // Partition already large enough — just unmount + reformat
1315 ac_log("[install] partition big enough, skip repartition → direct reformat\n");
1316 umount("/tmp/hd");
1317 umount2("/tmp/hd", MNT_DETACH);
1318 // Unmount all mounts on this disk
1319 char parent_blk_tmp[32] = "";
1320 {
1321 const char *d = devpath + 5;
1322 strncpy(parent_blk_tmp, d, sizeof(parent_blk_tmp) - 1);
1323 char *pp = strstr(parent_blk_tmp, "p");
1324 if (pp && pp > parent_blk_tmp && *(pp-1) >= '0' && *(pp-1) <= '9' && *(pp+1) >= '1' && *(pp+1) <= '9')
1325 *pp = 0;
1326 else {
1327 int len = strlen(parent_blk_tmp);
1328 while (len > 0 && parent_blk_tmp[len-1] >= '0' && parent_blk_tmp[len-1] <= '9') len--;
1329 parent_blk_tmp[len] = 0;
1330 }
1331 }
1332 const char *DLOG = "/tmp/install-debug.log";
1333 FILE *__dl = fopen(DLOG, "w");
1334 if (__dl) { fprintf(__dl, "=== direct-reformat (no repartition) ===\n"); fclose(__dl); }
1335 force_unmount_disk(parent_blk_tmp, DLOG);
1336 sync();
1337 // Flush block device cache
1338 {
1339 int pfd = open(devpath, O_RDONLY | O_CLOEXEC);
1340 if (pfd >= 0) { ioctl(pfd, BLKFLSBUF); close(pfd); }
1341 char dd[64];
1342 snprintf(dd, sizeof(dd), "/dev/%s", parent_blk_tmp);
1343 int dfd = open(dd, O_RDONLY | O_CLOEXEC);
1344 if (dfd >= 0) { ioctl(dfd, BLKFLSBUF); close(dfd); }
1345 }
1346 system("echo 3 > /proc/sys/vm/drop_caches 2>/dev/null || true");
1347 sync();
1348 usleep(2000000);
1349 // Format directly
1350 char rcmd[512];
1351 const char *MKFS_ERR = "/tmp/mkfs-err.log";
1352 int mkfs_exit = -1;
1353 for (int mkfs_try = 1; mkfs_try <= 5 && mkfs_exit != 0; mkfs_try++) {
1354 if (mkfs_try > 1) {
1355 int pfd = open(devpath, O_RDONLY | O_CLOEXEC);
1356 if (pfd >= 0) { ioctl(pfd, BLKFLSBUF); close(pfd); }
1357 system("echo 3 > /proc/sys/vm/drop_caches 2>/dev/null || true");
1358 sync();
1359 usleep(3000000);
1360 }
1361 snprintf(rcmd, sizeof(rcmd),
1362 "echo '--- mkfs attempt %d ---' >> %s; "
1363 "(mkfs.vfat -F 32 -n AC-NATIVE %s > %s 2>&1; rc=$?; "
1364 "cat %s >> %s; exit $rc)",
1365 mkfs_try, DLOG, devpath, MKFS_ERR, MKFS_ERR, DLOG);
1366 int rrc = system(rcmd);
1367 mkfs_exit = WIFEXITED(rrc) ? WEXITSTATUS(rrc) : -1;
1368 ac_log("[install] direct mkfs rc=%d attempt=%d\n", rrc, mkfs_try);
1369 FILE *mf = fopen(MKFS_ERR, "r");
1370 if (mf) {
1371 char line[512];
1372 while (fgets(line, sizeof(line), mf)) {
1373 size_t len = strlen(line);
1374 if (len > 0 && line[len-1] == '\n') line[len-1] = '\0';
1375 ac_log("[mkfs/%d] %s\n", mkfs_try, line);
1376 }
1377 fclose(mf);
1378 }
1379 }
1380 if (mkfs_exit == 0) {
1381 ac_log("[install] direct reformat succeeded\n");
1382 goto install_copy_phase;
1383 }
1384 ac_log("[install] direct reformat failed, falling through to repartition\n");
1385 }
1386
1387 // Repartition: expand ESP to 1024MB
1388 char parent_blk[32] = "";
1389 // Extract parent device: /dev/nvme0n1p1 → nvme0n1, /dev/sda1 → sda
1390 {
1391 const char *d = devpath + 5; // skip "/dev/"
1392 strncpy(parent_blk, d, sizeof(parent_blk) - 1);
1393 // Remove partition suffix: "nvme0n1p1" → "nvme0n1", "sda1" → "sda"
1394 char *pp = strstr(parent_blk, "p");
1395 if (pp && pp > parent_blk && *(pp-1) >= '0' && *(pp-1) <= '9' && *(pp+1) >= '1' && *(pp+1) <= '9')
1396 *pp = 0; // NVMe: nvme0n1p1 → nvme0n1
1397 else {
1398 // SATA: sda1 → sda (strip trailing digits)
1399 int len = strlen(parent_blk);
1400 while (len > 0 && parent_blk[len-1] >= '0' && parent_blk[len-1] <= '9') len--;
1401 parent_blk[len] = 0;
1402 }
1403 }
1404 ac_log("[install] repartitioning /dev/%s → 1024MB ESP\n", parent_blk);
1405 if (display) {
1406 char msg[80];
1407 snprintf(msg, sizeof(msg), "expanding to 1024MB...");
1408 draw_boot_status(graph, screen, display, msg, pixel_scale);
1409 }
1410 // Release every handle on the target disk BEFORE sfdisk.
1411 // The previous version relied on a shell `umount -l` loop
1412 // that (a) couldn't umount partitions by device path under
1413 // busybox, and (b) left enough lazy-mount residue that
1414 // BLKRRPART returned EBUSY every time. The new helpers
1415 // parse /proc/mounts in C and umount2 each target
1416 // explicitly, then retry BLKRRPART with backoff.
1417 umount("/tmp/hd");
1418 umount2("/tmp/hd", MNT_DETACH);
1419 // Write install-debug.log to TMPFS so it survives even
1420 // if the unmount loop pops the whole /mnt stack. We copy
1421 // it back to /mnt/install-debug.log at the end so the
1422 // USB has a post-mortem trace.
1423 const char *DLOG = "/tmp/install-debug.log";
1424 // Fresh log per attempt
1425 FILE *__dl = fopen(DLOG, "w");
1426 if (__dl) {
1427 fprintf(__dl, "=== install-debug starting ===\n");
1428 fprintf(__dl, "parent_blk=%s devpath=%s\n", parent_blk, devpath);
1429 fclose(__dl);
1430 }
1431 // Unmount every mount currently referencing this disk.
1432 int n_unmounted = force_unmount_disk(parent_blk, DLOG);
1433 ac_log("[install] force_unmount_disk(%s) released %d mounts\n",
1434 parent_blk, n_unmounted);
1435 sync();
1436
1437 // CRITICAL: After MNT_DETACH (lazy unmount), the kernel
1438 // VFS still holds block device references from cached
1439 // dentries/inodes/superblock. We MUST flush buffer cache
1440 // on both the partition and whole disk via BLKFLSBUF,
1441 // then drop page caches, before the block layer will
1442 // release its exclusive hold. Without this, BLKRRPART
1443 // and mkfs both fail with EBUSY.
1444 {
1445 // Flush buffer cache on partition
1446 int pfd = open(devpath, O_RDONLY | O_CLOEXEC);
1447 if (pfd >= 0) {
1448 ioctl(pfd, BLKFLSBUF);
1449 close(pfd);
1450 ac_log("[install] BLKFLSBUF on %s: ok\n", devpath);
1451 }
1452 // Flush buffer cache on whole disk
1453 char disk_dev[64];
1454 snprintf(disk_dev, sizeof(disk_dev), "/dev/%s", parent_blk);
1455 int dfd = open(disk_dev, O_RDONLY | O_CLOEXEC);
1456 if (dfd >= 0) {
1457 ioctl(dfd, BLKFLSBUF);
1458 close(dfd);
1459 ac_log("[install] BLKFLSBUF on %s: ok\n", disk_dev);
1460 }
1461 // Drop all page/dentry/inode caches
1462 system("echo 3 > /proc/sys/vm/drop_caches 2>/dev/null || true");
1463 sync();
1464 ac_log("[install] caches flushed + dropped\n");
1465 }
1466 usleep(1000000); // 1s settle after cache flush
1467
1468 // Nuke the old filesystem signatures BEFORE sfdisk. This
1469 // is critical: the old Fedora ESP's FAT boot sector and
1470 // GPT partition UUID are still on disk, and mkfs.vfat
1471 // later hits EBUSY trying to get O_EXCL because the
1472 // kernel's block device cache still maps to the old FS.
1473 // dd'ing zeros over the first 64KB removes the old FAT
1474 // signature + GPT primary header. The backup GPT at the
1475 // end of the disk also needs clearing but sfdisk --force
1476 // will overwrite it. busybox dd is in initramfs.
1477 char rcmd[512];
1478 snprintf(rcmd, sizeof(rcmd),
1479 "echo '--- wiping old FS signatures ---' >> %s; "
1480 "dd if=/dev/zero of=/dev/%s bs=512 count=128 conv=fsync 2>&1 | tee -a %s; "
1481 "sync",
1482 DLOG, parent_blk, DLOG);
1483 system(rcmd);
1484 usleep(500000);
1485
1486 // Repartition: create 1024MB EFI System Partition.
1487 // CRITICAL: --no-reread tells sfdisk NOT to call BLKRRPART
1488 // itself. The kernel was failing BLKRRPART with EBUSY
1489 // because the block device cache still holds references
1490 // from the old filesystem, and that blocked sfdisk too.
1491 // With --no-reread, sfdisk writes the partition table
1492 // and returns cleanly; we refresh partitions explicitly
1493 // via partx -u below, which is non-destructive and
1494 // works even when the device is "in use" at the kernel
1495 // level.
1496 snprintf(rcmd, sizeof(rcmd),
1497 "{ echo 'label: gpt'; echo 'type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B, size=1024M'; } | sfdisk --force --no-reread /dev/%s >> %s 2>&1",
1498 parent_blk, DLOG);
1499 int rrc = system(rcmd);
1500 ac_log("[install] sfdisk --no-reread rc=%d\n", rrc);
1501 // Give the kernel time to process the new GPT
1502 sync();
1503 usleep(500000);
1504
1505 // Refresh the kernel's partition table. Primary method:
1506 // BLKRRPART ioctl (no external binary needed). Falls back
1507 // to partx/partprobe if available for belt-and-suspenders.
1508 {
1509 char disk_dev[64];
1510 snprintf(disk_dev, sizeof(disk_dev), "/dev/%s", parent_blk);
1511 ac_log("[install] BLKRRPART on %s...\n", disk_dev);
1512 int brr = blkrrpart_with_retry(disk_dev, DLOG);
1513 ac_log("[install] BLKRRPART result=%d\n", brr);
1514 }
1515 // Also try partx/partprobe as secondary refresh (may not
1516 // be in initramfs — that's OK, || true swallows the error).
1517 snprintf(rcmd, sizeof(rcmd),
1518 "echo '--- partx refresh ---' >> %s; "
1519 "partx -u /dev/%s >> %s 2>&1 || true; "
1520 "partprobe /dev/%s >> %s 2>&1 || true; "
1521 "sfdisk --verify /dev/%s >> %s 2>&1 || true; "
1522 "ls -l /dev/%s* >> %s 2>&1 || true",
1523 DLOG, parent_blk, DLOG, parent_blk, DLOG,
1524 parent_blk, DLOG, parent_blk, DLOG);
1525 system(rcmd);
1526 // sfdisk wrote a single partition → the new ESP is p1 on
1527 // the parent disk, regardless of what partition index we
1528 // entered this branch with. Chromebook case: we arrived
1529 // via devpath=/dev/mmcblk1p12 (existing 16MB EFI-SYSTEM),
1530 // sfdisk rewrote the table to a single 1024MB entry at
1531 // p1 and wiped p12 entirely — so mkfs + mount need to
1532 // target the new partition, not the old devpath.
1533 char new_devpath[48];
1534 if (parent_blk[0] == 'n' || strncmp(parent_blk, "mmcblk", 6) == 0) {
1535 snprintf(new_devpath, sizeof(new_devpath), "/dev/%sp1", parent_blk);
1536 } else {
1537 snprintf(new_devpath, sizeof(new_devpath), "/dev/%s1", parent_blk);
1538 }
1539 if (strcmp(new_devpath, devpath) != 0) {
1540 ac_log("[install] canonicalizing devpath %s → %s after sfdisk\n",
1541 devpath, new_devpath);
1542 strncpy(devpath, new_devpath, sizeof(devpath) - 1);
1543 devpath[sizeof(devpath) - 1] = '\0';
1544 }
1545 // Wait for partition device node to appear (up to 10s)
1546 int devpath_ready = 0;
1547 for (int wait = 0; wait < 20; wait++) {
1548 usleep(500000);
1549 struct stat st;
1550 if (stat(devpath, &st) == 0 && S_ISBLK(st.st_mode)) {
1551 ac_log("[install] device %s ready after %dms\n", devpath, (wait+1)*500);
1552 devpath_ready = 1;
1553 break;
1554 }
1555 if (wait % 4 == 0) ac_log("[install] waiting for %s... (%d)\n", devpath, wait);
1556 }
1557 if (!devpath_ready) {
1558 ac_log("[install] device %s never appeared after sfdisk — see %s\n", devpath, DLOG);
1559 }
1560 // Wipe any remaining FS signature. Try wipefs first; if
1561 // it's missing from initramfs, fall back to dd'ing zeros
1562 // over the first 4KB of the partition (covers FAT BPB,
1563 // ext superblock, and any other FS magic).
1564 snprintf(rcmd, sizeof(rcmd),
1565 "echo '--- wipefs partition ---' >> %s; "
1566 "if command -v wipefs >/dev/null 2>&1; then "
1567 " wipefs -a %s >> %s 2>&1; "
1568 "else "
1569 " echo 'wipefs not found, using dd fallback' >> %s; "
1570 " dd if=/dev/zero of=%s bs=512 count=8 conv=fsync >> %s 2>&1; "
1571 "fi",
1572 DLOG, devpath, DLOG, DLOG, devpath, DLOG);
1573 system(rcmd);
1574 // Aggressively flush block device buffer cache + page cache
1575 // to release the kernel's exclusive hold on the partition.
1576 {
1577 int pfd = open(devpath, O_RDONLY | O_CLOEXEC);
1578 if (pfd >= 0) { ioctl(pfd, BLKFLSBUF); close(pfd); }
1579 char disk_dev2[64];
1580 snprintf(disk_dev2, sizeof(disk_dev2), "/dev/%s", parent_blk);
1581 int dfd = open(disk_dev2, O_RDONLY | O_CLOEXEC);
1582 if (dfd >= 0) { ioctl(dfd, BLKFLSBUF); close(dfd); }
1583 }
1584 system("echo 3 > /proc/sys/vm/drop_caches 2>/dev/null || true");
1585 sync();
1586 ac_log("[install] pre-mkfs cache flush done\n");
1587 // Long settle — let the kernel + nvme driver fully release
1588 // the device. 5 seconds to be safe.
1589 usleep(5000000);
1590
1591 // Reformat. Retry up to 3 times if mkfs fails — the
1592 // kernel sometimes needs multiple passes after a fresh
1593 // GPT to release exclusive holds on the block device.
1594 //
1595 // CRITICAL: run mkfs in a subshell that PRESERVES its
1596 // exit code. The previous implementation used
1597 // mkfs ... > err 2>&1; cat err >> dlog
1598 // which made system() always return cat's exit code (0),
1599 // silently masking every mkfs failure and causing the
1600 // install to proceed onto an unformatted partition.
1601 //
1602 // The subshell pattern below captures mkfs's rc, tees
1603 // the output, and `exit $rc` propagates it back through
1604 // system().
1605 const char *MKFS_ERR = "/tmp/mkfs-err.log";
1606 snprintf(rcmd, sizeof(rcmd),
1607 "echo '--- mkfs attempt 1 ---' >> %s; "
1608 "(mkfs.vfat -F 32 -n AC-NATIVE %s > %s 2>&1; rc=$?; "
1609 "cat %s >> %s; exit $rc)",
1610 DLOG, devpath, MKFS_ERR, MKFS_ERR, DLOG);
1611 rrc = system(rcmd);
1612 int mkfs_exit = WIFEXITED(rrc) ? WEXITSTATUS(rrc) : -1;
1613 ac_log("[install] mkfs rc=%d attempt=1 (WIFEXITED=%d status=%d)\n",
1614 rrc, WIFEXITED(rrc), mkfs_exit);
1615 // Inline dump mkfs output so we see the exact error
1616 {
1617 FILE *mf = fopen(MKFS_ERR, "r");
1618 if (mf) {
1619 char line[512];
1620 while (fgets(line, sizeof(line), mf)) {
1621 size_t len = strlen(line);
1622 if (len > 0 && line[len-1] == '\n') line[len-1] = '\0';
1623 ac_log("[mkfs/1] %s\n", line);
1624 }
1625 fclose(mf);
1626 }
1627 }
1628 // Attempt 2–5 if needed (more retries with aggressive flush)
1629 for (int mkfs_try = 2; mkfs_try <= 5 && mkfs_exit != 0; mkfs_try++) {
1630 // Flush block device buffer cache before each retry
1631 {
1632 int pfd = open(devpath, O_RDONLY | O_CLOEXEC);
1633 if (pfd >= 0) { ioctl(pfd, BLKFLSBUF); close(pfd); }
1634 char dd2[64];
1635 snprintf(dd2, sizeof(dd2), "/dev/%s", parent_blk);
1636 int dfd = open(dd2, O_RDONLY | O_CLOEXEC);
1637 if (dfd >= 0) { ioctl(dfd, BLKFLSBUF); close(dfd); }
1638 }
1639 sync();
1640 system("echo 3 > /proc/sys/vm/drop_caches 2>/dev/null || true");
1641 usleep(3000000); // 3s settle between retries
1642 snprintf(rcmd, sizeof(rcmd),
1643 "echo '--- mkfs attempt %d ---' >> %s; "
1644 "(mkfs.vfat -F 32 -n AC-NATIVE %s > %s 2>&1; rc=$?; "
1645 "cat %s >> %s; exit $rc)",
1646 mkfs_try, DLOG, devpath, MKFS_ERR, MKFS_ERR, DLOG);
1647 rrc = system(rcmd);
1648 mkfs_exit = WIFEXITED(rrc) ? WEXITSTATUS(rrc) : -1;
1649 ac_log("[install] mkfs rc=%d attempt=%d (WIFEXITED=%d status=%d)\n",
1650 rrc, mkfs_try, WIFEXITED(rrc), mkfs_exit);
1651 FILE *mf = fopen(MKFS_ERR, "r");
1652 if (mf) {
1653 char line[512];
1654 while (fgets(line, sizeof(line), mf)) {
1655 size_t len = strlen(line);
1656 if (len > 0 && line[len-1] == '\n') line[len-1] = '\0';
1657 ac_log("[mkfs/%d] %s\n", mkfs_try, line);
1658 }
1659 fclose(mf);
1660 }
1661 }
1662 rrc = (mkfs_exit == 0) ? 0 : -1;
1663 if (rrc != 0) {
1664 snprintf(install_fail_reason, sizeof(install_fail_reason),
1665 "format failed on %s", devpath);
1666 snprintf(install_fail_detail, sizeof(install_fail_detail),
1667 "mkfs.vfat rc=%d after repartition (see %s)", rrc, DLOG);
1668 if (display)
1669 draw_boot_status(graph, screen, display, "format failed!", pixel_scale);
1670 usleep(2000000);
1671 continue;
1672 }
1673 usleep(500000);
1674 // Remount and retry
1675 install_copy_phase:
1676 if (mount(devpath, "/tmp/hd", "vfat", 0, NULL) != 0) {
1677 ac_log("[install] remount failed after repartition\n");
1678 snprintf(install_fail_reason, sizeof(install_fail_reason),
1679 "repartition failed on %s", devpath);
1680 snprintf(install_fail_detail, sizeof(install_fail_detail),
1681 "sfdisk=%d mkfs then mount failed", rrc);
1682 if (display)
1683 draw_boot_status(graph, screen, display, "repartition failed!", pixel_scale);
1684 usleep(2000000);
1685 continue;
1686 }
1687 mkdir("/tmp/hd/EFI", 0755);
1688 mkdir("/tmp/hd/EFI/BOOT", 0755);
1689 ac_log("[install] repartitioned OK, retrying copy\n");
1690 }
1691 }
1692 // Copy kernel (and initramfs/loader for systemd-boot layout)
1693 long sz = 0;
1694 if (systemd_boot_layout) {
1695 // Universal layout: splash BOOTX64.EFI + LOADER.EFI +
1696 // KERNEL.EFI + initramfs + loader config.
1697 long bsz = copy_file(bootloader_src, "/tmp/hd/EFI/BOOT/BOOTX64.EFI");
1698 long lsz = copy_file(loader_src, "/tmp/hd/EFI/BOOT/LOADER.EFI");
1699 long ksz = copy_file(chain_kernel_src, "/tmp/hd/EFI/BOOT/KERNEL.EFI");
1700 char initramfs_dst[128];
1701 const char *initramfs_name = strstr(initramfs_src, ".lz4") ? "initramfs.cpio.lz4" : "initramfs.cpio.gz";
1702 snprintf(initramfs_dst, sizeof(initramfs_dst), "/tmp/hd/%s", initramfs_name);
1703 long isz = copy_file(initramfs_src, initramfs_dst);
1704 mkdir("/tmp/hd/loader", 0755);
1705 mkdir("/tmp/hd/loader/entries", 0755);
1706 long csz = copy_file(loader_conf_src, "/tmp/hd/loader/loader.conf");
1707 long esz = copy_file(loader_entry_src, "/tmp/hd/loader/entries/ac-native.conf");
1708 ac_log("[install] bootloader: %ld bytes\n", bsz);
1709 ac_log("[install] loader: %ld bytes\n", lsz);
1710 ac_log("[install] kernel: %ld bytes\n", ksz);
1711 ac_log("[install] initramfs: %ld bytes\n", isz);
1712 ac_log("[install] loader.conf: %ld bytes\n", csz);
1713 ac_log("[install] loader entry: %ld bytes\n", esz);
1714 sz = (bsz > 0 && lsz > 0 && ksz > 0 && isz > 0 && csz > 0 && esz > 0)
1715 ? (bsz + lsz + ksz + isz + csz + esz)
1716 : -1;
1717 } else if (chainloader_layout) {
1718 long bsz = copy_file(bootloader_src, "/tmp/hd/EFI/BOOT/BOOTX64.EFI");
1719 long ksz = copy_file(chain_kernel_src, "/tmp/hd/EFI/BOOT/KERNEL.EFI");
1720 ac_log("[install] chainloader: %ld bytes\n", bsz);
1721 ac_log("[install] kernel: %ld bytes\n", ksz);
1722 sz = (bsz > 0 && ksz > 0) ? (bsz + ksz) : -1;
1723 } else {
1724 // Monolithic: single BOOTX64.EFI is the kernel
1725 sz = copy_file(kernel_src, "/tmp/hd/EFI/BOOT/BOOTX64.EFI");
1726 ac_log("[install] copy result: %ld bytes\n", sz);
1727 }
1728
1729 if (sz <= 0) {
1730 ac_log("[install] copy failed on %s (sz=%ld)\n", devpath, sz);
1731 snprintf(install_fail_reason, sizeof(install_fail_reason),
1732 "copy failed on %s", devpath);
1733 snprintf(install_fail_detail, sizeof(install_fail_detail),
1734 "install payload copy returned %ld bytes", sz);
1735 umount("/tmp/hd");
1736 continue;
1737 }
1738
1739 {
1740 // Also overwrite Windows Boot Manager path (ThinkPad BIOS often
1741 // boots this first regardless of boot order) — but only if space permits
1742 struct statvfs vfs;
1743 long free_bytes = 0;
1744 if (statvfs("/tmp/hd", &vfs) == 0)
1745 free_bytes = (long)vfs.f_bavail * (long)vfs.f_bsize;
1746 if (!systemd_boot_layout && free_bytes > sz + 1048576) {
1747 mkdir("/tmp/hd/EFI/Microsoft", 0755);
1748 mkdir("/tmp/hd/EFI/Microsoft/Boot", 0755);
1749 copy_file(chainloader_layout ? bootloader_src : kernel_src,
1750 "/tmp/hd/EFI/Microsoft/Boot/bootmgfw.efi");
1751 }
1752
1753 // Preserve user config + wifi creds on installed disk
1754 if (access(config_src, F_OK) == 0) {
1755 copy_file(config_src, "/tmp/hd/config.json");
1756 ac_log("[install] copied config.json\n");
1757 }
1758 {
1759 char wifi_src[64];
1760 snprintf(wifi_src, sizeof(wifi_src), "%s/wifi_creds.json",
1761 source_mount);
1762 if (access(wifi_src, F_OK) == 0) {
1763 copy_file(wifi_src, "/tmp/hd/wifi_creds.json");
1764 ac_log("[install] copied wifi_creds.json\n");
1765 }
1766 }
1767
1768 // Write install marker so next boot knows it's installed
1769 FILE *marker = fopen("/tmp/hd/EFI/BOOT/ac-installed", "w");
1770 if (marker) { fputs("1\n", marker); fclose(marker); }
1771
1772 sync();
1773 installed = 1;
1774 ac_log("[install] Installed from %s to %s (systemd-boot=%d chainloader=%d)\n",
1775 source_dev, devpath, systemd_boot_layout, chainloader_layout);
1776 }
1777
1778 umount("/tmp/hd");
1779 }
1780 }
1781 }
1782
1783 if (installed && display) {
1784 draw_boot_status(graph, screen, display, "installed to disk!", pixel_scale);
1785 usleep(800000);
1786 } else if (!installed) {
1787 ac_log("[install] No suitable HD partition found for install\n");
1788 if (!install_fail_reason[0]) {
1789 snprintf(install_fail_reason, sizeof(install_fail_reason),
1790 "no internal FAT32/ESP partition found");
1791 // Build detail: list what we tried
1792 int dpos = 0;
1793 dpos += snprintf(install_fail_detail + dpos,
1794 sizeof(install_fail_detail) - dpos, "usb=%s ", usb_blk[0] ? usb_blk : "?");
1795 for (int i = 0; part_candidates[i]; i++) {
1796 const char *blk = part_candidates[i];
1797 char dp[32]; snprintf(dp, sizeof(dp), "/sys/block/%s", blk);
1798 if (access(dp, F_OK) != 0) continue;
1799 int rem = (blk[0] == 's' && blk[1] == 'd') ? is_removable(blk) : 0;
1800 const char *skip = "";
1801 if (usb_blk[0] && strcmp(blk, usb_blk) == 0) skip = "=boot";
1802 else if (rem == 1) skip = "=USB";
1803 else skip = "=tried";
1804 dpos += snprintf(install_fail_detail + dpos,
1805 sizeof(install_fail_detail) - dpos,
1806 "%s%s ", blk, skip);
1807 }
1808 }
1809 }
1810 if (source_mounted_tmp) umount("/tmp/src");
1811
1812 // Dump the full install-debug.log inline to ac_log so the trace ALWAYS
1813 // lands in ac-native.log (which we know reliably survives to the USB).
1814 // This is the primary diagnostic channel — the parallel copy to
1815 // /mnt/install-debug.log below is a best-effort secondary that may fail
1816 // silently if /mnt got trashed by the repartition attempt.
1817 {
1818 FILE *src = fopen("/tmp/install-debug.log", "r");
1819 if (src) {
1820 ac_log("[install] === /tmp/install-debug.log BEGIN ===\n");
1821 char line[1024];
1822 int line_count = 0;
1823 while (fgets(line, sizeof(line), src)) {
1824 // Strip trailing newline for consistent ac_log formatting
1825 size_t len = strlen(line);
1826 if (len > 0 && line[len-1] == '\n') line[len-1] = '\0';
1827 ac_log("[install-debug] %s\n", line);
1828 line_count++;
1829 if (line_count > 500) {
1830 ac_log("[install] ... (truncated at 500 lines)\n");
1831 break;
1832 }
1833 }
1834 ac_log("[install] === /tmp/install-debug.log END (%d lines) ===\n", line_count);
1835 fclose(src);
1836 } else {
1837 ac_log("[install] /tmp/install-debug.log not present (errno=%d)\n", errno);
1838 }
1839 }
1840
1841 // Parallel copy of the tmpfs log to /mnt (best effort). Even if the
1842 // inline dump above succeeded, keep this so the file is also visible
1843 // as a standalone artifact when /mnt is intact.
1844 {
1845 FILE *src = fopen("/tmp/install-debug.log", "rb");
1846 if (src) {
1847 FILE *dst = fopen("/mnt/install-debug.log", "wb");
1848 if (dst) {
1849 char buf[4096]; size_t n;
1850 while ((n = fread(buf, 1, sizeof(buf), src)) > 0)
1851 fwrite(buf, 1, n, dst);
1852 fflush(dst);
1853 fsync(fileno(dst));
1854 fclose(dst);
1855 ac_log("[install] copied /tmp/install-debug.log → /mnt/install-debug.log\n");
1856 } else {
1857 ac_log("[install] /mnt/install-debug.log copy failed (errno=%d: %s)\n",
1858 errno, strerror(errno));
1859 }
1860 fclose(src);
1861 }
1862 }
1863
1864 return installed;
1865}
1866
1867static void frame_sync_60fps(struct timespec *next) {
1868 next->tv_nsec += 16666667;
1869 if (next->tv_nsec >= 1000000000) {
1870 next->tv_nsec -= 1000000000;
1871 next->tv_sec++;
1872 }
1873 clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, next, NULL);
1874}
1875
1876// Check if a specific key is currently held using pre-opened fds (fast path)
1877static int check_key_held_fds(int keycode, int *fds, int nfds) {
1878 for (int i = 0; i < nfds; i++) {
1879 unsigned long bits[(KEY_MAX + 7) / 8 / sizeof(unsigned long) + 1] = {0};
1880 if (ioctl(fds[i], EVIOCGKEY(sizeof(bits)), bits) >= 0) {
1881 if (bits[keycode / (sizeof(unsigned long) * 8)] &
1882 (1UL << (keycode % (sizeof(unsigned long) * 8))))
1883 return 1;
1884 }
1885 }
1886 return 0;
1887}
1888
1889// Get LA offset from UTC: 7 for PDT, 8 for PST
1890// DST: second Sunday of March 2am PT → first Sunday of November 2am PT
1891static int get_la_offset(void) {
1892 time_t now = time(NULL);
1893 struct tm *utc = gmtime(&now);
1894 int m = utc->tm_mon, y = utc->tm_year + 1900;
1895 if (m > 2 && m < 10) return 7; // Apr-Oct: PDT
1896 if (m < 2 || m > 10) return 8; // Jan-Feb, Dec: PST
1897 if (m == 2) { // March: find second Sunday
1898 struct tm mar1 = {0}; mar1.tm_year = y - 1900; mar1.tm_mon = 2; mar1.tm_mday = 1;
1899 mktime(&mar1);
1900 int secondSun = 8 + (7 - mar1.tm_wday) % 7;
1901 // DST starts at 2am PST = 10:00 UTC on that day
1902 if (utc->tm_mday > secondSun) return 7;
1903 if (utc->tm_mday < secondSun) return 8;
1904 return (utc->tm_hour >= 10) ? 7 : 8;
1905 }
1906 // November: find first Sunday
1907 struct tm nov1 = {0}; nov1.tm_year = y - 1900; nov1.tm_mon = 10; nov1.tm_mday = 1;
1908 mktime(&nov1);
1909 int firstSun = 1 + (7 - nov1.tm_wday) % 7;
1910 // DST ends at 2am PDT = 9:00 UTC on that day
1911 if (utc->tm_mday < firstSun) return 7;
1912 if (utc->tm_mday > firstSun) return 8;
1913 return (utc->tm_hour < 9) ? 7 : 8;
1914}
1915
1916static int get_la_hour(void) {
1917 time_t now = time(NULL);
1918 struct tm *utc = gmtime(&now);
1919 return (utc->tm_hour - get_la_offset() + 24) % 24;
1920}
1921
1922static void play_install_prompt_beep(ACAudio *audio) {
1923 if (!audio || !audio->pcm) return;
1924 audio_synth(audio, WAVE_SINE, 620.0, 0.06, 0.40, 0.001, 0.05, 0.0);
1925 usleep(35000);
1926 audio_synth(audio, WAVE_SINE, 760.0, 0.06, 0.40, 0.001, 0.05, 0.0);
1927}
1928
1929static void play_install_accept_beep(ACAudio *audio) {
1930 if (!audio || !audio->pcm) return;
1931 audio_synth(audio, WAVE_TRIANGLE, 880.0, 0.08, 0.55, 0.001, 0.06, -0.1);
1932 usleep(45000);
1933 audio_synth(audio, WAVE_TRIANGLE, 1175.0, 0.10, 0.65, 0.001, 0.08, 0.1);
1934}
1935
1936static void play_install_reject_beep(ACAudio *audio) {
1937 if (!audio || !audio->pcm) return;
1938 audio_synth(audio, WAVE_SAWTOOTH, 260.0, 0.08, 0.45, 0.001, 0.06, 0.0);
1939 usleep(45000);
1940 audio_synth(audio, WAVE_SAWTOOTH, 180.0, 0.12, 0.45, 0.001, 0.09, 0.0);
1941}
1942
1943static void play_install_success_beep(ACAudio *audio) {
1944 if (!audio || !audio->pcm) return;
1945 audio_synth(audio, WAVE_TRIANGLE, 659.25, 0.12, 0.55, 0.001, 0.08, -0.2);
1946 usleep(50000);
1947 audio_synth(audio, WAVE_TRIANGLE, 783.99, 0.12, 0.60, 0.001, 0.08, 0.0);
1948 usleep(50000);
1949 audio_synth(audio, WAVE_TRIANGLE, 987.77, 0.16, 0.68, 0.001, 0.12, 0.2);
1950}
1951
1952static void play_install_failure_beep(ACAudio *audio) {
1953 if (!audio || !audio->pcm) return;
1954 audio_synth(audio, WAVE_SAWTOOTH, 180.0, 0.11, 0.50, 0.001, 0.08, 0.0);
1955 usleep(55000);
1956 audio_synth(audio, WAVE_SAWTOOTH, 140.0, 0.15, 0.50, 0.001, 0.11, 0.0);
1957}
1958
1959// Draw y/n install confirmation screen
1960// Returns 1 if user confirms with Y, 0 if N/Escape
1961static int draw_install_confirm(ACGraph *graph, ACFramebuffer *screen,
1962 ACDisplay *display, int *fds, int nfds,
1963 ACTts *tts, ACAudio *audio, int pixel_scale) {
1964 int is_dark = 1; // always dark
1965 struct timespec anim_time;
1966 clock_gettime(CLOCK_MONOTONIC, &anim_time);
1967
1968 if (tts) tts_speak(tts, "install to disk? press Y for yes, N for no");
1969 play_install_prompt_beep(audio);
1970
1971 for (;;) {
1972 uint8_t bg = is_dark ? 20 : 255;
1973 graph_wipe(graph, (ACColor){bg, bg, (uint8_t)(bg + (is_dark ? 5 : 0)), 255});
1974
1975 uint8_t fg = is_dark ? 220 : 0;
1976 graph_ink(graph, (ACColor){fg, fg, fg, 255});
1977
1978 int tw = font_measure_matrix("install to disk?", 2);
1979 font_draw_matrix(graph, "install to disk?",
1980 (screen->width - tw) / 2, screen->height / 2 - 24, 2);
1981
1982 // Warning text
1983 graph_ink(graph, (ACColor){220, 80, 80, 255});
1984 const char *warn = "this will overwrite EFI boot!";
1985 int ww = font_measure_matrix(warn, 1);
1986 font_draw_matrix(graph, warn, (screen->width - ww) / 2,
1987 screen->height / 2 + 2, 1);
1988
1989 // Y/N prompt
1990 graph_ink(graph, (ACColor){60, 200, 60, 255});
1991 const char *yn = "Y = yes N = no";
1992 int yw = font_measure_matrix(yn, 1);
1993 font_draw_matrix(graph, yn, (screen->width - yw) / 2,
1994 screen->height / 2 + 20, 1);
1995
1996 ac_display_present(display, screen, pixel_scale);
1997 frame_sync_60fps(&anim_time);
1998
1999 // Read key events
2000 struct input_event ev;
2001 for (int ki = 0; ki < nfds; ki++) {
2002 while (read(fds[ki], &ev, sizeof(ev)) == sizeof(ev)) {
2003 if (ev.type == EV_KEY && ev.value == 1) {
2004 if (ev.code == KEY_Y) {
2005 play_install_accept_beep(audio);
2006 return 1;
2007 }
2008 if (ev.code == KEY_N || ev.code == KEY_ESC) {
2009 play_install_reject_beep(audio);
2010 return 0;
2011 }
2012 }
2013 }
2014 }
2015 }
2016}
2017
2018// Pause after install attempt so USB boots do not continue into the piece.
2019// Returns 1 if reboot requested, 0 if user chose to continue boot (failure-only path).
2020static int draw_install_reboot_prompt(ACGraph *graph, ACFramebuffer *screen,
2021 ACDisplay *display, ACInput *input,
2022 ACTts *tts, ACAudio *audio,
2023 int install_ok, int pixel_scale) {
2024 struct timespec anim_time;
2025 clock_gettime(CLOCK_MONOTONIC, &anim_time);
2026
2027 if (install_ok) {
2028 if (tts) tts_speak(tts, "install complete. remove USB stick. press R to reboot.");
2029 play_install_success_beep(audio);
2030 } else {
2031 if (tts) tts_speak(tts, "install failed. press R to reboot or escape to continue.");
2032 play_install_failure_beep(audio);
2033 }
2034
2035 int blink = 0;
2036 int no_input_frames = 0;
2037 while (running) {
2038 blink++;
2039 graph_wipe(graph, (ACColor){20, 20, 25, 255});
2040
2041 const char *title = install_ok ? "disk install complete" : "disk install failed";
2042 ACColor title_color = install_ok
2043 ? (ACColor){90, 220, 120, 255}
2044 : (ACColor){220, 100, 90, 255};
2045 graph_ink(graph, title_color);
2046 int tw = font_measure_matrix(title, 2);
2047 font_draw_matrix(graph, title, (screen->width - tw) / 2, screen->height / 2 - 34, 2);
2048
2049 graph_ink(graph, (ACColor){220, 220, 220, 255});
2050 const char *line1 = install_ok
2051 ? "remove USB stick now"
2052 : "check EFI target and try again";
2053 int l1w = font_measure_matrix(line1, 1);
2054 font_draw_matrix(graph, line1, (screen->width - l1w) / 2, screen->height / 2 + 0, 1);
2055
2056 // Show failure diagnostics
2057 if (!install_ok && install_fail_reason[0]) {
2058 graph_ink(graph, (ACColor){200, 160, 100, 255});
2059 int rw = font_measure_matrix(install_fail_reason, 1);
2060 font_draw_matrix(graph, install_fail_reason,
2061 (screen->width - rw) / 2, screen->height / 2 + 16, 1);
2062
2063 if (install_fail_detail[0]) {
2064 graph_ink(graph, (ACColor){140, 140, 160, 255});
2065 int dw = font_measure_matrix(install_fail_detail, 1);
2066 font_draw_matrix(graph, install_fail_detail,
2067 (screen->width - dw) / 2, screen->height / 2 + 28, 1);
2068 }
2069 }
2070
2071 int yoff = (!install_ok && install_fail_reason[0]) ? 16 : 0;
2072 graph_ink(graph, (ACColor){180, 180, 210, 255});
2073 const char *line2 = "R/Enter = reboot";
2074 int l2w = font_measure_matrix(line2, 1);
2075 font_draw_matrix(graph, line2, (screen->width - l2w) / 2, screen->height / 2 + 32 + yoff, 1);
2076
2077 if (!install_ok) {
2078 graph_ink(graph, (ACColor){150, 150, 170, 255});
2079 const char *line3 = "Esc = continue USB boot";
2080 int l3w = font_measure_matrix(line3, 1);
2081 font_draw_matrix(graph, line3, (screen->width - l3w) / 2, screen->height / 2 + 46 + yoff, 1);
2082 }
2083
2084 if ((blink % 60) < 36) {
2085 graph_ink(graph, (ACColor){120, 120, 140, 255});
2086 const char *line4 = install_ok ? "waiting for reboot..." : "waiting for key...";
2087 int l4w = font_measure_matrix(line4, 1);
2088 font_draw_matrix(graph, line4, (screen->width - l4w) / 2, screen->height / 2 + 62 + yoff, 1);
2089 }
2090
2091 ac_display_present(display, screen, pixel_scale);
2092 frame_sync_60fps(&anim_time);
2093
2094 if (!input) {
2095 no_input_frames++;
2096 if (no_input_frames > 900) return install_ok ? 1 : 0; // ~15s fallback
2097 continue;
2098 }
2099 input_poll(input);
2100 for (int i = 0; i < input->event_count; i++) {
2101 ACEvent *ev = &input->events[i];
2102 if (ev->type != AC_EVENT_KEYBOARD_DOWN) continue;
2103
2104 if (ev->key_code == KEY_R || ev->key_code == KEY_ENTER || ev->key_code == KEY_KPENTER) {
2105 if (tts) tts_wait(tts); // let "install complete" TTS finish
2106 play_install_accept_beep(audio);
2107 usleep(300000); // let beep play
2108 return 1;
2109 }
2110 if (!install_ok && (ev->key_code == KEY_ESC || ev->key_code == KEY_SPACE)) {
2111 play_install_reject_beep(audio);
2112 return 0;
2113 }
2114 }
2115 }
2116 return install_ok ? 1 : 0;
2117}
2118
2119// Check if we booted from an installed (non-removable) disk
2120// by looking for ac-installed marker on an internal ESP
2121// Extract parent block device name from a partition path:
2122// "nvme0n1p1" → "nvme0n1"
2123// "mmcblk0p1" → "mmcblk0"
2124// "sda1" → "sda"
2125// NVMe and eMMC both use the "<dev>p<N>" partition-suffix scheme (because
2126// the parent name ends in a digit), so strip the trailing "pN" for both.
2127static void get_parent_block(const char *part, char *out, int out_sz) {
2128 out[0] = 0;
2129 int len = (int)strlen(part);
2130 if (len >= out_sz) return;
2131 // NVMe / eMMC: strip trailing "pN" (e.g. nvme0n1p1 → nvme0n1,
2132 // mmcblk0p1 → mmcblk0).
2133 if (strncmp(part, "nvme", 4) == 0 || strncmp(part, "mmcblk", 6) == 0) {
2134 int base_min = (part[0] == 'n') ? 4 : 6;
2135 for (int i = len - 1; i > base_min; i--) {
2136 if (part[i - 1] == 'p' && part[i] >= '0' && part[i] <= '9') {
2137 memcpy(out, part, i - 1);
2138 out[i - 1] = 0;
2139 return;
2140 }
2141 }
2142 }
2143 // SATA/USB: strip trailing digits (e.g. sda1 → sda)
2144 int i = len;
2145 while (i > 0 && part[i - 1] >= '0' && part[i - 1] <= '9') i--;
2146 if (i > 0 && i < out_sz) {
2147 memcpy(out, part, i);
2148 out[i] = 0;
2149 }
2150}
2151
2152static int is_installed_on_hd(void) {
2153 if (getpid() != 1) return 0; // not bare metal
2154 mkdir("/tmp/chk", 0755);
2155 const char *parts[] = {
2156 "/dev/nvme0n1p1", "/dev/nvme0n1p2",
2157 "/dev/mmcblk0p1", "/dev/mmcblk0p2",
2158 "/dev/sda1", "/dev/sdb1", NULL
2159 };
2160 for (int i = 0; parts[i]; i++) {
2161 // Extract parent block device and skip removable (USB) drives
2162 char blk[32] = "";
2163 get_parent_block(parts[i] + 5, blk, sizeof(blk));
2164 fprintf(stderr, "[install-check] %s → parent=%s\n", parts[i], blk);
2165 if (blk[0] && is_removable(blk) == 1) {
2166 fprintf(stderr, "[install-check] → removable, skipping\n");
2167 continue;
2168 }
2169 if (mount(parts[i], "/tmp/chk", "vfat", MS_RDONLY, NULL) != 0) {
2170 fprintf(stderr, "[install-check] → mount failed\n");
2171 continue;
2172 }
2173 int found = access("/tmp/chk/EFI/BOOT/ac-installed", F_OK) == 0;
2174 umount("/tmp/chk");
2175 fprintf(stderr, "[install-check] → mounted, ac-installed=%s\n", found ? "YES" : "no");
2176 if (found) return 1;
2177 }
2178 fprintf(stderr, "[install-check] not installed on HD\n");
2179 return 0;
2180}
2181
2182// Draw startup fade animation (black → white with title)
2183// Returns 1 if user pressed W and confirmed install, 0 otherwise
2184static int draw_startup_fade(ACGraph *graph, ACFramebuffer *screen,
2185 ACDisplay *display, ACTts *tts, ACAudio *audio,
2186 int pixel_scale) {
2187 struct timespec anim_time;
2188 clock_gettime(CLOCK_MONOTONIC, &anim_time);
2189 // Show install option whenever booting from USB (even if already installed —
2190 // user may want to update). Detect USB by checking if boot device is removable.
2191 int show_install = 0;
2192 if (log_dev[0]) {
2193 char boot_blk[32] = "";
2194 get_parent_block(log_dev + 5, boot_blk, sizeof(boot_blk));
2195 show_install = (boot_blk[0] && is_removable(boot_blk) == 1) ? 1 : 0;
2196 ac_log("[boot-anim] boot_dev=%s parent=%s removable=%d show_install=%d\n",
2197 log_dev, boot_blk, is_removable(boot_blk), show_install);
2198 } else {
2199 // No log_dev — check if any removable block device has our EFI boot file
2200 // If not, we're booted from internal disk (post-install) — don't show install
2201 show_install = 0;
2202 DIR *blkdir = opendir("/sys/block");
2203 if (blkdir) {
2204 struct dirent *bent;
2205 while ((bent = readdir(blkdir)) != NULL) {
2206 if (bent->d_name[0] == '.') continue;
2207 if (is_removable(bent->d_name) == 1) {
2208 show_install = 1;
2209 ac_log("[boot-anim] removable device %s found, show_install=1\n", bent->d_name);
2210 break;
2211 }
2212 }
2213 closedir(blkdir);
2214 }
2215 if (!show_install)
2216 ac_log("[boot-anim] no removable media, show_install=0\n");
2217 }
2218
2219 // Open evdev fds for key checking — retry until devices appear
2220 int key_fds[24];
2221 int key_fd_count = 0;
2222 for (int retry = 0; retry < 20 && key_fd_count == 0; retry++) {
2223 DIR *dir = opendir("/dev/input");
2224 if (dir) {
2225 struct dirent *ent;
2226 while ((ent = readdir(dir)) && key_fd_count < 24) {
2227 if (strncmp(ent->d_name, "event", 5) != 0) continue;
2228 char path[64];
2229 snprintf(path, sizeof(path), "/dev/input/%s", ent->d_name);
2230 int fd = open(path, O_RDONLY | O_NONBLOCK);
2231 if (fd >= 0) {
2232 key_fds[key_fd_count++] = fd;
2233 fprintf(stderr, "[boot-anim] opened %s (fd %d)\n", path, fd);
2234 }
2235 }
2236 closedir(dir);
2237 }
2238 if (key_fd_count == 0) {
2239 fprintf(stderr, "[boot-anim] no input devices yet, waiting... (%d/20)\n", retry + 1);
2240 usleep(100000); // 100ms
2241 }
2242 }
2243 fprintf(stderr, "[boot-anim] %d event devices\n", key_fd_count);
2244
2245 // Start with a solid frame immediately (hides any kernel text)
2246 // White for daytime, black for evening/night
2247 int boot_hour = get_la_hour();
2248 int boot_is_day = (boot_hour >= 7 && boot_hour < 18);
2249 graph_wipe(graph, boot_is_day
2250 ? (ACColor){255, 255, 255, 255}
2251 : (ACColor){0, 0, 0, 255});
2252 ac_display_present(display, screen, pixel_scale);
2253
2254 // Check if this is a fresh boot of a new version
2255 int is_new_version = 0;
2256#ifdef AC_GIT_HASH
2257#ifdef AC_BUILD_TS
2258 {
2259 const char *current_ver = AC_GIT_HASH "-" AC_BUILD_TS;
2260 char prev_ver[128] = "";
2261 FILE *vf = fopen("/mnt/booted-version", "r");
2262 if (vf) {
2263 if (fgets(prev_ver, sizeof(prev_ver), vf)) {
2264 // Strip trailing newline
2265 char *nl = strchr(prev_ver, '\n');
2266 if (nl) *nl = 0;
2267 }
2268 fclose(vf);
2269 }
2270 is_new_version = (strcmp(prev_ver, current_ver) != 0);
2271 ac_log("[boot] version=%s prev=%s fresh=%s\n", current_ver, prev_ver, is_new_version ? "YES" : "no");
2272 // Write current version immediately so next boot sees it
2273 vf = fopen("/mnt/booted-version", "w");
2274 if (vf) {
2275 fprintf(vf, "%s", current_ver);
2276 fclose(vf);
2277 }
2278 // Append to boot history log (persists across reboots)
2279 vf = fopen("/mnt/boot-history.log", "a");
2280 if (vf) {
2281 time_t now_t = time(NULL);
2282 struct tm *tm = gmtime(&now_t);
2283 char ts[32];
2284 strftime(ts, sizeof(ts), "%Y-%m-%dT%H:%M:%SZ", tm);
2285 fprintf(vf, "%s %s %s\n", ts, current_ver, is_new_version ? "FRESH" : "same");
2286 fclose(vf);
2287 }
2288 sync();
2289 }
2290#endif
2291#endif
2292
2293 // Generate or load persistent machine ID (needs /mnt mounted)
2294 init_machine_id();
2295
2296 // Initialize machines monitoring daemon
2297 machines_init(&g_machines);
2298
2299 // 180 frames = 3 second animation.
2300 // W press → halt and show y/n confirmation
2301 // Any other key → skip animation and start playing
2302 int total_frames = 180; // 3 seconds
2303 int skip_anim = 0;
2304 int w_pressed = 0;
2305 for (int f = 0; f < total_frames && !skip_anim && !w_pressed; f++) {
2306 double t = (double)f / (double)total_frames;
2307
2308 // Drain all key events — detect W press or other-key skip
2309 {
2310 struct input_event ev;
2311 for (int ki = 0; ki < key_fd_count; ki++) {
2312 while (read(key_fds[ki], &ev, sizeof(ev)) == sizeof(ev)) {
2313 if (ev.type == EV_KEY && ev.value == 1) {
2314 // First 60 frames (1 second): ignore all keys
2315 // Ensures W hint is visible before accepting input
2316 if (f < 60) {
2317 fprintf(stderr, "[boot-anim] drained key %d at f=%d (hold period)\n", ev.code, f);
2318 continue;
2319 }
2320 fprintf(stderr, "[boot-anim] key %d at f=%d\n", ev.code, f);
2321 if (ev.code == KEY_W && show_install) {
2322 w_pressed = 1;
2323 } else if (ev.code != KEY_RESERVED) {
2324 skip_anim = 1;
2325 }
2326 }
2327 }
2328 }
2329 }
2330
2331 // Startup greeting — time-of-day + handle, or boot melody if no handle
2332 if (f == 10) {
2333 const char *at = strchr(boot_title, '@');
2334 if (at && tts) {
2335 // Personalized TTS greeting
2336 char greet[256];
2337 int hour = get_la_hour();
2338 const char *tod;
2339 if (hour >= 5 && hour < 12) tod = "good morning";
2340 else if (hour >= 12 && hour < 17) tod = "good afternoon";
2341 else tod = "good evening";
2342#ifdef AC_BUILD_NAME
2343 char name_tts[64];
2344 strncpy(name_tts, AC_BUILD_NAME, sizeof(name_tts) - 1);
2345 name_tts[sizeof(name_tts) - 1] = 0;
2346 for (char *p = name_tts; *p; p++) { if (*p == '-') *p = ' '; }
2347 snprintf(greet, sizeof(greet), "%s %s. enjoy Los Angeles! %s.", tod, at + 1, name_tts);
2348#else
2349 snprintf(greet, sizeof(greet), "%s %s. enjoy Los Angeles!", tod, at + 1);
2350#endif
2351 tts_speak(tts, greet);
2352 } else if (audio) {
2353 // No handle — play a short ascending arpeggio (C E G C')
2354 audio_synth(audio, WAVE_SINE, 523.3, 0.15, 0.6, 0.003, 0.10, -0.3); // C5
2355 }
2356 }
2357 // Stagger the arpeggio notes across frames for no-handle boot
2358 if (!strchr(boot_title, '@') && audio) {
2359 if (f == 20) audio_synth(audio, WAVE_SINE, 659.3, 0.15, 0.6, 0.003, 0.10, 0.0); // E5
2360 if (f == 30) audio_synth(audio, WAVE_SINE, 784.0, 0.15, 0.6, 0.003, 0.10, 0.3); // G5
2361 if (f == 42) audio_synth(audio, WAVE_SINE, 1047.0, 0.20, 0.5, 0.003, 0.15, 0.0); // C6
2362 }
2363 // W hint is visual only — no TTS
2364
2365 // Fade from black/white to time-of-day themed bg (complete in first 0.3s)
2366 double fade_t = t * 3.33;
2367 if (fade_t > 1.0) fade_t = 1.0;
2368 int hour = get_la_hour();
2369 int is_day = (hour >= 7 && hour < 18); // light mode 7am-6pm
2370 int target_r, target_g, target_b;
2371 if (is_day) {
2372 // Light mode: soft warm backgrounds
2373 if (hour >= 7 && hour < 12) {
2374 target_r = 235; target_g = 230; target_b = 220; // morning cream
2375 } else {
2376 target_r = 240; target_g = 235; target_b = 215; // afternoon warm white
2377 }
2378 } else {
2379 // Dark mode: rich atmospheric backgrounds
2380 if (hour >= 5 && hour < 7) {
2381 target_r = 100; target_g = 45; target_b = 20; // sunrise orange
2382 } else if (hour >= 18 && hour < 20) {
2383 target_r = 80; target_g = 25; target_b = 60; // sunset purple
2384 } else {
2385 target_r = 15; target_g = 15; target_b = 40; // night deep blue
2386 }
2387 }
2388 int start_r = is_day ? 255 : 0;
2389 int start_g = is_day ? 255 : 0;
2390 int start_b = is_day ? 255 : 0;
2391 int bg_r = start_r + (int)((target_r - start_r) * fade_t);
2392 int bg_g = start_g + (int)((target_g - start_g) * fade_t);
2393 int bg_b = start_b + (int)((target_b - start_b) * fade_t);
2394 graph_wipe(graph, (ACColor){(uint8_t)bg_r, (uint8_t)bg_g, (uint8_t)bg_b, 255});
2395
2396 // Title — per-handle palette (fallback rainbow), animated pulse
2397 int alpha = (int)(255.0 * fade_t);
2398 if (alpha > 0) {
2399 const char *title = boot_title;
2400 // Auto-scale: use 3x unless title is too wide, then 2x
2401 int scale = 3;
2402 int tw = font_measure_matrix(title, scale);
2403 if (tw > screen->width - 20) {
2404 scale = 2;
2405 tw = font_measure_matrix(title, scale);
2406 }
2407 int tx = (screen->width - tw) / 2;
2408 int ty = screen->height / 2 - 20;
2409 for (int ci = 0; title[ci]; ci++) {
2410 ACColor cc = title_char_color(ci, f, alpha);
2411 graph_ink(graph, cc);
2412 char ch[2] = { title[ci], 0 };
2413 tx = font_draw_matrix(graph, ch, tx, ty, scale);
2414 }
2415 }
2416
2417 // Version + build name + build date (high-contrast panel, top-right)
2418#ifdef AC_GIT_HASH
2419 if (alpha > 40) {
2420 char ver[64];
2421 char bts[64];
2422 char bname[64] = "";
2423 char ddrv[64] = "";
2424 snprintf(ver, sizeof(ver), "version %s", AC_GIT_HASH);
2425#ifdef AC_BUILD_TS
2426 snprintf(bts, sizeof(bts), "%s", AC_BUILD_TS);
2427#else
2428 snprintf(bts, sizeof(bts), "build unknown");
2429#endif
2430#ifdef AC_BUILD_NAME
2431 snprintf(bname, sizeof(bname), "%s", AC_BUILD_NAME);
2432#endif
2433 const char *driver = drm_display_driver(display);
2434 snprintf(ddrv, sizeof(ddrv), "display %s", driver);
2435 int wv = font_measure_matrix(ver, 1);
2436 int wt = font_measure_matrix(bts, 1);
2437 int wn = bname[0] ? font_measure_matrix(bname, 1) : 0;
2438 int wd = font_measure_matrix(ddrv, 1);
2439 int max_w = wv;
2440 if (wt > max_w) max_w = wt;
2441 if (wn > max_w) max_w = wn;
2442 if (wd > max_w) max_w = wd;
2443 int panel_w = max_w + 8;
2444 int panel_h = (bname[0] ? 28 : 20) + 8;
2445 int panel_x = screen->width - panel_w - 3;
2446 int panel_y = 3;
2447 graph_ink(graph, is_day
2448 ? (ACColor){255, 255, 255, (uint8_t)(alpha * 0.7)}
2449 : (ACColor){0, 0, 0, (uint8_t)(alpha * 0.82)});
2450 graph_box(graph, panel_x, panel_y, panel_w, panel_h, 1);
2451 // Build name (top line)
2452 if (bname[0]) {
2453 graph_ink(graph, is_day
2454 ? (ACColor){140, 100, 0, (uint8_t)alpha}
2455 : (ACColor){255, 200, 60, (uint8_t)alpha});
2456 font_draw_matrix(graph, bname, panel_x + 4, panel_y + 3, 1);
2457 }
2458 int line_y = panel_y + (bname[0] ? 11 : 3);
2459 graph_ink(graph, is_day
2460 ? (ACColor){60, 60, 60, (uint8_t)alpha}
2461 : (ACColor){255, 255, 255, (uint8_t)alpha});
2462 font_draw_matrix(graph, ver, panel_x + 4, line_y, 1);
2463 graph_ink(graph, is_day
2464 ? (ACColor){80, 100, 90, (uint8_t)alpha}
2465 : (ACColor){210, 235, 220, (uint8_t)alpha});
2466 font_draw_matrix(graph, bts, panel_x + 4, line_y + 8, 1);
2467 // Display driver line
2468 graph_ink(graph, is_day
2469 ? (ACColor){90, 60, 120, (uint8_t)alpha}
2470 : (ACColor){180, 160, 255, (uint8_t)alpha});
2471 font_draw_matrix(graph, ddrv, panel_x + 4, line_y + 16, 1);
2472 // "FRESH" badge when first boot of this version
2473 if (is_new_version) {
2474 graph_ink(graph, is_day
2475 ? (ACColor){0, 140, 60, (uint8_t)alpha}
2476 : (ACColor){80, 255, 120, (uint8_t)alpha});
2477 font_draw_matrix(graph, "FRESH", panel_x - font_measure_matrix("FRESH", 1) - 4, panel_y + 6, 1);
2478 }
2479 }
2480#endif
2481
2482 // Subtitle: "enjoy Los Angeles!" appears after frame 130
2483 if (f > 130) {
2484 double sub_t = (double)(f - 130) / 30.0;
2485 if (sub_t > 1.0) sub_t = 1.0;
2486 int sub_alpha = (int)(180.0 * sub_t);
2487 graph_ink(graph, is_day
2488 ? (ACColor){120, 100, 80, (uint8_t)sub_alpha}
2489 : (ACColor){220, 180, 140, (uint8_t)sub_alpha});
2490 const char *subtitle = "enjoy Los Angeles!";
2491 int sw = font_measure_matrix(subtitle, 1);
2492 font_draw_matrix(graph, subtitle,
2493 (screen->width - sw) / 2, screen->height / 2 + 10, 1);
2494 }
2495
2496 // Auth badges (bottom-left): pixel crab = Claude, pixel octocat = GitHub
2497 if (f > 60 && alpha > 80) {
2498 int badge_x = 6;
2499 int badge_y = screen->height - 22;
2500 double badge_t = (double)(f - 60) / 40.0;
2501 if (badge_t > 1.0) badge_t = 1.0;
2502 int ba = (int)(220.0 * badge_t); // badge alpha
2503
2504 // 11x9 pixel crab (Claude/Anthropic)
2505 if (access("/claude-token", F_OK) == 0 || getenv("CLAUDE_CODE_OAUTH_TOKEN")) {
2506 static const char crab[9][12] = {
2507 " . . ",
2508 " . . ",
2509 " ..##.##.. ",
2510 ".# #### #.",
2511 ". ####### .",
2512 " ####### ",
2513 " ## . ## ",
2514 " . . ",
2515 " . . ",
2516 };
2517 for (int cy = 0; cy < 9; cy++)
2518 for (int cx = 0; cx < 11; cx++) {
2519 char c = crab[cy][cx];
2520 if (c == '#')
2521 graph_ink(graph, (ACColor){255, 120, 50, (uint8_t)ba});
2522 else if (c == '.')
2523 graph_ink(graph, (ACColor){200, 90, 30, (uint8_t)(ba*2/3)});
2524 else continue;
2525 graph_box(graph, badge_x + cx*2, badge_y + cy*2, 2, 2, 1);
2526 }
2527 badge_x += 28;
2528 }
2529 // 11x11 pixel octocat (GitHub)
2530 if (access("/github-pat", F_OK) == 0 || getenv("GH_TOKEN")) {
2531 static const char octo[11][12] = {
2532 " .###. ",
2533 " ####### ",
2534 " ## o#o ## ",
2535 " ######### ",
2536 " ## ### ## ",
2537 " ####### ",
2538 " ##### ",
2539 " .# . #. ",
2540 " .# . #. ",
2541 " . . . ",
2542 ". . .",
2543 };
2544 for (int cy = 0; cy < 11; cy++)
2545 for (int cx = 0; cx < 11; cx++) {
2546 char c = octo[cy][cx];
2547 if (c == '#')
2548 graph_ink(graph, (ACColor){180, 210, 255, (uint8_t)ba});
2549 else if (c == 'o')
2550 graph_ink(graph, (ACColor){60, 80, 120, (uint8_t)ba});
2551 else if (c == '.')
2552 graph_ink(graph, (ACColor){120, 150, 200, (uint8_t)(ba*2/3)});
2553 else continue;
2554 graph_box(graph, badge_x + cx*2, badge_y + cy*2, 2, 2, 1);
2555 }
2556 badge_x += 28;
2557 }
2558 }
2559
2560 // Animated triangles — geometric decoration
2561 if (alpha > 30) {
2562 int tri_alpha = (int)(alpha * 0.15);
2563 int W = screen->width;
2564 int H = screen->height;
2565 // Drifting triangles based on frame counter
2566 for (int ti = 0; ti < 6; ti++) {
2567 double phase = (double)f * 0.02 + ti * 1.047; // 60° apart
2568 int cx = (int)(W * 0.5 + W * 0.35 * sin(phase + 1.5708));
2569 int cy = (int)(H * 0.5 + H * 0.3 * sin(phase * 0.7));
2570 int sz = 8 + ti * 3 + (int)(4.0 * sin(f * 0.05 + ti));
2571 ACColor tc = is_day
2572 ? (ACColor){180 - ti*15, 140 - ti*10, 120, (uint8_t)tri_alpha}
2573 : (ACColor){80 + ti*20, 60 + ti*15, 120 + ti*10, (uint8_t)tri_alpha};
2574 graph_ink(graph, tc);
2575 // Draw triangle as 3 lines
2576 int x0 = cx, y0 = cy - sz;
2577 int x1 = cx - sz, y1 = cy + sz/2;
2578 int x2 = cx + sz, y2 = cy + sz/2;
2579 graph_line(graph, x0, y0, x1, y1);
2580 graph_line(graph, x1, y1, x2, y2);
2581 graph_line(graph, x2, y2, x0, y0);
2582 }
2583 }
2584
2585 // Bottom: shrinking time bar
2586 int bar_full = screen->width - 40;
2587 int bar_remaining = (int)((1.0 - t) * bar_full);
2588 if (bar_remaining > 0) {
2589 graph_ink(graph, (ACColor){200, 150, 180, (uint8_t)(80 * (1.0 - t))});
2590 graph_box(graph, 20, screen->height - 6, bar_remaining, 3, 1);
2591 }
2592
2593 // Animated W install prompt
2594 if (alpha > 100 && show_install) {
2595 // Pulsing box behind the text
2596 double pulse = 0.5 + 0.5 * sin(f * 0.1);
2597 int pa = (int)(40 + 30 * pulse);
2598 const char *hint = is_installed_on_hd()
2599 ? "W: update" : "W: install to disk";
2600 int hw = font_measure_matrix(hint, 1);
2601 int hx = (screen->width - hw) / 2;
2602 int hy = screen->height - 20;
2603 // Pulsing background pill
2604 graph_ink(graph, is_day
2605 ? (ACColor){200, 160, 120, (uint8_t)pa}
2606 : (ACColor){60, 40, 80, (uint8_t)pa});
2607 graph_box(graph, hx - 4, hy - 2, hw + 8, 12, 1);
2608 // Triangle arrow pointing down at the text
2609 int ax = hx - 10;
2610 int ay = hy + 3;
2611 graph_ink(graph, is_day
2612 ? (ACColor){180, 120, 60, (uint8_t)(alpha / 2)}
2613 : (ACColor){200, 150, 255, (uint8_t)(alpha / 2)});
2614 graph_line(graph, ax, ay - 3, ax, ay + 3);
2615 graph_line(graph, ax, ay + 3, ax - 3, ay);
2616 // Text with higher contrast
2617 graph_ink(graph, is_day
2618 ? (ACColor){120, 60, 0, (uint8_t)(alpha * 2 / 3)}
2619 : (ACColor){220, 180, 255, (uint8_t)(alpha * 2 / 3)});
2620 font_draw_matrix(graph, hint, hx, hy, 1);
2621 }
2622
2623 ac_display_present(display, screen, pixel_scale);
2624 frame_sync_60fps(&anim_time);
2625 }
2626
2627 // If W was pressed, show y/n confirmation
2628 int result = 0;
2629 if (w_pressed) {
2630 result = draw_install_confirm(graph, screen, display, key_fds, key_fd_count, tts, audio, pixel_scale);
2631 }
2632
2633 for (int i = 0; i < key_fd_count; i++) close(key_fds[i]);
2634 return result;
2635}
2636
2637// Draw a status frame during boot (white bg, bouncy title + status text)
2638// Renders multiple frames with a bounce animation
2639static void draw_boot_status(ACGraph *graph, ACFramebuffer *screen,
2640 ACDisplay *display, const char *status, int pixel_scale) {
2641 static int boot_frame = 0;
2642 // Time-of-day themed background (light for day, dark for night)
2643 int la_hour = get_la_hour();
2644 int is_day = (la_hour >= 7 && la_hour < 18);
2645 uint8_t bg_r, bg_g, bg_b;
2646 if (is_day) {
2647 if (la_hour < 12) {
2648 bg_r = 235; bg_g = 230; bg_b = 220; // morning cream
2649 } else {
2650 bg_r = 240; bg_g = 235; bg_b = 215; // afternoon warm white
2651 }
2652 } else {
2653 if (la_hour >= 5 && la_hour < 7) {
2654 bg_r = 100; bg_g = 45; bg_b = 20; // sunrise orange
2655 } else if (la_hour >= 18 && la_hour < 20) {
2656 bg_r = 80; bg_g = 25; bg_b = 60; // sunset purple
2657 } else {
2658 bg_r = 15; bg_g = 15; bg_b = 40; // night deep blue
2659 }
2660 }
2661
2662 struct timespec anim_time;
2663 clock_gettime(CLOCK_MONOTONIC, &anim_time);
2664
2665 // Animate for 20 frames (~333ms) per status change
2666 for (int af = 0; af < 20; af++) {
2667 boot_frame++;
2668 graph_wipe(graph, (ACColor){bg_r, bg_g, bg_b, 255});
2669
2670 // Rainbow stripes at top and bottom
2671 {
2672 int stripe_h = 3;
2673 int num_stripes = 8;
2674 int band = num_stripes * stripe_h;
2675 for (int s = 0; s < num_stripes; s++) {
2676 double hue = fmod((double)s / num_stripes * 360.0 + boot_frame * 3.0, 360.0);
2677 double h6 = hue / 60.0;
2678 int hi = (int)h6 % 6;
2679 double fr = h6 - (int)h6;
2680 double cr, cg, cb;
2681 switch (hi) {
2682 case 0: cr = 1; cg = fr; cb = 0; break;
2683 case 1: cr = 1-fr; cg = 1; cb = 0; break;
2684 case 2: cr = 0; cg = 1; cb = fr; break;
2685 case 3: cr = 0; cg = 1-fr; cb = 1; break;
2686 case 4: cr = fr; cg = 0; cb = 1; break;
2687 default: cr = 1; cg = 0; cb = 1-fr; break;
2688 }
2689 int y_top = s * stripe_h;
2690 int y_bot = screen->height - band + s * stripe_h;
2691 graph_ink(graph, (ACColor){(uint8_t)(cr*200), (uint8_t)(cg*200), (uint8_t)(cb*200), 80});
2692 graph_box(graph, 0, y_top, screen->width, stripe_h, 1);
2693 graph_box(graph, 0, y_bot, screen->width, stripe_h, 1);
2694 }
2695 }
2696
2697 // Bounce: title oscillates with a decaying sine
2698 double bounce_t = (double)af / 20.0;
2699 int bounce_y = (int)(6.0 * sin(bounce_t * 3.14159 * 2) * (1.0 - bounce_t));
2700
2701 // Title: boot_title with per-handle colors
2702 int tw = font_measure_matrix(boot_title, 3);
2703 int tx = (screen->width - tw) / 2;
2704 int ty = screen->height / 2 - 20 + bounce_y;
2705 for (int ci = 0; boot_title[ci]; ci++) {
2706 ACColor cc = title_char_color(ci, boot_frame, 255);
2707 graph_ink(graph, cc);
2708 char ch[2] = { boot_title[ci], 0 };
2709 tx = font_draw_matrix(graph, ch, tx, ty, 3);
2710 }
2711
2712 // Subtitle (slight counter-bounce)
2713 uint8_t sub = is_day ? 100 : 140;
2714 graph_ink(graph, (ACColor){sub, sub, sub, 255});
2715 int sw = font_measure_matrix("aesthetic.computer", 1);
2716 font_draw_matrix(graph, "aesthetic.computer",
2717 (screen->width - sw) / 2,
2718 screen->height / 2 + 10 - bounce_y / 3, 1);
2719
2720 // Status text — slides in from right
2721 if (status) {
2722 int slide = (int)((1.0 - bounce_t) * 40);
2723 if (slide < 0) slide = 0;
2724 uint8_t sc = is_day ? 80 : 120;
2725 graph_ink(graph, (ACColor){sc, sc, sc, (uint8_t)(255 * bounce_t)});
2726 int stw = font_measure_matrix(status, 1);
2727 font_draw_matrix(graph, status,
2728 (screen->width - stw) / 2 + slide,
2729 screen->height / 2 + 26, 1);
2730 }
2731
2732 // Spinning dot indicator (rotates each boot_frame)
2733 {
2734 int cx = screen->width / 2;
2735 int cy = screen->height / 2 + 42;
2736 double angle = boot_frame * 0.15;
2737 for (int d = 0; d < 4; d++) {
2738 double a = angle + d * 1.5708; // 90° apart
2739 int dx = (int)(6.0 * cos(a));
2740 int dy = (int)(3.0 * sin(a));
2741 uint8_t bright = (d == 0) ? 200 : 80;
2742 graph_ink(graph, (ACColor){bright, bright, bright, 255});
2743 graph_box(graph, cx + dx - 1, cy + dy - 1, 2, 2, 1);
2744 }
2745 }
2746
2747 ac_display_present(display, screen, pixel_scale);
2748 frame_sync_60fps(&anim_time);
2749 }
2750}
2751
2752// Audio recording tap callback (adapts rec_callback signature to recorder_submit_audio)
2753static void rec_audio_tap(const int16_t *pcm, int frames, void *userdata) {
2754 recorder_submit_audio((ACRecorder *)userdata, pcm, frames);
2755}
2756
2757// Tape recording state — the red "rolling" overlay in the top-left and
2758// the elapsed-time counter both read from these globals, which are set
2759// by the PrintScreen key handler when a tape starts.
2760static volatile int g_tape_recording = 0; // 1 while actively recording
2761static char g_tape_current_path[256] = {0}; // /mnt/tapes/<slug>.mp4
2762static time_t g_tape_start_sec = 0; // wall-clock start for elapsed display
2763
2764// Fire-and-forget tape upload: shells out a curl script that
2765// 1. requests a presigned PUT URL from /api/presigned-upload-url/mp4/<slug>/user
2766// 2. PUTs the MP4 directly to DO Spaces
2767// 3. POSTs /api/track-media with ext=mp4 + metadata
2768// Runs in a child process so the audio+render loop never blocks on network.
2769// The script reads config.json for {handle, token}.
2770static void tape_upload_async(const char *tape_path) {
2771 if (!tape_path || !tape_path[0]) return;
2772 // Sanity: only upload MP4 tapes, only if config.json has a token
2773 FILE *cf = fopen("/mnt/config.json", "r");
2774 if (!cf) { ac_log("[tape] upload skipped — no /mnt/config.json\n"); return; }
2775 char cbuf[4096] = {0};
2776 fread(cbuf, 1, sizeof(cbuf) - 1, cf);
2777 fclose(cf);
2778 char handle[64] = {0}, actoken[1024] = {0};
2779 parse_config_string(cbuf, "\"handle\"", handle, sizeof(handle));
2780 parse_config_string(cbuf, "\"token\"", actoken, sizeof(actoken));
2781 if (!handle[0] || !actoken[0]) {
2782 ac_log("[tape] upload skipped — no handle/token in config.json\n");
2783 return;
2784 }
2785 // Extract slug: "/mnt/tapes/2026.04.11.12.34.56.789.mp4" → "2026.04.11.12.34.56.789"
2786 const char *base = strrchr(tape_path, '/');
2787 base = base ? base + 1 : tape_path;
2788 char slug[128] = {0};
2789 strncpy(slug, base, sizeof(slug) - 1);
2790 char *dot = strrchr(slug, '.');
2791 if (dot) *dot = '\0';
2792 ac_log("[tape] uploading %s as slug %s for @%s\n", tape_path, slug, handle);
2793 // Shell out to curl. Runs in background (&) so we don't block.
2794 // Step 1: GET presigned URL. Step 2: PUT mp4. Step 3: POST track-media.
2795 char cmd[4096];
2796 snprintf(cmd, sizeof(cmd),
2797 "( "
2798 "URL=$(curl -fsSL -H 'Authorization: Bearer %s' "
2799 " 'https://aesthetic.computer/api/presigned-upload-url/mp4/%s/user' "
2800 " 2>/dev/null | jq -r '.uploadURL' 2>/dev/null); "
2801 "[ -z \"$URL\" ] && echo '[tape] no presigned url' && exit 1; "
2802 "curl -fsSL -X PUT -H 'Content-Type: video/mp4' -H 'x-amz-acl: public-read' "
2803 " --data-binary @'%s' \"$URL\" 2>/dev/null; "
2804 "curl -fsSL -X POST -H 'Authorization: Bearer %s' -H 'Content-Type: application/json' "
2805 " 'https://aesthetic.computer/api/track-tape' "
2806 " -d '{\"slug\":\"%s\",\"ext\":\"mp4\",\"metadata\":{\"totalDuration\":30,\"audioOnly\":false,\"device\":\"ac-native\"}}' "
2807 " >/dev/null 2>&1; "
2808 "echo '[tape] upload finished %s'; "
2809 ") >> /tmp/tape-upload.log 2>&1 &",
2810 actoken, slug, tape_path, actoken, slug, slug);
2811 system(cmd);
2812}
2813
2814int main(int argc, char *argv[]) {
2815 struct timespec boot_start;
2816 clock_gettime(CLOCK_MONOTONIC, &boot_start);
2817
2818 // Mount filesystems if PID 1 (direct DRM boot)
2819 if (getpid() == 1) {
2820 mount_minimal_fs();
2821 // Ensure PATH includes all standard binary directories
2822 setenv("PATH", "/bin:/sbin:/usr/bin:/usr/sbin", 1);
2823 // Keep curl/OpenSSL trust lookup stable in initramfs.
2824 setenv("SSL_CERT_FILE", "/etc/pki/tls/certs/ca-bundle.crt", 1);
2825 setenv("CURL_CA_BUNDLE", "/etc/pki/tls/certs/ca-bundle.crt", 1);
2826 setenv("SSL_CERT_DIR", "/etc/ssl/certs", 1);
2827 } else {
2828 // Under cage: filesystems already mounted by init script,
2829 // but still need USB log mount and PATH
2830 if (!getenv("PATH"))
2831 setenv("PATH", "/bin:/sbin:/usr/bin:/usr/sbin", 1);
2832 setenv("SSL_CERT_FILE", "/etc/pki/tls/certs/ca-bundle.crt", 1);
2833 setenv("CURL_CA_BUNDLE", "/etc/pki/tls/certs/ca-bundle.crt", 1);
2834 setenv("SSL_CERT_DIR", "/etc/ssl/certs", 1);
2835 // Log to USB directly (parent has /mnt mounted, we inherit it)
2836 // Use a separate file so we don't truncate parent's log
2837 logfile = fopen("/mnt/cage-child.log", "w");
2838 if (!logfile) logfile = fopen("/tmp/ac-native-cage.log", "w");
2839 if (logfile) {
2840 fprintf(logfile, "[ac-native] Running under cage (pid=%d)\n", getpid());
2841 fflush(logfile);
2842 }
2843 }
2844
2845 signal(SIGINT, signal_handler);
2846 signal(SIGTERM, signal_handler);
2847 signal(SIGSEGV, signal_handler);
2848 signal(SIGBUS, signal_handler);
2849 signal(SIGABRT, signal_handler);
2850 signal(SIGFPE, signal_handler);
2851 // Ignore SIGPIPE so writes to a closed socket (WebSocket, UDP peer,
2852 // session-server log uploads) return EPIPE instead of killing the
2853 // process. Without this, any server-side close during an in-flight
2854 // SSL_write kills ac-native with exit=141 (observed in crash logs).
2855 signal(SIGPIPE, SIG_IGN);
2856#ifdef USE_WAYLAND
2857 // Under Wayland: no DRM handoff signals needed (browser is sibling client)
2858 if (!getenv("WAYLAND_DISPLAY"))
2859#endif
2860 {
2861 signal(SIGUSR1, sigusr_handler);
2862 signal(SIGUSR2, sigusr_handler);
2863 signal(SIGTERM, sigterm_handler);
2864 }
2865
2866 // Determine piece path (ignore kernel cmdline args passed to init)
2867 const char *piece_path = "/piece.mjs";
2868 int headless = 0;
2869 for (int i = 1; i < argc; i++) {
2870 if (strcmp(argv[i], "--headless") == 0) headless = 1;
2871 else if (argv[i][0] == '/' || argv[i][0] == '.') piece_path = argv[i];
2872 }
2873
2874 ACDisplay *display = NULL;
2875 extern void *g_display; // expose to js-bindings for browser DRM handoff
2876 ACFramebuffer *screen = NULL;
2877 ACInput *input = NULL;
2878 int pixel_scale = 3; // Default: computed below to target ~300px wide
2879#ifdef USE_WAYLAND
2880 ACWaylandDisplay *wayland_display = NULL;
2881 int is_wayland = 0;
2882#endif
2883
2884 if (!headless) {
2885#ifdef USE_WAYLAND
2886 // Prefer Wayland if running under cage compositor
2887 ac_log("[ac-native] WAYLAND_DISPLAY=%s XDG_RUNTIME_DIR=%s\n",
2888 getenv("WAYLAND_DISPLAY") ? getenv("WAYLAND_DISPLAY") : "(null)",
2889 getenv("XDG_RUNTIME_DIR") ? getenv("XDG_RUNTIME_DIR") : "(null)");
2890 if (getenv("WAYLAND_DISPLAY")) {
2891 wayland_display = wayland_display_init();
2892 if (wayland_display) {
2893 is_wayland = 1;
2894 g_wayland_display = wayland_display;
2895 // Create a minimal ACDisplay for code that needs width/height
2896 display = calloc(1, sizeof(ACDisplay));
2897 display->width = wayland_display->width;
2898 display->height = wayland_display->height;
2899 g_display = display;
2900 fprintf(stderr, "[ac-native] Wayland display: %dx%d\n",
2901 display->width, display->height);
2902 } else {
2903 ac_log("[ac-native] Wayland init failed — exiting (cage will restart or fallback)\n");
2904 return 1; // exit so cage exits and init falls through to DRM
2905 }
2906 }
2907 if (!is_wayland)
2908#endif
2909 {
2910 display = drm_init();
2911 g_display = display;
2912 }
2913 if (!display) {
2914 fprintf(stderr, "[ac-native] FATAL: No display\n");
2915 // Dump device diagnostics for debugging
2916 fprintf(stderr, "[ac-native] /dev/dri contents:\n");
2917 system("ls -la /dev/dri/ 2>&1 || echo ' /dev/dri does not exist'");
2918 fprintf(stderr, "[ac-native] /dev/fb contents:\n");
2919 system("ls -la /dev/fb* 2>&1 || echo ' no framebuffer devices'");
2920 fprintf(stderr, "[ac-native] PCI GPU devices:\n");
2921 system("cat /sys/bus/pci/devices/*/class 2>/dev/null | grep -c 0x03 || echo ' 0'");
2922 system("for d in /sys/bus/pci/devices/*; do c=$(cat $d/class 2>/dev/null); [ \"${c#0x03}\" != \"$c\" ] && echo \" $(basename $d) $c $(cat $d/vendor $d/device 2>/dev/null)\"; done");
2923 fprintf(stderr, "[ac-native] dmesg DRM/i915:\n");
2924 system("dmesg 2>/dev/null | grep -i 'drm\\|i915\\|display\\|error' | tail -20");
2925 // Write diagnostics to USB too
2926 system("cat /proc/cmdline >> /mnt/ac-init.log 2>/dev/null");
2927 system("dmesg 2>/dev/null | grep -i 'drm\\|i915\\|display' >> /mnt/ac-init.log 2>/dev/null");
2928 sleep(30); ac_poweroff();
2929 return 1;
2930 }
2931
2932 // Target ~300px wide — just divide and let fb_copy_scaled handle the remainder
2933 {
2934 int target = display->width / 300;
2935 if (target < 1) target = 1;
2936 if (target > 16) target = 16;
2937 // Prefer clean divisors, but accept any scale
2938 pixel_scale = target;
2939 for (int delta = 0; delta <= 3; delta++) {
2940 int s = target + delta;
2941 if (s >= 1 && s <= 16 && display->width % s == 0 && display->height % s == 0) {
2942 pixel_scale = s; break;
2943 }
2944 s = target - delta;
2945 if (s >= 1 && display->width % s == 0 && display->height % s == 0) {
2946 pixel_scale = s; break;
2947 }
2948 }
2949 }
2950 ac_log("pixel_scale=%d (display %dx%d -> screen %dx%d)\n",
2951 pixel_scale, display->width, display->height,
2952 display->width / pixel_scale, display->height / pixel_scale);
2953 screen = fb_create(display->width / pixel_scale, display->height / pixel_scale);
2954 if (!screen) {
2955#ifdef USE_WAYLAND
2956 if (is_wayland) wayland_display_destroy(wayland_display);
2957 else
2958#endif
2959 drm_destroy(display);
2960 return 1;
2961 }
2962 } else {
2963 screen = fb_create(320, 200);
2964 }
2965
2966 // Init graphics + font
2967 ACGraph graph;
2968 graph_init(&graph, screen);
2969 if (display) graph_init_gpu(&graph, display);
2970 font_init();
2971
2972 // Cursor overlay buffer — drawn separately so KidLisp effects don't smear it
2973 ACFramebuffer *cursor_fb = fb_create(screen->width, screen->height);
2974
2975 // Mount USB log early so boot animation can detect USB boot
2976#ifdef USE_WAYLAND
2977 if (!is_wayland) // Under cage: parent already has USB mounted at /mnt
2978#endif
2979 try_mount_log();
2980
2981 // Read boot visuals (handle + optional per-char colors) from /mnt/config.json
2982 load_boot_visual_config();
2983
2984 // Init audio + TTS early (needed for boot animation speech)
2985 ACAudio *audio = audio_init();
2986 ACTts *tts = NULL;
2987 ACWifi *wifi = NULL;
2988 ACSecondaryDisplay *hdmi = NULL;
2989 char bl_path[128] = "";
2990 int bl_max = 0;
2991
2992#ifdef USE_WAYLAND
2993 if (is_wayland) {
2994 // ── Cage session ──
2995 ac_log("[ac-native] Wayland session\n");
2996
2997 // Input (Wayland path)
2998 if (!headless)
2999 input = input_init_wayland(wayland_display, display->width, display->height, pixel_scale);
3000
3001 // Boot animation with install prompt (same as DRM path)
3002 audio_boot_beep(audio);
3003 tts = tts_init(audio);
3004 int want_install = 0;
3005 if (!headless) {
3006 want_install = draw_startup_fade(&graph, screen, display, tts, audio, pixel_scale);
3007 if (!want_install)
3008 draw_boot_status(&graph, screen, display, "starting...", pixel_scale);
3009 }
3010 if (want_install) {
3011 int install_ok = auto_install_to_hd(&graph, screen, display, pixel_scale);
3012 if (display) {
3013 int should_reboot = draw_install_reboot_prompt(&graph, screen, display, input, tts, audio, install_ok, pixel_scale);
3014 if (should_reboot) {
3015 if (tts) tts_wait(tts); // let TTS finish before reboot
3016 // Use the shared ac_reboot() path — it tries reboot(2)
3017 // syscall first (reboot=efi,cold cmdline steers this
3018 // to UEFI ResetSystem which is the only consistently
3019 // working path on coreboot Chromebooks), falls back
3020 // to systemctl / busybox reboot -f, then finally
3021 // _exit(2) so init.sh sees "reboot requested" and
3022 // re-enters its own multi-tiered reboot loop
3023 // (reboot -f → sysrq-b → halt -f → sysrq-c panic).
3024 // The old path here did `_exit(0)` which triggered
3025 // init.sh's POWEROFF branch, so a failing reboot(2)
3026 // would hang waiting to power off instead of retrying.
3027 ac_reboot();
3028 }
3029 }
3030 }
3031
3032 // WiFi is already running from parent — just connect to it
3033 if (!wifi_disabled)
3034 wifi = wifi_init();
3035 } else
3036#endif
3037 {
3038 // ── DRM boot: full boot sequence ──
3039 // Load persisted sample (overrides default seed if file exists)
3040 if (audio && audio_sample_load(audio, "/mnt/ac-sample.raw") > 0) {
3041 ac_log("[audio] loaded persisted sample (%d samples)\n", audio->sample_len);
3042 }
3043 audio_boot_beep(audio);
3044 tts = tts_init(audio);
3045 // Skip tts_precache here — defer to background after piece loads
3046
3047 // Startup fade animation (black → white, hides kernel text)
3048 ac_log("[ac-native] pre-fade: display=%p screen=%p w=%d h=%d\n",
3049 (void*)display, (void*)screen,
3050 display ? display->width : -1, display ? display->height : -1);
3051 int want_install = 0;
3052 if (!headless) {
3053 want_install = draw_startup_fade(&graph, screen, display, tts, audio, pixel_scale);
3054 if (!want_install)
3055 draw_boot_status(&graph, screen, display, "starting input...", pixel_scale);
3056 }
3057
3058 // Init input (DRM path)
3059 if (!headless)
3060 input = input_init(display->width, display->height, pixel_scale);
3061
3062 // Find backlight path
3063 {
3064 DIR *bldir = opendir("/sys/class/backlight");
3065 if (bldir) {
3066 struct dirent *ent;
3067 while ((ent = readdir(bldir)) && !bl_path[0]) {
3068 if (ent->d_name[0] == '.') continue;
3069 char tmp[160];
3070 snprintf(tmp, sizeof(tmp), "/sys/class/backlight/%s/max_brightness", ent->d_name);
3071 FILE *f = fopen(tmp, "r");
3072 if (f) {
3073 if (fscanf(f, "%d", &bl_max) == 1 && bl_max > 0)
3074 snprintf(bl_path, sizeof(bl_path), "/sys/class/backlight/%s", ent->d_name);
3075 fclose(f);
3076 }
3077 }
3078 closedir(bldir);
3079 }
3080 }
3081
3082 // Install kernel to internal drive (only if user pressed W during boot)
3083 if (want_install) {
3084 int install_ok = auto_install_to_hd(&graph, screen, display, pixel_scale);
3085 int should_reboot = 1;
3086 if (!headless && display)
3087 should_reboot = draw_install_reboot_prompt(&graph, screen, display, input, tts, audio, install_ok, pixel_scale);
3088 if (install_ok) should_reboot = 1;
3089 if (should_reboot) {
3090 if (tts) { tts_speak(tts, "rebooting"); tts_wait(tts); }
3091 audio_shutdown_sound(audio);
3092 usleep(500000);
3093 sync();
3094 ac_reboot();
3095 while (running) sleep(1);
3096 }
3097 }
3098
3099 // Init WiFi
3100 if (!wifi_disabled) {
3101 if (!headless && display)
3102 draw_boot_status(&graph, screen, display, "starting wifi...", pixel_scale);
3103 wifi = wifi_init();
3104 // Don't autoconnect from C — notepat.mjs handles scanning
3105 // at 30s intervals (reduced from 2s to avoid 65ms frame drops).
3106 } else {
3107 if (!headless && display)
3108 draw_boot_status(&graph, screen, display, "wifi disabled", pixel_scale);
3109 ac_log("[ac-native] WiFi disabled by config\n");
3110 }
3111
3112 // Init secondary HDMI display (if connected)
3113 if (display && !display->is_fbdev) {
3114 hdmi = drm_init_secondary(display);
3115 if (hdmi) ac_log("[ac-native] HDMI secondary: %dx%d\n", hdmi->width, hdmi->height);
3116 }
3117 }
3118
3119 // Init JS
3120 ACRuntime *rt = js_init(&graph, input, audio, wifi, tts);
3121 if (!rt) {
3122 fprintf(stderr, "[ac-native] FATAL: Cannot init JS\n");
3123 wifi_destroy(wifi);
3124 audio_destroy(audio);
3125 fb_destroy(screen);
3126 if (display) drm_destroy(display);
3127 return 1;
3128 }
3129 rt->hdmi = hdmi;
3130
3131 // Read user config from EFI partition (/mnt/config.json)
3132 {
3133 FILE *cfg = fopen("/mnt/config.json", "r");
3134 if (cfg) {
3135 char buf[32768] = {0};
3136 size_t n = fread(buf, 1, sizeof(buf) - 1, cfg);
3137 buf[n] = '\0';
3138 fclose(cfg);
3139
3140 // Skip identity block marker line if present
3141 char *json = buf;
3142 if (strncmp(buf, "AC_IDENTITY_BLOCK_V1", 20) == 0) {
3143 char *nl = strchr(buf, '\n');
3144 if (nl) json = nl + 1;
3145 }
3146
3147 parse_config_string(json, "\"handle\"", rt->handle, sizeof(rt->handle));
3148 parse_config_string(json, "\"piece\"", rt->piece, sizeof(rt->piece));
3149 ac_log("[ac-native] Config: handle=%s piece=%s\n",
3150 rt->handle[0] ? rt->handle : "(none)",
3151 rt->piece[0] ? rt->piece : "(none)");
3152
3153 // Check voice config (voice: "off" disables keystroke TTS)
3154 char voice_cfg[16] = {0};
3155 if (parse_config_string(json, "\"voice\"", voice_cfg, sizeof(voice_cfg))) {
3156 voice_off = (strcmp(voice_cfg, "off") == 0);
3157 ac_log("[config] voice: %s\n", voice_cfg);
3158 }
3159
3160 // (Tokens are baked early in load_boot_visual_config() so the
3161 // boot-fade badges can render — see notes there.)
3162 }
3163 }
3164
3165 // Check for previous crash (written by signal handler)
3166 {
3167 FILE *cf = fopen("/mnt/crash.json", "r");
3168 if (cf) {
3169 char cbuf[512] = {0};
3170 fread(cbuf, 1, sizeof(cbuf) - 1, cf);
3171 fclose(cf);
3172 char sig[32] = {0};
3173 parse_config_string(cbuf, "\"signal\"", sig, sizeof(sig));
3174 if (sig[0]) {
3175 rt->crash_active = 1;
3176 rt->crash_frame = 0;
3177 snprintf(rt->crash_msg, sizeof(rt->crash_msg),
3178 "previous crash: %s", sig);
3179 ac_log("[ac-native] Previous crash detected: %s\n", sig);
3180 }
3181 // Remove crash file so we don't re-show it
3182 remove("/mnt/crash.json");
3183 }
3184 }
3185
3186 // Override piece_path from config.json boot piece (pieces bundled at /pieces/)
3187 // Resolve aliases: "claude" and "cc" → terminal (with param "claude")
3188 if (strcmp(rt->piece, "claude") == 0 || strcmp(rt->piece, "cc") == 0) {
3189 strcpy(rt->piece, "terminal");
3190 // Set initial jump params so terminal.mjs gets "claude" as param
3191 strcpy(rt->jump_params[0], "claude");
3192 rt->jump_param_count = 1;
3193 }
3194 char piece_path_buf[256];
3195 if (rt->piece[0]) {
3196 snprintf(piece_path_buf, sizeof(piece_path_buf), "/pieces/%s.mjs", rt->piece);
3197 if (access(piece_path_buf, R_OK) == 0) {
3198 piece_path = piece_path_buf;
3199 ac_log("[ac-native] Boot piece from config: %s\n", piece_path);
3200 } else {
3201 ac_log("[ac-native] Config piece '%s' not found, falling back to %s\n",
3202 rt->piece, piece_path);
3203 }
3204 }
3205
3206 // Load piece
3207 if (js_load_piece(rt, piece_path) < 0) {
3208 fprintf(stderr, "[ac-native] FATAL: Cannot load %s\n", piece_path);
3209 if (!headless) {
3210 graph_wipe(&graph, (ACColor){200, 0, 0, 255});
3211 graph_ink(&graph, (ACColor){255, 255, 255, 255});
3212 char msg[256];
3213 snprintf(msg, sizeof(msg), "Cannot load: %s", piece_path);
3214 font_draw(&graph, msg, 20, 20, 2);
3215 ac_display_present(display, screen, pixel_scale);
3216 sleep(10);
3217 }
3218 js_destroy(rt);
3219 audio_destroy(audio);
3220 fb_destroy(screen);
3221 if (display) drm_destroy(display);
3222 if (logfile) { fclose(logfile); logfile = NULL; }
3223 sync();
3224 ac_poweroff();
3225 return 1;
3226 }
3227
3228 struct timespec boot_end;
3229 clock_gettime(CLOCK_MONOTONIC, &boot_end);
3230 double boot_ms = (boot_end.tv_sec - boot_start.tv_sec) * 1000.0 +
3231 (boot_end.tv_nsec - boot_start.tv_nsec) / 1000000.0;
3232 ac_log("[ac-native] Booted in %.1fms\n", boot_ms);
3233
3234 // Log audio and backlight status
3235 ac_log("[ac-native] Audio: %s\n", audio && audio->pcm ? "OK" : "NO PCM");
3236 ac_log("[ac-native] Backlight: %s (max=%d)\n", bl_path[0] ? bl_path : "none", bl_max);
3237 ac_log("[ac-native] Input devices: %d\n", input ? input->count : 0);
3238 // Log each input device name for debugging keyboard issues
3239 if (input) {
3240 for (int i = 0; i < input->count; i++) {
3241 char dname[256] = "?";
3242 ioctl(input->fds[i], EVIOCGNAME(sizeof(dname)), dname);
3243 ac_log("[input] dev%d: %s\n", i, dname);
3244 }
3245 }
3246 ac_log("[ac-native] NuPhy: has_analog=%d hidraw=%d\n",
3247 input ? input->has_analog : -1, input ? input->hidraw_count : -1);
3248 ac_log("[ac-native] JS: boot=%d paint=%d act=%d sim=%d\n",
3249 JS_IsFunction(rt->ctx, rt->boot_fn),
3250 JS_IsFunction(rt->ctx, rt->paint_fn),
3251 JS_IsFunction(rt->ctx, rt->act_fn),
3252 JS_IsFunction(rt->ctx, rt->sim_fn));
3253 if (logfile) { fflush(logfile); }
3254
3255 // Prewarm audio engine so first keypress has zero latency
3256 audio_prewarm(audio);
3257
3258 // Drain any queued input events from boot animation (prevents first-key stick)
3259 input_poll(input);
3260 input->event_count = 0;
3261
3262 // Call boot() immediately — melody plays over the already-running piece
3263 js_call_boot(rt);
3264
3265 // Precache TTS in background — user won't type for several seconds
3266 pthread_t tts_precache_thread;
3267 int tts_precache_spawned = 0;
3268 if (tts) {
3269 tts_precache_spawned = (pthread_create(&tts_precache_thread, NULL,
3270 (void *(*)(void *))tts_precache, tts) == 0);
3271 }
3272
3273 // Let TTS finish and play ready melody (non-blocking to piece)
3274 if (tts) {
3275 tts_wait(tts);
3276 usleep(300000); // Let TTS ring buffer drain
3277 }
3278 audio_ready_melody(audio);
3279
3280 // Join TTS precache thread before entering main loop
3281 if (tts_precache_spawned)
3282 pthread_join(tts_precache_thread, NULL);
3283
3284 // Init performance logger
3285 perf_init();
3286
3287 // ── Graceful DRM → cage transition ──
3288 // After boot completes in DRM mode, try to launch cage compositor
3289 // and re-exec ac-native under it. This gives us a Wayland session
3290 // for browser popups (Firefox, OAuth) while keeping fast DRM boot.
3291#ifdef USE_WAYLAND
3292 if (!is_wayland && !headless && getpid() == 1 &&
3293 access("/bin/cage", X_OK) == 0 && access("/dev/dri/card0", F_OK) == 0) {
3294 ac_log("[cage-transition] Starting cage compositor...\n");
3295
3296 // Close audio so cage child can open ALSA
3297 audio_destroy(audio);
3298 audio = NULL;
3299
3300 // Release DRM master so cage can take it
3301 drm_release_master(display);
3302 ac_log("[cage-transition] Released DRM master\n");
3303
3304 pid_t cage_pid = fork();
3305 if (cage_pid == 0) {
3306 // === CHILD: start seatd + cage + ac-native ===
3307 setenv("HOME", "/tmp", 1);
3308 setenv("XDG_RUNTIME_DIR", "/tmp/xdg", 1);
3309 setenv("WLR_RENDERER", "pixman", 1);
3310 setenv("WLR_BACKENDS", "drm", 1);
3311 setenv("LIBGL_ALWAYS_SOFTWARE", "1", 1);
3312 setenv("WLR_LIBINPUT_NO_DEVICES", "1", 1);
3313 mkdir("/tmp/xdg", 0700);
3314
3315 // Log /dev/input state for debugging (write to USB)
3316 {
3317 int dfd = open("/mnt/cage-diag.log", O_WRONLY | O_CREAT | O_TRUNC, 0644);
3318 if (dfd >= 0) {
3319 char dbuf[1024];
3320 int dlen = 0;
3321 dlen += snprintf(dbuf + dlen, sizeof(dbuf) - dlen, "pid=%d\n", getpid());
3322 DIR *d = opendir("/dev/input");
3323 if (d) {
3324 struct dirent *e;
3325 dlen += snprintf(dbuf + dlen, sizeof(dbuf) - dlen, "dev-input:");
3326 while ((e = readdir(d))) {
3327 if (e->d_name[0] != '.')
3328 dlen += snprintf(dbuf + dlen, sizeof(dbuf) - dlen, " %s", e->d_name);
3329 }
3330 dlen += snprintf(dbuf + dlen, sizeof(dbuf) - dlen, "\n");
3331 closedir(d);
3332 } else {
3333 dlen += snprintf(dbuf + dlen, sizeof(dbuf) - dlen,
3334 "dev-input: MISSING errno=%d\n", errno);
3335 }
3336 // Also list /dev/dri
3337 d = opendir("/dev/dri");
3338 if (d) {
3339 struct dirent *e;
3340 dlen += snprintf(dbuf + dlen, sizeof(dbuf) - dlen, "dev-dri:");
3341 while ((e = readdir(d))) {
3342 if (e->d_name[0] != '.')
3343 dlen += snprintf(dbuf + dlen, sizeof(dbuf) - dlen, " %s", e->d_name);
3344 }
3345 dlen += snprintf(dbuf + dlen, sizeof(dbuf) - dlen, "\n");
3346 closedir(d);
3347 }
3348 write(dfd, dbuf, dlen);
3349 fsync(dfd);
3350 close(dfd);
3351 }
3352 }
3353
3354 // Ensure /run exists (may be missing if mount_minimal_fs re-mounted rootfs)
3355 mkdir("/run", 0755);
3356 mount("tmpfs", "/run", "tmpfs", 0, NULL);
3357
3358 // Start seatd
3359 system("seatd -g root > /tmp/seatd.log 2>&1 &");
3360 for (int si = 0; si < 30; si++) {
3361 usleep(100000);
3362 if (access("/run/seatd.sock", F_OK) == 0) break;
3363 }
3364
3365 if (access("/run/seatd.sock", F_OK) != 0) {
3366 FILE *ef = fopen("/tmp/cage-stderr.log", "w");
3367 if (ef) {
3368 fprintf(ef, "seatd failed to start after 3s\n");
3369 fprintf(ef, "run-dir-exists=%d run-writable=%d\n",
3370 access("/run", F_OK) == 0, access("/run", W_OK) == 0);
3371 // Dump seatd log
3372 FILE *sl = fopen("/tmp/seatd.log", "r");
3373 if (sl) {
3374 char sbuf[256];
3375 fprintf(ef, "--- seatd.log ---\n");
3376 while (fgets(sbuf, sizeof(sbuf), sl)) fputs(sbuf, ef);
3377 fclose(sl);
3378 }
3379 fclose(ef);
3380 }
3381 _exit(1);
3382 }
3383
3384 // Redirect stderr to file so parent can read cage errors
3385 FILE *cage_log = fopen("/tmp/cage-stderr.log", "w");
3386 if (cage_log) {
3387 dup2(fileno(cage_log), STDERR_FILENO);
3388 fclose(cage_log);
3389 }
3390 fprintf(stderr, "[cage-transition] seatd ok, launching cage...\n");
3391
3392 execlp("cage", "cage", "-s", "--", "/ac-native", "/piece.mjs", NULL);
3393 fprintf(stderr, "[cage-transition] exec cage failed: %s\n", strerror(errno));
3394 _exit(127);
3395 }
3396
3397 if (cage_pid > 0) {
3398 // === PARENT: wait for cage session to end ===
3399 ac_log("[cage-transition] cage pid=%d, waiting...\n", cage_pid);
3400 int status = 0;
3401 waitpid(cage_pid, &status, 0);
3402 int rc = WIFEXITED(status) ? WEXITSTATUS(status) : -1;
3403 ac_log("[cage-transition] cage exited: %d\n", rc);
3404
3405 // Copy cage stderr to USB log
3406 FILE *cage_err = fopen("/tmp/cage-stderr.log", "r");
3407 if (cage_err) {
3408 char buf[256];
3409 while (fgets(buf, sizeof(buf), cage_err))
3410 ac_log("[cage-err] %s", buf);
3411 fclose(cage_err);
3412 }
3413 // Copy child ac-native log to USB log
3414 FILE *cage_child = fopen("/tmp/ac-native-cage.log", "r");
3415 if (cage_child) {
3416 char buf[256];
3417 while (fgets(buf, sizeof(buf), cage_child))
3418 ac_log("[cage-child] %s", buf);
3419 fclose(cage_child);
3420 }
3421
3422 // Cleanup seatd
3423 system("killall seatd 2>/dev/null");
3424 } else {
3425 ac_log("[cage-transition] fork failed: %s\n", strerror(errno));
3426 }
3427
3428 // Check if cage child requested reboot/poweroff
3429 if (reboot_requested) {
3430 ac_log("[cage-transition] Reboot requested by cage child\n");
3431 ac_log_flush();
3432 ac_reboot();
3433 }
3434 if (poweroff_requested) {
3435 ac_log("[cage-transition] Poweroff requested by cage child\n");
3436 ac_log_flush();
3437 ac_poweroff();
3438 }
3439
3440 // Reclaim DRM and continue in DRM mode (fallback)
3441 drm_acquire_master(display);
3442 audio = audio_init();
3443 ac_log("[cage-transition] Reclaimed DRM, continuing in DRM mode\n");
3444 }
3445#endif
3446
3447 // Video recorder (F9 to toggle) — declared before headless branch so cleanup is in scope
3448 ACRecorder *recorder = NULL;
3449
3450 if (headless) {
3451 for (int i = 0; i < 10 && running; i++) {
3452 js_call_sim(rt);
3453 js_call_paint(rt);
3454 }
3455 } else {
3456#ifdef HAVE_AVCODEC
3457 if (screen && audio) {
3458 recorder = recorder_create(screen->width, screen->height, 60,
3459 audio->actual_rate ? audio->actual_rate : 192000);
3460 if (recorder) {
3461 ac_log("[ac-native] recorder ready (%dx%d)\n", screen->width, screen->height);
3462 // Expose to JS via sound.tape.* bindings
3463 if (rt) rt->recorder = recorder;
3464 }
3465 }
3466#endif
3467
3468 // Main loop
3469 struct timespec frame_time;
3470 clock_gettime(CLOCK_MONOTONIC, &frame_time);
3471
3472 int main_frame = 0;
3473 while (running) {
3474#ifdef USE_WAYLAND
3475 // Under Wayland: input_poll handles all Wayland event dispatch
3476 // (reading from socket + firing listeners in correct order)
3477 if (is_wayland) {
3478 // nothing — input_poll does full dispatch
3479 } else
3480#endif
3481 {
3482 // DRM handoff: xdg-open sends SIGUSR1 to release, SIGUSR2 to reclaim
3483 if (drm_handoff_release && display) {
3484 drm_handoff_release = 0;
3485
3486 char browser_url[2048] = "";
3487 FILE *uf = fopen("/tmp/.browser-url", "r");
3488 if (uf) {
3489 if (fgets(browser_url, sizeof(browser_url), uf))
3490 browser_url[strcspn(browser_url, "\n")] = 0;
3491 fclose(uf);
3492 unlink("/tmp/.browser-url");
3493 }
3494
3495 if (browser_url[0]) {
3496 ac_log("[browser] URL: %s", browser_url);
3497 drm_release_master(display);
3498 ac_log("[browser] Released DRM master");
3499
3500 // Fork a child for the entire browser session.
3501 // Child sets env, starts seatd+cage+firefox, then exits.
3502 // Parent waits, then reclaims DRM. If child crashes, parent survives.
3503 pid_t bpid = fork();
3504 if (bpid == 0) {
3505 // === CHILD PROCESS ===
3506 mkdir("/tmp/xdg-browser", 0700);
3507 mkdir("/tmp/.mozilla", 0755);
3508 mkdir("/run", 0755);
3509
3510 setenv("HOME", "/tmp", 1);
3511 setenv("XDG_RUNTIME_DIR", "/tmp/xdg-browser", 1);
3512 setenv("WLR_BACKENDS", "drm", 1);
3513 setenv("WLR_RENDERER", "pixman", 1);
3514 setenv("LD_LIBRARY_PATH", "/lib64:/opt/firefox", 1);
3515 setenv("LIBGL_ALWAYS_SOFTWARE", "1", 1);
3516 setenv("MOZ_ENABLE_WAYLAND", "1", 1);
3517 setenv("GDK_BACKEND", "wayland", 1);
3518 setenv("MOZ_APP_LAUNCHER", "/opt/firefox/firefox", 1);
3519 setenv("GRE_HOME", "/opt/firefox", 1);
3520 setenv("DBUS_SESSION_BUS_ADDRESS", "disabled:", 1);
3521 setenv("MOZ_DBUS_REMOTE", "0", 1);
3522 setenv("MOZ_DISABLE_CONTENT_SANDBOX", "1", 1);
3523
3524 // Ensure /etc/group exists for seatd
3525 {
3526 FILE *grp = fopen("/etc/group", "a");
3527 if (grp) {
3528 fseek(grp, 0, SEEK_END);
3529 if (ftell(grp) == 0) fprintf(grp, "root:x:0:\n");
3530 fclose(grp);
3531 }
3532 }
3533
3534 // Start seatd, wait for socket
3535 system("seatd -g root > /tmp/seatd.log 2>&1 &");
3536 for (int si = 0; si < 15; si++) {
3537 usleep(200000);
3538 if (access("/run/seatd.sock", F_OK) == 0) break;
3539 }
3540
3541 // Run cage+firefox (blocks until browser exits)
3542 char cmd[4096];
3543 snprintf(cmd, sizeof(cmd),
3544 "cd /opt/firefox && cage -s -- ./firefox --kiosk --no-remote "
3545 "--new-instance '%s' > /tmp/cage-out.log 2>&1", browser_url);
3546 int rc = system(cmd);
3547
3548 // Cleanup
3549 system("killall seatd 2>/dev/null");
3550 // Copy logs to USB
3551 FILE *lf = fopen("/tmp/cage-out.log", "r");
3552 if (lf) {
3553 FILE *mf = fopen("/mnt/cage.log", "w");
3554 if (mf) {
3555 char buf[512];
3556 while (fgets(buf, sizeof(buf), lf)) fputs(buf, mf);
3557 fclose(mf);
3558 sync();
3559 }
3560 fclose(lf);
3561 }
3562 _exit(rc);
3563 }
3564
3565 // === PARENT PROCESS ===
3566 if (bpid > 0) {
3567 int bstatus = 0;
3568 ac_log("[browser] child pid=%d, waiting...", bpid);
3569 waitpid(bpid, &bstatus, 0);
3570 if (WIFEXITED(bstatus))
3571 ac_log("[browser] child exited: %d", WEXITSTATUS(bstatus));
3572 else if (WIFSIGNALED(bstatus))
3573 ac_log("[browser] child killed by signal %d", WTERMSIG(bstatus));
3574 } else {
3575 ac_log("[browser] fork failed: %s", strerror(errno));
3576 }
3577
3578 // Always reclaim DRM, even if child crashed
3579 drm_acquire_master(display);
3580 ac_log("[browser] Reclaimed DRM master");
3581 } else {
3582 ac_log("[browser] SIGUSR1 but no URL in /tmp/.browser-url");
3583 }
3584 }
3585 }
3586
3587 input_poll(input);
3588 main_frame++;
3589 // Check for device token API response (from wifi-connect fetch)
3590 if (main_frame % 300 == 0) {
3591 FILE *rf = fopen("/tmp/claude-api-resp.json", "r");
3592 if (rf) {
3593 char rbuf[2048] = {0};
3594 fread(rbuf, 1, sizeof(rbuf) - 1, rf);
3595 fclose(rf);
3596 unlink("/tmp/claude-api-resp.json");
3597 // Extract Claude token: {"token":"sk-ant-...","githubPat":"ghp_..."}
3598 const char *tk = strstr(rbuf, "\"token\"");
3599 if (tk) {
3600 const char *tv = strchr(tk + 7, ':');
3601 if (tv) {
3602 tv++; while (*tv == ' ' || *tv == '"') tv++;
3603 const char *te = strchr(tv, '"');
3604 if (te && te > tv && (te - tv) > 10) {
3605 FILE *tf = fopen("/claude-token", "w");
3606 if (tf) { fwrite(tv, 1, te - tv, tf); fclose(tf); }
3607 ac_log("[tokens] claude token from API (%d bytes)\n", (int)(te - tv));
3608 }
3609 }
3610 }
3611 // Extract GitHub PAT
3612 const char *gk = strstr(rbuf, "\"githubPat\"");
3613 if (gk) {
3614 const char *gv = strchr(gk + 11, ':');
3615 if (gv) {
3616 gv++; while (*gv == ' ' || *gv == '"') gv++;
3617 const char *ge = strchr(gv, '"');
3618 if (ge && ge > gv && (ge - gv) > 5) {
3619 FILE *gf = fopen("/github-pat", "w");
3620 if (gf) { fwrite(gv, 1, ge - gv, gf); fclose(gf); }
3621 ac_log("[tokens] github pat from API (%d bytes)\n", (int)(ge - gv));
3622 }
3623 }
3624 }
3625 }
3626 }
3627 // Copy tmpfs debug logs to USB periodically (every 5 sec)
3628 if (logfile && main_frame % 300 == 0) {
3629 FILE *xf = fopen("/tmp/xdg-open.log", "r");
3630 if (xf) {
3631 char xbuf[512];
3632 while (fgets(xbuf, sizeof(xbuf), xf))
3633 ac_log("[xdg] %s", xbuf);
3634 fclose(xf);
3635 unlink("/tmp/xdg-open.log"); // only report once
3636 }
3637 FILE *ff = fopen("/tmp/firefox-debug.log", "r");
3638 if (ff) {
3639 char fbuf[512];
3640 while (fgets(fbuf, sizeof(fbuf), ff))
3641 ac_log("[firefox] %s", fbuf);
3642 fclose(ff);
3643 unlink("/tmp/firefox-debug.log");
3644 }
3645 }
3646 // Log input state periodically (every 5 sec)
3647 if (logfile && main_frame % 300 == 1) {
3648 int analog_active = 0;
3649 for (int k = 0; k < MAX_ANALOG_KEYS; k++)
3650 if (input->analog_keys[k].active) analog_active++;
3651 ac_log("[input] frame=%d events=%d has_analog=%d hidraw=%d analog_active=%d evdev=%d\n",
3652 main_frame, input->event_count, input->has_analog,
3653 input->hidraw_count, analog_active, input->count);
3654 }
3655 // Log key events to USB + instant TTS for key presses
3656 for (int i = 0; i < input->event_count; i++) {
3657 if (input->events[i].type == AC_EVENT_KEYBOARD_DOWN) {
3658 if (logfile) {
3659 ac_log("[key] DOWN code=%d name=%s pressure=%.3f\n",
3660 input->events[i].key_code,
3661 input->events[i].key_name,
3662 input->events[i].pressure);
3663 }
3664 // Instant TTS: play cached sound immediately (bypasses JS frame delay)
3665 // Only when running prompt.mjs (other pieces should not voice keystrokes)
3666 if (tts && !voice_off && strcmp(rt->piece, "prompt") == 0) {
3667 const char *kn = input->events[i].key_name;
3668 if (kn && kn[0] && kn[1] == 0) {
3669 // Single printable character
3670 tts_speak_cached(tts, kn);
3671 } else if (kn && strcmp(kn, "space") == 0) {
3672 tts_speak_cached(tts, "space");
3673 } else if (kn && strcmp(kn, "backspace") == 0) {
3674 tts_speak_cached(tts, "back");
3675 } else if (kn && (strcmp(kn, "enter") == 0 || strcmp(kn, "return") == 0)) {
3676 tts_speak_cached(tts, "enter");
3677 } else if (kn && strcmp(kn, "escape") == 0) {
3678 tts_speak_cached(tts, "clear");
3679 } else if (kn && strcmp(kn, "tab") == 0) {
3680 tts_speak_cached(tts, "tab");
3681 }
3682 }
3683 } else if (input->events[i].type == AC_EVENT_KEYBOARD_UP && logfile) {
3684 ac_log("[key] UP code=%d name=%s pressure=%.3f\n",
3685 input->events[i].key_code,
3686 input->events[i].key_name,
3687 input->events[i].pressure);
3688 }
3689 }
3690 // Hardware keys
3691 static int ctrl_held = 0;
3692 int power_pressed = 0;
3693 int scale_change = 0; // -1 = decrease density (bigger pixels), +1 = increase
3694 // Global escape → prompt fallback. If the current piece doesn't
3695 // jump() or otherwise consume an escape key-down this frame, we
3696 // transparently route the user back to the prompt after act()
3697 // returns. Notepat has its own triple-escape-to-exit behavior and
3698 // the prompt itself uses escape to clear input, so both are
3699 // exempt from the fallback.
3700 int escape_pressed_this_frame = 0;
3701 for (int i = 0; i < input->event_count; i++) {
3702 if (input->events[i].type == AC_EVENT_KEYBOARD_DOWN &&
3703 input->events[i].key_code == KEY_ESC) {
3704 if (strcmp(rt->piece, "notepat") != 0 &&
3705 strcmp(rt->piece, "prompt") != 0) {
3706 escape_pressed_this_frame = 1;
3707 }
3708 break;
3709 }
3710 }
3711 for (int i = 0; i < input->event_count; i++) {
3712 // Track ctrl modifier
3713 if (input->events[i].key_code == KEY_LEFTCTRL || input->events[i].key_code == KEY_RIGHTCTRL) {
3714 ctrl_held = (input->events[i].type == AC_EVENT_KEYBOARD_DOWN) ? 1 : 0;
3715 }
3716 if (input->events[i].type == AC_EVENT_KEYBOARD_DOWN) {
3717 // Ctrl+= (or Ctrl++) → bigger pixels (increase scale number)
3718 // Ctrl+- → smaller pixels (decrease scale number)
3719 if (ctrl_held && (input->events[i].key_code == KEY_EQUAL ||
3720 input->events[i].key_code == KEY_KPPLUS)) {
3721 scale_change = -1; // bigger pixels (lower res)
3722 input->events[i].type = 0; // suppress from JS
3723 } else if (ctrl_held && (input->events[i].key_code == KEY_MINUS ||
3724 input->events[i].key_code == KEY_KPMINUS)) {
3725 scale_change = 1; // smaller pixels (higher res)
3726 input->events[i].type = 0; // suppress from JS
3727 } else if (ctrl_held && input->events[i].key_code == KEY_0) {
3728 scale_change = 99; // reset to default (3)
3729 input->events[i].type = 0; // suppress from JS
3730 }
3731 // Volume: KEY_VOLUMEUP/DOWN/MUTE or F1/F2/F3 as fallback
3732 else if (strcmp(input->events[i].key_name, "audiovolumeup") == 0 ||
3733 strcmp(input->events[i].key_name, "f3") == 0) {
3734 audio_volume_adjust(audio, 1);
3735 audio_synth(audio, WAVE_SINE, 1200.0, 0.04, 0.15, 0.001, 0.03, 0.0);
3736 } else if (strcmp(input->events[i].key_name, "audiovolumedown") == 0 ||
3737 strcmp(input->events[i].key_name, "f2") == 0) {
3738 audio_volume_adjust(audio, -1);
3739 audio_synth(audio, WAVE_SINE, 800.0, 0.04, 0.15, 0.001, 0.03, 0.0);
3740 } else if (strcmp(input->events[i].key_name, "audiomute") == 0 ||
3741 strcmp(input->events[i].key_name, "f1") == 0) {
3742 audio_volume_adjust(audio, 0);
3743 audio_synth(audio, WAVE_SINE, 440.0, 0.08, 0.15, 0.001, 0.07, 0.0);
3744 // Brightness: KEY_BRIGHTNESS or F5/F6 as fallback
3745 } else if ((strcmp(input->events[i].key_name, "brightnessup") == 0 ||
3746 strcmp(input->events[i].key_name, "brightnessdown") == 0 ||
3747 strcmp(input->events[i].key_name, "f6") == 0 ||
3748 strcmp(input->events[i].key_name, "f5") == 0) && bl_path[0]) {
3749 int up = (strcmp(input->events[i].key_name, "brightnessup") == 0 ||
3750 strcmp(input->events[i].key_name, "f6") == 0);
3751 char tmp[160];
3752 snprintf(tmp, sizeof(tmp), "%s/brightness", bl_path);
3753 FILE *f = fopen(tmp, "r");
3754 int cur = bl_max / 2;
3755 if (f) { fscanf(f, "%d", &cur); fclose(f); }
3756 int step = bl_max / 20; // 5% steps
3757 if (step < 1) step = 1;
3758 cur += up ? step : -step;
3759 if (cur < 1) cur = 1; // never fully off
3760 if (cur > bl_max) cur = bl_max;
3761 f = fopen(tmp, "w");
3762 if (f) { fprintf(f, "%d", cur); fclose(f); }
3763 // Click sound: higher = brighter, lower = dimmer
3764 audio_synth(audio, WAVE_SINE, up ? 1000.0 : 600.0,
3765 0.04, 0.12, 0.001, 0.03, 0.0);
3766 }
3767 // Tape recording: PrintScreen (or Insert/Pause as fallbacks
3768 // on laptops where PrtSc doesn't fire standalone) toggles
3769 // MP4 tape recording. Saves to /mnt/tapes/<slug>.mp4 where
3770 // slug follows the same YYYY.MM.DD.HH.MM.SS.mmm format that
3771 // web AC uses for tapes. On stop, auto-uploads to the
3772 // cloud via a background curl shell-out.
3773 else if ((strcmp(input->events[i].key_name, "printscreen") == 0 ||
3774 strcmp(input->events[i].key_name, "insert") == 0 ||
3775 strcmp(input->events[i].key_name, "pause") == 0) && recorder) {
3776 if (recorder_is_recording(recorder)) {
3777 // Stop: remove audio tap, finalize file
3778 if (audio) {
3779 audio->rec_callback = NULL;
3780 audio->rec_userdata = NULL;
3781 }
3782 // Snapshot the path before stop (recorder_stop may clear it)
3783 char saved_tape_path[256] = {0};
3784 strncpy(saved_tape_path, g_tape_current_path, sizeof(saved_tape_path) - 1);
3785 recorder_stop(recorder);
3786 // Clear the live overlay state
3787 g_tape_recording = 0;
3788 g_tape_current_path[0] = '\0';
3789 // TTS announce + audible cue (descending)
3790 if (tts) tts_speak(tts, "tape stopped");
3791 audio_synth(audio, WAVE_SINE, 880.0, 0.12, 0.2, 0.001, 0.10, 0.0);
3792 audio_synth(audio, WAVE_SINE, 660.0, 0.12, 0.15, 0.03, 0.09, 0.0);
3793 // Kick off background upload if we captured a path
3794 if (saved_tape_path[0]) {
3795 tape_upload_async(saved_tape_path);
3796 }
3797 } else {
3798 // Start: generate timestamped slug, wire up audio tap
3799 mkdir("/mnt/tapes", 0755);
3800 time_t now = time(NULL);
3801 struct tm *tm = gmtime(&now);
3802 // Milliseconds for slug uniqueness
3803 struct timespec ts;
3804 clock_gettime(CLOCK_REALTIME, &ts);
3805 int ms = (int)(ts.tv_nsec / 1000000);
3806 char rec_path[256];
3807 snprintf(rec_path, sizeof(rec_path),
3808 "/mnt/tapes/%04d.%02d.%02d.%02d.%02d.%02d.%03d.mp4",
3809 tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday,
3810 tm->tm_hour, tm->tm_min, tm->tm_sec, ms);
3811 if (recorder_start(recorder, rec_path) == 0) {
3812 // Wire up audio tap
3813 if (audio) {
3814 audio->rec_userdata = recorder;
3815 audio->rec_callback = rec_audio_tap;
3816 }
3817 // Latch the path + flag for the on-screen overlay
3818 strncpy(g_tape_current_path, rec_path, sizeof(g_tape_current_path) - 1);
3819 g_tape_current_path[sizeof(g_tape_current_path) - 1] = '\0';
3820 g_tape_recording = 1;
3821 g_tape_start_sec = time(NULL);
3822 // TTS announce + audible cue (ascending)
3823 if (tts) tts_speak(tts, "tape rolling");
3824 audio_synth(audio, WAVE_SINE, 660.0, 0.12, 0.2, 0.001, 0.10, 0.0);
3825 audio_synth(audio, WAVE_SINE, 880.0, 0.12, 0.15, 0.03, 0.09, 0.0);
3826 }
3827 }
3828 }
3829 else if (strcmp(input->events[i].key_name, "power") == 0 ||
3830 input->events[i].key_code == KEY_POWER)
3831 power_pressed = 1;
3832 }
3833 }
3834
3835 // Pixel density scale change
3836 if (scale_change && display) {
3837 int new_scale = pixel_scale;
3838 if (scale_change == 99) {
3839 new_scale = 3; // reset to default
3840 } else if (scale_change == 1) {
3841 // Increase density (smaller pixels): 12→10→8→6→4→3→2→1
3842 if (pixel_scale > 6) new_scale = pixel_scale - 2;
3843 else if (pixel_scale > 3) new_scale = pixel_scale - 2;
3844 else if (pixel_scale > 1) new_scale = pixel_scale - 1;
3845 } else if (scale_change == -1) {
3846 // Decrease density (bigger pixels): 1→2→3→4→6→8→10→12
3847 if (pixel_scale < 3) new_scale = pixel_scale + 1;
3848 else if (pixel_scale < 6) new_scale = pixel_scale + 2;
3849 else new_scale = pixel_scale + 2;
3850 }
3851 if (new_scale != pixel_scale && new_scale >= 1 && new_scale <= 12) {
3852 pixel_scale = new_scale;
3853 // Recreate framebuffer at new resolution
3854 ACFramebuffer *new_screen = fb_create(display->width / pixel_scale,
3855 display->height / pixel_scale);
3856 if (new_screen) {
3857 fb_destroy(screen);
3858 screen = new_screen;
3859 graph_init(&graph, screen);
3860 if (display) graph_init_gpu(&graph, display);
3861 input->scale = pixel_scale;
3862 // Update JS runtime's graph reference (holds framebuffer)
3863 if (rt) {
3864 rt->graph = &graph;
3865 }
3866 // Recreate cursor overlay at new resolution
3867 if (cursor_fb) fb_destroy(cursor_fb);
3868 cursor_fb = fb_create(screen->width, screen->height);
3869 ac_log("[scale] pixel_scale=%d resolution=%dx%d",
3870 pixel_scale, screen->width, screen->height);
3871 audio_synth(audio, WAVE_SINE,
3872 440.0 + (6 - pixel_scale) * 150.0,
3873 0.06, 0.15, 0.002, 0.05, 0.0);
3874 }
3875 }
3876 }
3877
3878 if (power_pressed || poweroff_requested) {
3879 poweroff_requested = 0; // consume the flag
3880 // Say bye IMMEDIATELY (TTS + shutdown chime start before animation)
3881 char bye_title[80];
3882 {
3883 const char *at = strchr(boot_title, '@');
3884 if (at)
3885 snprintf(bye_title, sizeof(bye_title), "bye @%s", at + 1);
3886 else
3887 snprintf(bye_title, sizeof(bye_title), "bye");
3888 char bye_speech[96];
3889 if (at)
3890 snprintf(bye_speech, sizeof(bye_speech), "bye %s", at + 1);
3891 else
3892 snprintf(bye_speech, sizeof(bye_speech), "bye");
3893 if (tts) tts_speak(tts, bye_speech);
3894 audio_shutdown_sound(audio);
3895 }
3896
3897 // Shutdown animation — chaotic red/white strobe with bye title
3898 struct timespec anim_time;
3899 clock_gettime(CLOCK_MONOTONIC, &anim_time);
3900
3901 for (int f = 0; f < 90; f++) { // 90 frames @ 60fps = 1.5s
3902 double t = (double)f / 90.0;
3903
3904 // Chaotic strobe: alternate red/white/black with randomish pattern
3905 int phase = (f * 7 + f / 3) % 6; // pseudo-random cycle
3906 uint8_t br, bg_g, bb;
3907 if (phase < 2) { br = 220; bg_g = 20; bb = 20; } // red
3908 else if (phase < 3) { br = 255; bg_g = 255; bb = 255; } // white
3909 else if (phase < 5) { br = 180; bg_g = 0; bb = 0; } // dark red
3910 else { br = 10; bg_g = 10; bb = 10; } // near black
3911
3912 // Fade intensity toward end
3913 double fade = 1.0 - t * t;
3914 br = (uint8_t)(br * fade);
3915 bg_g = (uint8_t)(bg_g * fade);
3916 bb = (uint8_t)(bb * fade);
3917 graph_wipe(&graph, (ACColor){br, bg_g, bb, 255});
3918
3919 // Title text — jitter position, flicker between red and white
3920 if (t < 0.85) {
3921 int alpha = (int)(255.0 * (1.0 - t / 0.85));
3922 int jx = (f * 13 % 7) - 3; // -3 to +3 pixel jitter
3923 int jy = (f * 17 % 5) - 2; // -2 to +2
3924 uint8_t tr = (f % 3 == 0) ? 255 : 200;
3925 uint8_t tg = (f % 3 == 0) ? 255 : 40;
3926 uint8_t tb = (f % 3 == 0) ? 255 : 40;
3927 graph_ink(&graph, (ACColor){tr, tg, tb, (uint8_t)alpha});
3928 int tw = font_measure_matrix(bye_title, 3);
3929 font_draw_matrix(&graph, bye_title,
3930 (screen->width - tw) / 2 + jx,
3931 screen->height / 2 - 20 + jy, 3);
3932 graph_ink(&graph, (ACColor){(uint8_t)(120 * fade), 40, 40, (uint8_t)(alpha / 2)});
3933 int sw = font_measure_matrix("aesthetic.computer", 1);
3934 font_draw_matrix(&graph, "aesthetic.computer",
3935 (screen->width - sw) / 2 + jx / 2,
3936 screen->height / 2 + 10 + jy / 2, 1);
3937 }
3938
3939 ac_display_present(display, screen, pixel_scale);
3940 frame_sync_60fps(&anim_time);
3941 }
3942
3943 // Final black frame + hide console text
3944 graph_wipe(&graph, (ACColor){0, 0, 0, 255});
3945 ac_display_present(display, screen, pixel_scale);
3946 // Suppress kernel console output during shutdown
3947 {
3948 // Set kernel loglevel to 0 (suppress all printk)
3949 FILE *pl = fopen("/proc/sys/kernel/printk", "w");
3950 if (pl) { fputs("0 0 0 0", pl); fclose(pl); }
3951 // Hide VT cursor
3952 FILE *vc = fopen("/dev/tty0", "w");
3953 if (vc) { fputs("\033[?25l\033[2J", vc); fclose(vc); }
3954 }
3955
3956 running = 0;
3957 break;
3958 }
3959
3960 struct timespec _pf_start, _pf_act0, _pf_act1;
3961 clock_gettime(CLOCK_MONOTONIC, &_pf_start);
3962 clock_gettime(CLOCK_MONOTONIC, &_pf_act0);
3963 js_call_act(rt);
3964 clock_gettime(CLOCK_MONOTONIC, &_pf_act1);
3965
3966 // Global escape fallback: if the piece didn't consume the escape
3967 // key-down (i.e. didn't call system.jump() or otherwise stop the
3968 // event), send the user back to the prompt automatically.
3969 if (escape_pressed_this_frame && !rt->jump_requested) {
3970 ac_log("[ac-native] escape fallback: %s → prompt\n", rt->piece);
3971 strncpy(rt->jump_target, "prompt", sizeof(rt->jump_target) - 1);
3972 rt->jump_target[sizeof(rt->jump_target) - 1] = 0;
3973 rt->jump_requested = 1;
3974 }
3975
3976 // Handle piece jump requests from system.jump()
3977 if (rt->jump_requested) {
3978 rt->jump_requested = 0;
3979
3980 // Parse colon-separated params: "chat:clock" → name="chat", params=["clock"]
3981 rt->jump_param_count = 0;
3982 {
3983 char *colon = strchr(rt->jump_target, ':');
3984 if (colon) {
3985 *colon = 0; // terminate piece name at first colon
3986 char *rest = colon + 1;
3987 while (rest && *rest && rt->jump_param_count < 8) {
3988 char *next = strchr(rest, ':');
3989 if (next) *next = 0;
3990 strncpy(rt->jump_params[rt->jump_param_count], rest, 63);
3991 rt->jump_params[rt->jump_param_count][63] = 0;
3992 rt->jump_param_count++;
3993 rest = next ? next + 1 : NULL;
3994 }
3995 }
3996 }
3997
3998 ac_log("[ac-native] Jumping to piece: %s (%d params)\n",
3999 rt->jump_target, rt->jump_param_count);
4000
4001 // Call leave() on current piece
4002 js_call_leave(rt);
4003
4004 // Free old lifecycle functions
4005 JS_FreeValue(rt->ctx, rt->boot_fn); rt->boot_fn = JS_UNDEFINED;
4006 JS_FreeValue(rt->ctx, rt->paint_fn); rt->paint_fn = JS_UNDEFINED;
4007 JS_FreeValue(rt->ctx, rt->act_fn); rt->act_fn = JS_UNDEFINED;
4008 JS_FreeValue(rt->ctx, rt->sim_fn); rt->sim_fn = JS_UNDEFINED;
4009 JS_FreeValue(rt->ctx, rt->leave_fn); rt->leave_fn = JS_UNDEFINED;
4010 JS_FreeValue(rt->ctx, rt->beat_fn); rt->beat_fn = JS_UNDEFINED;
4011
4012 // Clear globalThis lifecycle refs so new piece starts clean
4013 JSValue global = JS_GetGlobalObject(rt->ctx);
4014 const char *lc_names[] = {"boot","paint","act","sim","leave","beat","configureAutopat",NULL};
4015 for (int i = 0; lc_names[i]; i++) {
4016 JSAtom a = JS_NewAtom(rt->ctx, lc_names[i]);
4017 JS_DeleteProperty(rt->ctx, global, a, 0);
4018 JS_FreeAtom(rt->ctx, a);
4019 }
4020 JS_FreeValue(rt->ctx, global);
4021
4022 // Reset counters and crash state
4023 rt->paint_count = 0;
4024 rt->sim_count = 0;
4025 rt->crash_active = 0;
4026 rt->crash_count = 0;
4027 rt->crash_frame = 0;
4028 rt->crash_msg[0] = 0;
4029
4030 // NOTE: "claude" and "cc" aliases removed — claude.mjs handles auth curtain
4031 // before jumping to terminal:claude itself.
4032
4033 // Check for .lisp piece — hand off to SBCL
4034 {
4035 char lisp_path[256];
4036 // Strip .lisp suffix if present
4037 char lisp_name[128];
4038 strncpy(lisp_name, rt->jump_target, sizeof(lisp_name) - 1);
4039 lisp_name[sizeof(lisp_name) - 1] = 0;
4040 char *dot = strstr(lisp_name, ".lisp");
4041 if (dot) *dot = 0;
4042 snprintf(lisp_path, sizeof(lisp_path), "/pieces/%s.lisp", lisp_name);
4043 if (access(lisp_path, F_OK) == 0 && access("/ac-swank", X_OK) == 0) {
4044 ac_log("[ac-native] Launching CL piece: %s\n", lisp_name);
4045 // Clean up before exec
4046 if (logfile) { fflush(logfile); fsync(fileno(logfile)); }
4047 sync();
4048 execl("/ac-swank", "ac-swank", "--piece", lisp_name, NULL);
4049 // execl failed — fall through to JS path
4050 ac_log("[ac-native] execl /ac-swank failed: %s\n", strerror(errno));
4051 }
4052 }
4053
4054 // Construct piece path: /pieces/<name>.mjs
4055 char jump_path[256];
4056 snprintf(jump_path, sizeof(jump_path), "/pieces/%s.mjs", rt->jump_target);
4057
4058 // Load new piece
4059 if (js_load_piece(rt, jump_path) < 0) {
4060 ac_log("[ac-native] Failed to load %s, falling back to /piece.mjs\n", jump_path);
4061 // Fall back to default piece
4062 if (js_load_piece(rt, "/piece.mjs") < 0) {
4063 ac_log("[ac-native] FATAL: Cannot reload default piece\n");
4064 running = 0;
4065 break;
4066 }
4067 }
4068
4069 // Flush log on piece transition so USB pull captures it
4070 if (logfile) { fflush(logfile); fsync(fileno(logfile)); }
4071
4072 // Clear screen and call boot() on new piece
4073 graph_wipe(&graph, (ACColor){0, 0, 0, 255});
4074 js_call_boot(rt);
4075 }
4076
4077 struct timespec _pf_sim0, _pf_sim1, _pf_paint0, _pf_paint1, _pf_pres0, _pf_pres1;
4078 if (audio_beat_check(audio)) js_call_beat(rt);
4079 clock_gettime(CLOCK_MONOTONIC, &_pf_sim0);
4080 js_call_sim(rt);
4081 clock_gettime(CLOCK_MONOTONIC, &_pf_sim1);
4082 clock_gettime(CLOCK_MONOTONIC, &_pf_paint0);
4083 js_call_paint(rt);
4084
4085 clock_gettime(CLOCK_MONOTONIC, &_pf_paint1);
4086
4087 // Tape recording overlay — red REC dot + elapsed timer in
4088 // the top-left corner. Blinks slowly so it reads as "live"
4089 // without being visually distracting.
4090 if (g_tape_recording) {
4091 long elapsed = (long)(time(NULL) - g_tape_start_sec);
4092 int blink = ((main_frame / 30) & 1); // toggle ~every 0.5s
4093 // Red dot
4094 if (blink) {
4095 graph_ink(&graph, (ACColor){230, 40, 40, 240});
4096 graph_box(&graph, 6, 6, 8, 8, 1);
4097 }
4098 // "TAPE 0:23" label
4099 char rec_label[32];
4100 snprintf(rec_label, sizeof(rec_label), "TAPE %ld:%02ld",
4101 elapsed / 60, elapsed % 60);
4102 graph_ink(&graph, (ACColor){0, 0, 0, 180});
4103 graph_box(&graph, 16, 4, (int)strlen(rec_label) * 6 + 4, 12, 1);
4104 graph_ink(&graph, (ACColor){255, 220, 220, 255});
4105 font_draw_matrix(&graph, rec_label, 18, 6, 1);
4106 }
4107
4108 // Crash overlay — red bar with error message when JS throws
4109 if (rt->crash_active) {
4110 rt->crash_frame++;
4111 int bar_h = 24;
4112 int flash = (rt->crash_frame < 30) ? (rt->crash_frame % 6 < 3 ? 255 : 180) : 200;
4113 graph_ink(&graph, (ACColor){flash, 0, 0, 240});
4114 graph_box(&graph, 0, 0, screen->width, bar_h, 1);
4115 graph_ink(&graph, (ACColor){255, 255, 255, 255});
4116 font_draw_matrix(&graph, "CRASH", 4, 4, 2);
4117 // Truncate message to fit screen
4118 char crash_display[128];
4119 snprintf(crash_display, sizeof(crash_display), "%s", rt->crash_msg);
4120 graph_ink(&graph, (ACColor){255, 200, 200, 255});
4121 font_draw_matrix(&graph, crash_display, 60, 8, 1);
4122 // Auto-dismiss after 5 seconds (300 frames at 60fps)
4123 if (rt->crash_frame > 300) {
4124 rt->crash_active = 0;
4125 }
4126 }
4127
4128 // Software cursor on its own overlay buffer (unaffected by KidLisp effects)
4129 if (cursor_fb && input && (input->pointer_x || input->pointer_y)) {
4130 fb_clear(cursor_fb, 0x00000000); // transparent
4131 graph_page(&graph, cursor_fb);
4132 int cx = input->pointer_x / pixel_scale, cy = input->pointer_y / pixel_scale;
4133 // Shadow (black, offset +1,+1)
4134 graph_ink(&graph, (ACColor){0, 0, 0, 180});
4135 graph_line(&graph, cx+1, cy-9, cx+1, cy-4);
4136 graph_line(&graph, cx+1, cy+6, cx+1, cy+11);
4137 graph_line(&graph, cx-9, cy+1, cx-4, cy+1);
4138 graph_line(&graph, cx+6, cy+1, cx+11, cy+1);
4139 graph_plot(&graph, cx+1, cy+1);
4140 // Crosshair color reflects WiFi state
4141 {
4142 ACColor cross_color = {128, 128, 128, 255}; // gray = no wifi
4143 if (wifi) {
4144 switch (wifi->state) {
4145 case WIFI_STATE_CONNECTED:
4146 cross_color = (ACColor){0, 255, 255, 255}; // cyan
4147 break;
4148 case WIFI_STATE_SCANNING:
4149 case WIFI_STATE_CONNECTING:
4150 cross_color = (ACColor){255, 255, 0, 255}; // yellow
4151 break;
4152 case WIFI_STATE_FAILED:
4153 cross_color = (ACColor){255, 60, 60, 255}; // red
4154 break;
4155 default:
4156 cross_color = (ACColor){128, 128, 128, 255}; // gray
4157 break;
4158 }
4159 }
4160 graph_ink(&graph, cross_color);
4161 }
4162 graph_line(&graph, cx, cy-10, cx, cy-5);
4163 graph_line(&graph, cx, cy+5, cx, cy+10);
4164 graph_line(&graph, cx-10, cy, cx-5, cy);
4165 graph_line(&graph, cx+5, cy, cx+10, cy);
4166 // White center dot
4167 graph_ink(&graph, (ACColor){255, 255, 255, 255});
4168 graph_plot(&graph, cx, cy);
4169 // Composite cursor region onto screen (only the area around cursor)
4170 graph_page(&graph, screen);
4171 int bx = cx - 12, by = cy - 12, bw = 25, bh = 25;
4172 if (bx < 0) bx = 0;
4173 if (by < 0) by = 0;
4174 if (bx + bw > cursor_fb->width) bw = cursor_fb->width - bx;
4175 if (by + bh > cursor_fb->height) bh = cursor_fb->height - by;
4176 for (int py = by; py < by + bh; py++) {
4177 for (int px = bx; px < bx + bw; px++) {
4178 uint32_t pixel = cursor_fb->pixels[py * cursor_fb->stride + px];
4179 if (pixel >> 24) // only blit non-transparent
4180 fb_blend_pixel(screen, px, py, pixel);
4181 }
4182 }
4183 }
4184
4185 // WiFi "online" TTS announcement
4186 {
4187 static int was_connected = 0;
4188 int is_connected = (wifi && wifi->state == WIFI_STATE_CONNECTED);
4189 if (is_connected && !was_connected) {
4190 if (tts) {
4191 char wifi_msg[128];
4192 if (wifi->connected_ssid[0])
4193 snprintf(wifi_msg, sizeof(wifi_msg), "connected to %s", wifi->connected_ssid);
4194 else
4195 snprintf(wifi_msg, sizeof(wifi_msg), "online");
4196 tts_speak(tts, wifi_msg);
4197 }
4198 ac_log("[wifi-tts] connected to %s", wifi->connected_ssid[0] ? wifi->connected_ssid : "(unknown)");
4199
4200 // Fetch device tokens (Claude + GitHub) from API (authenticated)
4201 {
4202 FILE *cf = fopen("/mnt/config.json", "r");
4203 if (cf) {
4204 char cbuf[4096] = {0};
4205 fread(cbuf, 1, sizeof(cbuf) - 1, cf);
4206 fclose(cf);
4207 char handle[64] = {0}, actoken[1024] = {0};
4208 parse_config_string(cbuf, "\"handle\"", handle, sizeof(handle));
4209 parse_config_string(cbuf, "\"token\"", actoken, sizeof(actoken));
4210 if (handle[0] && actoken[0]) {
4211 char cmd[2048];
4212 snprintf(cmd, sizeof(cmd),
4213 "curl -fsSL -H 'Authorization: Bearer %s' "
4214 "'https://aesthetic.computer/.netlify/functions/claude-token' "
4215 "-o /tmp/claude-api-resp.json 2>/dev/null &", actoken);
4216 system(cmd);
4217 ac_log("[tokens] fetching for @%s\n", handle);
4218 }
4219 }
4220 }
4221 // Clone (or pull) the aesthetic-computer repo to
4222 // /mnt/ac-repo so Claude Code has a real project to
4223 // work in. Fetch stays on the GitHub HTTPS mirror for
4224 // simple read access; if a Tangled SSH key was baked,
4225 // the repo also gets origin pushurls for knot + GitHub
4226 // and a dedicated `tangled` remote. Runs in the
4227 // background so boot isn't delayed. We use a shallow
4228 // clone (--depth=50) to keep the size manageable
4229 // (~200 MB) while still giving Claude enough history
4230 // for basic blame/log work.
4231 //
4232 // Idempotent: if /mnt/ac-repo/.git already exists,
4233 // `git pull` runs instead (bounded by 30s timeout).
4234 // Logs go to /tmp/ac-repo-clone.log.
4235 {
4236 char clone_cmd[3072];
4237 snprintf(clone_cmd, sizeof(clone_cmd),
4238 "( REPO=/mnt/ac-repo; "
4239 " FETCH_URL='https://github.com/whistlegraph/aesthetic-computer.git'; "
4240 " TANGLED_URL='git@knot.aesthetic.computer:aesthetic.computer/core'; "
4241 " if [ -d \"$REPO/.git\" ]; then "
4242 " echo '[ac-repo] pulling latest' && "
4243 " cd \"$REPO\" && timeout 30 git pull --ff-only 2>&1; "
4244 " else "
4245 " echo '[ac-repo] cloning (shallow)' && "
4246 " timeout 300 git clone --depth=50 --branch=main \"$FETCH_URL\" \"$REPO\" 2>&1; "
4247 " fi; "
4248 " if [ -d \"$REPO/.git\" ]; then "
4249 " cd \"$REPO\"; "
4250 " git remote set-url origin \"$FETCH_URL\"; "
4251 " git config --unset-all remote.origin.pushurl 2>/dev/null || true; "
4252 " if [ -f /tmp/.ssh/tangled ]; then "
4253 " git config --add remote.origin.pushurl \"$TANGLED_URL\"; "
4254 " git config --add remote.origin.pushurl \"$FETCH_URL\"; "
4255 " if git remote | grep -qx tangled; then "
4256 " git remote set-url tangled \"$TANGLED_URL\"; "
4257 " else "
4258 " git remote add tangled \"$TANGLED_URL\"; "
4259 " fi; "
4260 " echo '[ac-repo] origin pushurl: tangled + github'; "
4261 " else "
4262 " git config --add remote.origin.pushurl \"$FETCH_URL\"; "
4263 " git remote remove tangled 2>/dev/null || true; "
4264 " echo '[ac-repo] origin pushurl: github only (no tangled key)'; "
4265 " fi; "
4266 " fi "
4267 ") >> /tmp/ac-repo-clone.log 2>&1 &");
4268 system(clone_cmd);
4269 ac_log("[ac-repo] background clone/pull started\n");
4270 }
4271 ac_log_flush();
4272 }
4273 was_connected = is_connected;
4274 }
4275
4276 // Machines monitoring daemon (connects, heartbeats, handles commands)
4277 {
4278 static int fps_counter = 0, fps_display = 0;
4279 static struct timespec fps_last = {0};
4280 fps_counter++;
4281 struct timespec fps_now;
4282 clock_gettime(CLOCK_MONOTONIC, &fps_now);
4283 if (fps_now.tv_sec > fps_last.tv_sec) {
4284 fps_display = fps_counter;
4285 fps_counter = 0;
4286 fps_last = fps_now;
4287 }
4288 machines_tick(&g_machines, wifi, main_frame, fps_display,
4289 rt->jump_target[0] ? rt->jump_target : rt->piece);
4290
4291 // Handle commands forwarded from machines daemon
4292 if (g_machines.cmd_pending) {
4293 g_machines.cmd_pending = 0;
4294 if (strcmp(g_machines.cmd_type, "jump") == 0 && g_machines.cmd_target[0]) {
4295 strncpy(rt->jump_target, g_machines.cmd_target, sizeof(rt->jump_target) - 1);
4296 rt->jump_requested = 1;
4297 ac_log("[machines] jump → %s\n", g_machines.cmd_target);
4298 } else if (strcmp(g_machines.cmd_type, "update") == 0) {
4299 // Jump to notepat which handles OTA updates
4300 strncpy(rt->jump_target, "notepat", sizeof(rt->jump_target) - 1);
4301 rt->jump_requested = 1;
4302 ac_log("[machines] update requested, jumping to notepat\n");
4303 }
4304 }
4305 }
4306
4307 // Submit frame to recorder (after cursor overlay, before display)
4308 if (recorder_is_recording(recorder))
4309 recorder_submit_video(recorder, screen->pixels, screen->stride);
4310
4311 // Draw recording indicator (red dot + duration)
4312 if (recorder_is_recording(recorder)) {
4313 graph_page(&graph, screen);
4314 graph_ink(&graph, (ACColor){255, 40, 40, 255});
4315 // Red dot at top-right (4px circle)
4316 int rx = screen->width - 8, ry = 4;
4317 graph_circle(&graph, rx, ry, 3, 1);
4318 }
4319
4320 clock_gettime(CLOCK_MONOTONIC, &_pf_pres0);
4321 ac_display_present(display, screen, pixel_scale);
4322 clock_gettime(CLOCK_MONOTONIC, &_pf_pres1);
4323
4324 // HDMI: render waveform at ~7.5Hz (every 8 frames) — 4K dumb-buf is slow
4325 if (hdmi && audio && main_frame % 8 == 0) {
4326 drm_secondary_present_waveform(hdmi, &graph,
4327 audio->waveform_left, AUDIO_WAVEFORM_SIZE, audio->waveform_pos);
4328 }
4329
4330 // HDMI hotplug detection every ~180 frames (~3s)
4331 if (main_frame % 180 == 0 && display && !display->is_fbdev) {
4332 int hdmi_connected = drm_secondary_is_connected(display);
4333 if (hdmi_connected && !hdmi) {
4334 hdmi = drm_init_secondary(display);
4335 rt->hdmi = hdmi;
4336 if (hdmi) {
4337 ac_log("[ac-native] HDMI plugged in: %dx%d\n", hdmi->width, hdmi->height);
4338 // HDMI on: rising chord
4339 if (audio) { audio_synth(audio, WAVE_SINE, 523.25, 0.15, 0.2, 0.005, 0.12, 0.0);
4340 audio_synth(audio, WAVE_SINE, 783.99, 0.15, 0.15, 0.02, 0.11, 0.0); }
4341 }
4342 } else if (!hdmi_connected && hdmi) {
4343 ac_log("[ac-native] HDMI unplugged\n");
4344 // HDMI off: descending two-tone
4345 if (audio) { audio_synth(audio, WAVE_SINE, 523.25, 0.12, 0.18, 0.005, 0.10, 0.0);
4346 audio_synth(audio, WAVE_SINE, 392.00, 0.12, 0.14, 0.02, 0.09, 0.0); }
4347 drm_secondary_destroy(hdmi);
4348 hdmi = NULL;
4349 rt->hdmi = NULL;
4350 }
4351 }
4352
4353 // Sync the USB log in batches instead of on every event.
4354 if (logfile && log_dirty && main_frame % 300 == 0) {
4355 ac_log_flush();
4356 }
4357
4358 // ── Record frame perf ──
4359 {
4360 struct timespec _pf_end;
4361 clock_gettime(CLOCK_MONOTONIC, &_pf_end);
4362 #define TS_US(a, b) (uint16_t)({ \
4363 long _d = ((b).tv_sec - (a).tv_sec) * 1000000L + \
4364 ((b).tv_nsec - (a).tv_nsec) / 1000L; \
4365 _d < 0 ? 0 : (_d > 65535 ? 65535 : _d); })
4366 int _voices = 0;
4367 if (audio) {
4368 for (int _v = 0; _v < AUDIO_MAX_VOICES; _v++)
4369 if (audio->voices[_v].state != VOICE_INACTIVE) _voices++;
4370 }
4371 PerfRecord pr = {
4372 .frame = (uint32_t)main_frame,
4373 .total_us = TS_US(_pf_start, _pf_end),
4374 .act_us = TS_US(_pf_act0, _pf_act1),
4375 .sim_us = TS_US(_pf_sim0, _pf_sim1),
4376 .paint_us = TS_US(_pf_paint0, _pf_paint1),
4377 .present_us = TS_US(_pf_pres0, _pf_pres1),
4378 .voices = (uint8_t)(_voices > 255 ? 255 : _voices),
4379 .events = (uint8_t)(input->event_count > 255 ? 255 : input->event_count),
4380 .js_heap_mb = 0,
4381 .flags = (uint8_t)((input->pointer_x || input->pointer_y ? 2 : 0)),
4382 };
4383 perf_record(&pr);
4384
4385 // Log perf summary every 5 seconds (300 frames @ 60fps)
4386 {
4387 static uint32_t perf_log_max = 0, perf_log_sum = 0;
4388 static uint16_t perf_log_paint_max = 0, perf_log_pres_max = 0;
4389 static int perf_log_slow = 0; // frames > 20ms
4390 if (pr.total_us > perf_log_max) perf_log_max = pr.total_us;
4391 if (pr.paint_us > perf_log_paint_max) perf_log_paint_max = pr.paint_us;
4392 if (pr.present_us > perf_log_pres_max) perf_log_pres_max = pr.present_us;
4393 perf_log_sum += pr.total_us;
4394 if (pr.total_us > 20000) perf_log_slow++;
4395 if (main_frame % 300 == 299) {
4396 ac_log("[perf] f=%d avg=%.1fms max=%.1fms paint_max=%.1fms pres_max=%.1fms slow=%d voices=%d\n",
4397 (int)main_frame,
4398 perf_log_sum / 300.0f / 1000.0f,
4399 perf_log_max / 1000.0f,
4400 perf_log_paint_max / 1000.0f,
4401 perf_log_pres_max / 1000.0f,
4402 perf_log_slow, pr.voices);
4403 perf_log_max = 0; perf_log_sum = 0;
4404 perf_log_paint_max = 0; perf_log_pres_max = 0;
4405 perf_log_slow = 0;
4406 }
4407 }
4408
4409 // Write frame marker to ftrace ring buffer for BPF correlation.
4410 // Only active when /tmp/.trace-active exists (zero cost otherwise).
4411 {
4412 static int trace_marker_fd = -2; // -2 = unchecked
4413 if (trace_marker_fd == -2) {
4414 struct stat _tst;
4415 if (stat("/tmp/.trace-active", &_tst) == 0)
4416 trace_marker_fd = open("/sys/kernel/tracing/trace_marker",
4417 O_WRONLY | O_CLOEXEC);
4418 else
4419 trace_marker_fd = -1;
4420 }
4421 if (trace_marker_fd >= 0) {
4422 char _tbuf[64];
4423 int _tlen = snprintf(_tbuf, sizeof(_tbuf),
4424 "frame:%u total:%u\n",
4425 (uint32_t)main_frame, pr.total_us);
4426 (void)write(trace_marker_fd, _tbuf, _tlen);
4427 }
4428 }
4429
4430 // Flush chunk to disk every 30 seconds (fsync'd, crash-safe)
4431 if (main_frame - perf_flush_frame >= PERF_CHUNK_FRAMES) {
4432 perf_flush();
4433 perf_flush_frame = main_frame;
4434 }
4435 #undef TS_US
4436 }
4437
4438 // DRM page flip already syncs to vblank (~16ms). Only use
4439 // frame_sync for fbdev/non-vsync paths to avoid double-wait.
4440 if (display && display->is_fbdev)
4441 frame_sync_60fps(&frame_time);
4442 }
4443 }
4444
4445 // Stop recording if active
4446 if (recorder) {
4447 if (audio) { audio->rec_callback = NULL; audio->rec_userdata = NULL; }
4448 recorder_destroy(recorder);
4449 recorder = NULL;
4450 }
4451
4452 // Flush final perf data
4453 perf_destroy();
4454
4455 // Cleanup (TTS bye + shutdown chime already fired at power-press time)
4456 ac_log("[ac-native] Shutting down\n");
4457 // Upload the complete boot-to-shutdown log before tearing down
4458 machines_flush_logs(&g_machines);
4459 machines_destroy(&g_machines);
4460
4461 if (logfile) { fclose(logfile); logfile = NULL; }
4462 sync();
4463 // Unmount USB log
4464 umount("/mnt");
4465
4466 js_call_leave(rt);
4467 js_destroy(rt);
4468 wifi_destroy(wifi);
4469 if (tts) {
4470 tts_wait(tts);
4471 usleep(300000); // Let TTS ring buffer drain
4472 }
4473 usleep(600000); // Let shutdown chime ring out
4474 tts_destroy(tts);
4475 audio_destroy(audio);
4476 if (input) input_destroy(input);
4477 if (hdmi) drm_secondary_destroy(hdmi);
4478 fb_destroy(screen);
4479 if (display) drm_destroy(display);
4480
4481 ac_poweroff();
4482
4483 return 0;
4484}