Monorepo for Aesthetic.Computer
aesthetic.computer
1#!/bin/bash
2# make-alpine-kiosk.sh — Create an Alpine Linux kiosk USB with a bundled piece
3#
4# Builds a minimal Alpine rootfs from scratch using apk.static, producing a
5# ~400MB bootable image (vs ~4GB Fedora). Same piece injection / FEDAC-PIECE
6# partition layout as the Fedora script for oven compatibility.
7#
8# Boot flow:
9# Power on → GRUB (instant) → linux-lts kernel → OpenRC → Cage → Chromium
10# → bundled piece (offline, no WiFi needed)
11#
12# Usage:
13# sudo bash fedac/scripts/make-alpine-kiosk.sh <piece-code> [<device>] [options]
14#
15# Options:
16# --image <path> Build a bootable disk image file (.img)
17# --image-size <g> Image size in GiB (default: 1)
18# --base-image Build base image without a specific piece (placeholder only)
19# --density <n> Default pack/runtime density query value (default: 8)
20# --work-base <dir> Directory for large temp work files (default: /tmp)
21# --no-eject Don't eject when done
22# --yes Skip confirmation prompts
23#
24# Requirements:
25# squashfs-tools (mksquashfs), curl, parted, e2fsprogs, dosfstools
26
27set -euo pipefail
28
29RED='\033[0;31m'
30GREEN='\033[0;32m'
31YELLOW='\033[1;33m'
32CYAN='\033[0;36m'
33PINK='\033[38;5;205m'
34NC='\033[0m'
35
36SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
37FEDAC_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
38OVERLAY_DIR="$FEDAC_DIR/overlays/kiosk"
39REPO_ROOT="$(cd "$FEDAC_DIR/.." && pwd)"
40
41# Alpine 3.21 (edge for latest Chromium)
42ALPINE_VERSION="3.21"
43ALPINE_MIRROR="https://dl-cdn.alpinelinux.org/alpine"
44ALPINE_REPO_MAIN="${ALPINE_MIRROR}/v${ALPINE_VERSION}/main"
45ALPINE_REPO_COMMUNITY="${ALPINE_MIRROR}/v${ALPINE_VERSION}/community"
46APK_STATIC_URL="${ALPINE_MIRROR}/v${ALPINE_VERSION}/main/x86_64/apk-tools-static-2.14.6-r3.apk"
47APK_CACHE_DIR="${HOME}/.cache/alpine-apk"
48
49PACK_DENSITY_DEFAULT="8"
50
51usage() {
52 echo -e "${PINK}Alpine Kiosk Piece USB Creator${NC}"
53 echo ""
54 echo "Creates a minimal Alpine Linux USB that boots into a fullscreen piece (offline)."
55 echo ""
56 echo "Usage: sudo $0 <piece-code> [<device>] [options]"
57 echo ""
58 echo " <piece-code> Piece code (e.g., notepat, \$cow)"
59 echo " <device> Optional USB block device (e.g., /dev/sdb)"
60 echo ""
61 echo "Options:"
62 echo " --image <path> Build a bootable disk image file (.img)"
63 echo " --image-size <g> Image size in GiB (default: 1)"
64 echo " --base-image Build base image (placeholder piece)"
65 echo " --density <n> Runtime density (default: 8)"
66 echo " --work-base <dir> Temp directory (default: /tmp)"
67 echo " --no-eject Don't eject the USB when done"
68 echo " --yes Skip confirmation prompts"
69 echo " --help Show this help"
70 exit 1
71}
72
73cleanup() {
74 echo -e "\n${YELLOW}Cleaning up mounts...${NC}"
75 for mp in "${EFI_MOUNT:-}" "${LIVE_MOUNT:-}" "${PIECE_MOUNT_TMP:-}"; do
76 [ -n "$mp" ] && mountpoint -q "$mp" 2>/dev/null && umount "$mp" 2>/dev/null || true
77 done
78 if [ -n "${LOOP_DEV:-}" ]; then
79 losetup -d "$LOOP_DEV" 2>/dev/null || true
80 LOOP_DEV=""
81 fi
82}
83trap cleanup EXIT
84
85# ── Parse args ──
86PIECE_CODE=""
87DEVICE=""
88IMAGE_PATH=""
89IMAGE_SIZE_GB="1"
90WORK_BASE="/tmp"
91DO_EJECT=true
92SKIP_CONFIRM=false
93BASE_IMAGE_MODE=false
94PACK_DENSITY="$PACK_DENSITY_DEFAULT"
95
96while [ $# -gt 0 ]; do
97 case "$1" in
98 --image) IMAGE_PATH="$2"; shift 2 ;;
99 --image-size) IMAGE_SIZE_GB="$2"; shift 2 ;;
100 --density) PACK_DENSITY="$2"; shift 2 ;;
101 --work-base) WORK_BASE="$2"; shift 2 ;;
102 --base-image) BASE_IMAGE_MODE=true; shift ;;
103 --no-eject) DO_EJECT=false; shift ;;
104 --yes) SKIP_CONFIRM=true; shift ;;
105 --help|-h) usage ;;
106 /dev/*) DEVICE="$1"; shift ;;
107 *) if [ -z "$PIECE_CODE" ]; then PIECE_CODE="$1"; shift; else echo -e "${RED}Unknown arg: $1${NC}"; usage; fi ;;
108 esac
109done
110
111if [ "$BASE_IMAGE_MODE" = true ]; then
112 [ -n "$PIECE_CODE" ] || PIECE_CODE="__base__"
113else
114 [ -n "$PIECE_CODE" ] || { echo -e "${RED}Error: No piece code specified${NC}"; usage; }
115fi
116[ -n "$DEVICE" ] || [ -n "$IMAGE_PATH" ] || {
117 echo -e "${RED}Error: Specify a USB device and/or --image path${NC}"
118 usage
119}
120[ -z "$DEVICE" ] || [ -b "$DEVICE" ] || { echo -e "${RED}Error: $DEVICE is not a block device${NC}"; exit 1; }
121[ -z "$IMAGE_PATH" ] || [[ "$IMAGE_SIZE_GB" =~ ^[0-9]+$ ]] || {
122 echo -e "${RED}Error: --image-size must be an integer GiB value${NC}"
123 exit 1
124}
125[[ "$PACK_DENSITY" =~ ^[1-9][0-9]*$ ]] || {
126 echo -e "${RED}Error: --density must be a positive integer${NC}"
127 exit 1
128}
129[ -d "$WORK_BASE" ] || mkdir -p "$WORK_BASE"
130
131if [ "$(id -u)" -ne 0 ]; then
132 echo -e "${RED}Error: Must run as root (sudo)${NC}"
133 exit 1
134fi
135
136# Safety: refuse system disks
137if [ -n "$DEVICE" ]; then
138 case "$DEVICE" in
139 /dev/nvme*|/dev/vda|/dev/xvda)
140 echo -e "${RED}REFUSED: $DEVICE looks like a system disk.${NC}"
141 exit 1
142 ;;
143 /dev/sda)
144 RM_FLAG="$(lsblk -dn -o RM "$DEVICE" 2>/dev/null || echo "")"
145 if [ "$RM_FLAG" != "1" ]; then
146 echo -e "${RED}REFUSED: $DEVICE looks like a system disk.${NC}"
147 exit 1
148 fi
149 ;;
150 esac
151fi
152
153# Ensure required tools are installed
154if ! command -v mksquashfs &>/dev/null; then
155 echo -e " Installing squashfs-tools..."
156 apt-get install -y squashfs-tools >/dev/null 2>&1 || true
157fi
158
159# Check tools
160for tool in mksquashfs curl parted mkfs.vfat mkfs.ext4 losetup; do
161 command -v "$tool" &>/dev/null || { echo -e "${RED}Error: $tool not found${NC}"; exit 1; }
162done
163
164echo -e "${PINK}╔═══════════════════════════════════════════════╗${NC}"
165echo -e "${PINK}║ Alpine Kiosk Piece USB Creator ║${NC}"
166echo -e "${PINK}║ Piece: ${CYAN}${PIECE_CODE}${PINK} → offline bootable USB ║${NC}"
167echo -e "${PINK}╚═══════════════════════════════════════════════╝${NC}"
168echo ""
169
170WORK_DIR=$(mktemp -d "$WORK_BASE/alpine-kiosk-XXXX")
171echo -e "Work dir: $WORK_DIR"
172echo ""
173
174OWN_ROOTFS=true
175ROOTFS_DIR="$WORK_DIR/rootfs"
176EFI_MOUNT=""
177LIVE_MOUNT=""
178PIECE_MOUNT_TMP=""
179LOOP_DEV=""
180
181# ══════════════════════════════════════════
182# Step 1: Fetch piece bundle
183# ══════════════════════════════════════════
184BUNDLE_PATH="$WORK_DIR/piece.html"
185
186if [ "$BASE_IMAGE_MODE" = true ]; then
187 echo -e "${CYAN}[1/6] Base image mode — generating placeholder piece...${NC}"
188 cat > "$BUNDLE_PATH" << 'PLACEHOLDER_EOF'
189<!DOCTYPE html><html><head><meta charset="utf-8"><title>FedOS Base</title></head>
190<body style="background:#000;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;font-family:monospace">
191<p>No piece loaded. Build with aesthetic.computer/os</p>
192</body></html>
193PLACEHOLDER_EOF
194else
195 echo -e "${CYAN}[1/6] Fetching piece bundle from oven...${NC}"
196 OVEN_URL="https://oven.aesthetic.computer/pack-html"
197 if [[ "$PIECE_CODE" == \$* ]]; then
198 FETCH_URL="${OVEN_URL}?code=${PIECE_CODE}&density=${PACK_DENSITY}"
199 else
200 FETCH_URL="${OVEN_URL}?piece=${PIECE_CODE}&density=${PACK_DENSITY}"
201 fi
202 echo -e " Fetching: ${FETCH_URL}"
203 HTTP_CODE=$(curl -sSL -w '%{http_code}' -o "$BUNDLE_PATH" "$FETCH_URL" 2>/dev/null)
204 if [ "$HTTP_CODE" != "200" ]; then
205 echo -e "${RED}Error: Oven returned HTTP ${HTTP_CODE}${NC}"
206 exit 1
207 fi
208 BUNDLE_SIZE=$(stat -c%s "$BUNDLE_PATH")
209 echo -e " ${GREEN}Bundle fetched: $(numfmt --to=iec $BUNDLE_SIZE)${NC}"
210fi
211
212# ══════════════════════════════════════════
213# Step 2: Bootstrap Alpine rootfs
214# ══════════════════════════════════════════
215echo -e "${CYAN}[2/6] Building Alpine rootfs...${NC}"
216
217# 2a. Get apk.static (runs on any Linux host)
218mkdir -p "$APK_CACHE_DIR"
219APK_STATIC="$APK_CACHE_DIR/apk.static"
220if [ ! -x "$APK_STATIC" ]; then
221 echo -e " Downloading apk-tools-static..."
222 APK_TMP="$APK_CACHE_DIR/apk-tools-static.apk"
223 curl -sSL -o "$APK_TMP" "$APK_STATIC_URL"
224 # apk.static is inside the .apk at sbin/apk.static (it's a tar.gz)
225 tar -xzf "$APK_TMP" -C "$APK_CACHE_DIR" sbin/apk.static 2>/dev/null || \
226 tar -xf "$APK_TMP" -C "$APK_CACHE_DIR" sbin/apk.static 2>/dev/null
227 mv "$APK_CACHE_DIR/sbin/apk.static" "$APK_STATIC"
228 rmdir "$APK_CACHE_DIR/sbin" 2>/dev/null || true
229 rm -f "$APK_TMP"
230 chmod +x "$APK_STATIC"
231 echo -e " ${GREEN}apk.static ready${NC}"
232fi
233
234# 2b. Bootstrap the rootfs
235mkdir -p "$ROOTFS_DIR"
236
237echo -e " Installing Alpine base + kiosk packages..."
238$APK_STATIC \
239 --root "$ROOTFS_DIR" \
240 --initdb \
241 --allow-untrusted \
242 --no-progress \
243 --repository "$ALPINE_REPO_MAIN" \
244 --repository "$ALPINE_REPO_COMMUNITY" \
245 add \
246 alpine-base \
247 busybox \
248 openrc \
249 linux-lts \
250 linux-firmware-intel \
251 linux-firmware-amdgpu \
252 mesa-dri-gallium \
253 mesa-egl \
254 mesa-gl \
255 mesa-gbm \
256 libdrm \
257 chromium \
258 cage \
259 wlroots \
260 pipewire \
261 wireplumber \
262 pipewire-pulse \
263 alsa-lib \
264 alsa-plugins-pulse \
265 eudev \
266 dbus \
267 font-noto \
268 ttf-dejavu \
269 python3 \
270 seatd \
271 xwayland
272
273echo -e " ${GREEN}Alpine packages installed${NC}"
274
275# 2c. Configure Alpine repos inside rootfs
276mkdir -p "$ROOTFS_DIR/etc/apk"
277cat > "$ROOTFS_DIR/etc/apk/repositories" << EOF
278${ALPINE_REPO_MAIN}
279${ALPINE_REPO_COMMUNITY}
280EOF
281
282# 2d. Configure hostname and basic system
283echo "fedos" > "$ROOTFS_DIR/etc/hostname"
284cat > "$ROOTFS_DIR/etc/hosts" << EOF
285127.0.0.1 localhost fedos
286::1 localhost fedos
287EOF
288
289# 2e. Create kiosk user
290echo -e " Creating kioskuser..."
291chroot "$ROOTFS_DIR" /usr/sbin/adduser -D -s /bin/sh -h /home/kioskuser kioskuser 2>/dev/null || true
292chroot "$ROOTFS_DIR" /usr/sbin/addgroup kioskuser video 2>/dev/null || true
293chroot "$ROOTFS_DIR" /usr/sbin/addgroup kioskuser audio 2>/dev/null || true
294chroot "$ROOTFS_DIR" /usr/sbin/addgroup kioskuser input 2>/dev/null || true
295chroot "$ROOTFS_DIR" /usr/sbin/addgroup kioskuser seat 2>/dev/null || true
296
297# 2f. Configure inittab for autologin on tty1
298cat > "$ROOTFS_DIR/etc/inittab" << 'EOF'
299::sysinit:/sbin/openrc sysinit
300::sysinit:/sbin/openrc boot
301::wait:/sbin/openrc default
302tty1::respawn:/bin/login -f kioskuser
303tty2::askfirst:/bin/login
304::ctrlaltdel:/sbin/reboot
305::shutdown:/sbin/openrc shutdown
306EOF
307
308# 2g. Profile script — auto-start kiosk session on tty1
309mkdir -p "$ROOTFS_DIR/home/kioskuser"
310cat > "$ROOTFS_DIR/home/kioskuser/.profile" << 'PROFILEEOF'
311# Auto-start kiosk session on tty1 (match /dev/tty1, /dev/console, or unknown tty)
312# On some EFI hardware busybox init reports /dev/console instead of /dev/tty1
313_tty=$(tty 2>/dev/null)
314case "$_tty" in
315 /dev/tty1|/dev/console|"not a tty"|"")
316 exec /usr/local/bin/kiosk-session.sh
317 ;;
318esac
319PROFILEEOF
320chown -R 1000:1000 "$ROOTFS_DIR/home/kioskuser" 2>/dev/null || true
321
322# 2h. fstab — tmpfs mounts required for read-only SquashFS root
323cat > "$ROOTFS_DIR/etc/fstab" << 'EOF'
324# SquashFS root is read-only; writable layers via tmpfs
325tmpfs /tmp tmpfs nosuid,nodev,mode=1777 0 0
326tmpfs /run tmpfs nosuid,nodev,mode=0755 0 0
327tmpfs /var/log tmpfs nosuid,nodev,mode=0755 0 0
328tmpfs /var/tmp tmpfs nosuid,nodev,mode=1777 0 0
329tmpfs /var/cache tmpfs nosuid,nodev,mode=0755 0 0
330EOF
331
332# Symlinks that Alpine expects (some scripts use /var/run instead of /run)
333ln -sf /run "$ROOTFS_DIR/var/run"
334ln -sf /run/lock "$ROOTFS_DIR/var/lock"
335
336echo -e " ${GREEN}Alpine rootfs configured${NC}"
337
338# ══════════════════════════════════════════
339# Step 3: Inject kiosk config
340# ══════════════════════════════════════════
341echo -e "${CYAN}[3/6] Injecting kiosk config...${NC}"
342
343# 3a. Place the piece bundle
344mkdir -p "$ROOTFS_DIR/usr/local/share/kiosk"
345cp "$BUNDLE_PATH" "$ROOTFS_DIR/usr/local/share/kiosk/piece.html"
346echo -e " ${GREEN}Piece bundle installed${NC}"
347
348KIOSK_PIECE_URL="http://localhost:8080/piece.html?density=${PACK_DENSITY}"
349
350# 3b. Kiosk session script (adapted for Alpine/OpenRC)
351cat > "$ROOTFS_DIR/usr/local/bin/kiosk-session.sh" << 'SESSEOF'
352#!/bin/sh
353# Alpine Kiosk Session — Cage + Chromium, PipeWire audio
354# Write logs to persistent FEDAC-PIECE partition
355PIECE_LOG=""
356PIECE_DEV=$(blkid -L FEDAC-PIECE 2>/dev/null || true)
357if [ -n "$PIECE_DEV" ]; then
358 mkdir -p /mnt/piece
359 mount "$PIECE_DEV" /mnt/piece 2>/dev/null || true
360 if mountpoint -q /mnt/piece 2>/dev/null && touch /mnt/piece/.writetest 2>/dev/null; then
361 rm -f /mnt/piece/.writetest
362 PIECE_LOG="/mnt/piece/kiosk.log"
363 fi
364fi
365LOG="${PIECE_LOG:-/tmp/kiosk.log}"
366exec > "$LOG" 2>&1
367echo "[kiosk] $(date) — kiosk-session.sh starting (Alpine)"
368echo "[kiosk] log file: $LOG"
369
370export XDG_SESSION_TYPE=wayland
371export XDG_RUNTIME_DIR="/run/user/$(id -u)"
372mkdir -p "$XDG_RUNTIME_DIR"
373chmod 0700 "$XDG_RUNTIME_DIR"
374
375# seatd socket — required for Cage to get DRM/input access
376export LIBSEAT_BACKEND=seatd
377if [ -S /run/seatd.sock ]; then
378 export SEATD_SOCK=/run/seatd.sock
379fi
380
381# Wayland/DRM env for Cage
382export WLR_LIBINPUT_NO_DEVICES=1
383
384# Wait for DRM device (GPU)
385echo "[kiosk] waiting for /dev/dri/card0..."
386for i in $(seq 1 30); do
387 [ -e /dev/dri/card0 ] && break
388 sleep 0.5
389done
390if [ -e /dev/dri/card0 ]; then
391 echo "[kiosk] DRM device ready: $(ls -la /dev/dri/)"
392else
393 echo "[kiosk] WARNING: /dev/dri/card0 not found after 15s"
394 ls -la /dev/dri/ 2>&1 || echo "[kiosk] /dev/dri does not exist"
395fi
396
397# Link piece files from FEDAC-PIECE if available
398if [ -f /mnt/piece/piece.html ]; then
399 for f in /mnt/piece/*.html; do
400 [ -f "$f" ] && ln -sf "$f" "/usr/local/share/kiosk/$(basename "$f")"
401 done
402 echo "[kiosk] linked piece files from FEDAC-PIECE partition"
403 ls -la /usr/local/share/kiosk/*.html 2>&1
404fi
405
406# Start PipeWire audio stack
407if command -v pipewire >/dev/null 2>&1; then
408 pipewire &
409 sleep 0.2
410 command -v wireplumber >/dev/null 2>&1 && wireplumber &
411 command -v pipewire-pulse >/dev/null 2>&1 && pipewire-pulse &
412fi
413
414echo "[kiosk] XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR"
415echo "[kiosk] KIOSK_PIECE_URL=__KIOSK_PIECE_URL__"
416
417# Wait for piece server (port 8080)
418for i in $(seq 1 30); do
419 if python3 -c "import socket; s=socket.socket(); s.settimeout(0.5); s.connect(('127.0.0.1',8080)); s.close()" 2>/dev/null; then
420 echo "[kiosk] piece server ready (attempt $i)"
421 break
422 fi
423 echo "[kiosk] waiting for piece server... ($i/30)"
424 sleep 0.5
425done
426
427# Fallback: if OpenRC service isn't running, start piece server ourselves
428if ! python3 -c "import socket; s=socket.socket(); s.settimeout(0.5); s.connect(('127.0.0.1',8080)); s.close()" 2>/dev/null; then
429 echo "[kiosk] piece server not ready after 15s — starting fallback"
430 python3 /usr/local/bin/kiosk-piece-server.py &
431 sleep 1
432fi
433
434echo "[kiosk] launching cage + chromium"
435echo "[kiosk] LIBSEAT_BACKEND=$LIBSEAT_BACKEND SEATD_SOCK=${SEATD_SOCK:-unset}"
436echo "[kiosk] DRI devices: $(ls /dev/dri/ 2>&1)"
437
438# Check for DRM device — if missing, show diagnostic on console instead of silent fail
439if [ ! -e /dev/dri/card0 ]; then
440 echo "[kiosk] FATAL: no DRM device (/dev/dri/card0) — cage cannot start" >&2
441 # Restore console output so user can see the error
442 exec >/dev/console 2>&1
443 echo ""
444 echo "=== KIOSK ERROR: No GPU/DRM device found ==="
445 echo "cage requires /dev/dri/card0 to run."
446 echo "Check: ls -la /dev/dri/ lspci | grep -i vga dmesg | grep -i drm"
447 echo "Log: $LOG"
448 echo ""
449 # Drop to shell instead of silent exit/respawn loop
450 exec /bin/sh
451fi
452
453# Cage launches a single Wayland app fullscreen with black background
454cage -s -- chromium-browser \
455 --no-first-run \
456 --disable-translate \
457 --disable-infobars \
458 --disable-suggestions-service \
459 --disable-save-password-bubble \
460 --disable-session-crashed-bubble \
461 --disable-component-update \
462 --no-default-browser-check \
463 --autoplay-policy=no-user-gesture-required \
464 --kiosk \
465 --disable-pinch \
466 --overscroll-history-navigation=0 \
467 --enable-features=OverlayScrollbar \
468 --force-device-scale-factor=1 \
469 --disable-background-networking \
470 --disable-sync \
471 --metrics-recording-only \
472 --disable-default-apps \
473 --mute-audio=false \
474 --no-sandbox \
475 --disable-gpu-sandbox \
476 --enable-gpu-rasterization \
477 --enable-zero-copy \
478 --ignore-gpu-blocklist \
479 --enable-features=VaapiVideoDecoder,VaapiVideoEncoder \
480 "__KIOSK_PIECE_URL__"
481CAGE_EXIT=$?
482
483# If cage exits (crash or missing Wayland support), show error on console
484echo "[kiosk] cage exited with code $CAGE_EXIT"
485exec >/dev/console 2>&1
486echo ""
487echo "=== KIOSK: cage exited (code $CAGE_EXIT) ==="
488echo "Log: $LOG"
489echo "Restarting in 5s... (Ctrl+C for shell)"
490sleep 5
491SESSEOF
492
493# Replace placeholder URLs
494sed -i "s|__KIOSK_PIECE_URL__|${KIOSK_PIECE_URL}|g" "$ROOTFS_DIR/usr/local/bin/kiosk-session.sh"
495chmod +x "$ROOTFS_DIR/usr/local/bin/kiosk-session.sh"
496echo -e " ${GREEN}Kiosk session script installed${NC}"
497
498# 3c. Install piece server (Python HTTP + volume API)
499cp "$OVERLAY_DIR/kiosk-piece-server.py" "$ROOTFS_DIR/usr/local/bin/kiosk-piece-server.py"
500chmod +x "$ROOTFS_DIR/usr/local/bin/kiosk-piece-server.py"
501
502# Adapt piece server for Alpine: use kioskuser instead of liveuser
503sed -i 's/USER = "liveuser"/USER = "kioskuser"/' "$ROOTFS_DIR/usr/local/bin/kiosk-piece-server.py"
504
505# 3d. OpenRC service for piece server (instead of systemd)
506cat > "$ROOTFS_DIR/etc/init.d/kiosk-piece-server" << 'OPENRCEOF'
507#!/sbin/openrc-run
508description="FedOS Kiosk Piece Server"
509command="/usr/bin/python3"
510command_args="/usr/local/bin/kiosk-piece-server.py"
511pidfile="/run/${RC_SVCNAME}.pid"
512command_background=true
513
514depend() {
515 need localmount
516 after bootmisc
517}
518OPENRCEOF
519chmod +x "$ROOTFS_DIR/etc/init.d/kiosk-piece-server"
520
521# 3e. Enable OpenRC services in proper runlevels
522# sysinit: hardware/device setup (must run first)
523for svc in devfs dmesg mdev hwdrivers; do
524 chroot "$ROOTFS_DIR" /sbin/rc-update add "$svc" sysinit 2>/dev/null || true
525done
526# boot: filesystem and system setup
527# Alpine eudev provides: udev, udev-trigger, udev-settle, udev-postmount
528for svc in bootmisc hostname modules localmount udev udev-trigger udev-settle; do
529 chroot "$ROOTFS_DIR" /sbin/rc-update add "$svc" boot 2>/dev/null || {
530 echo " (service $svc not available, skipping)"
531 }
532done
533# default: user services (seatd for DRM access, dbus for PipeWire)
534for svc in seatd dbus kiosk-piece-server; do
535 chroot "$ROOTFS_DIR" /sbin/rc-update add "$svc" default 2>/dev/null || true
536done
537
538# 3f. Configure seatd — Alpine uses /etc/conf.d/seatd
539mkdir -p "$ROOTFS_DIR/etc/conf.d"
540cat > "$ROOTFS_DIR/etc/conf.d/seatd" << 'SEATDEOF'
541# Run seatd as root, allow "seat" group
542SEATD_ARGS="-g seat"
543SEATDEOF
544
545# 3g. Kernel modules — Alpine uses /etc/modules (one module per line)
546cat > "$ROOTFS_DIR/etc/modules" << EOF
547# GPU drivers
548i915
549amdgpu
550# Sound
551snd_hda_intel
552snd_hda_codec_hdmi
553# Input
554uinput
555# Squashfs (backup, usually built-in)
556squashfs
557EOF
558
559echo -e " ${GREEN}Kiosk config injected${NC}"
560
561# ── 3h. Ensure squashfs is in the initramfs ──
562# Alpine's linux-lts kernel includes squashfs built-in (CONFIG_SQUASHFS=y).
563# mkinitfs already has a squashfs feature file — just make sure it's enabled.
564echo -e " Enabling squashfs in initramfs..."
565if [ -f "$ROOTFS_DIR/etc/mkinitfs/mkinitfs.conf" ]; then
566 if ! grep -q 'squashfs' "$ROOTFS_DIR/etc/mkinitfs/mkinitfs.conf"; then
567 sed -i '/^features=/ s/"$/ squashfs"/' "$ROOTFS_DIR/etc/mkinitfs/mkinitfs.conf"
568 fi
569fi
570# Regenerate initramfs
571KVER=$(ls "$ROOTFS_DIR/lib/modules/" 2>/dev/null | head -1)
572if [ -n "$KVER" ]; then
573 mount -t proc none "$ROOTFS_DIR/proc" 2>/dev/null || true
574 mount -t sysfs none "$ROOTFS_DIR/sys" 2>/dev/null || true
575 chroot "$ROOTFS_DIR" mkinitfs -o /boot/initramfs-lts "$KVER"
576 umount "$ROOTFS_DIR/proc" 2>/dev/null || true
577 umount "$ROOTFS_DIR/sys" 2>/dev/null || true
578 echo -e " ${GREEN}Initramfs regenerated${NC}"
579fi
580
581# ── 3i. Replace /init in initramfs with our findroot script ──
582# Alpine's nlplug-findfs can't resolve PARTUUID/LABEL for raw SquashFS
583# partitions. We extract the initramfs, rename the original /init to
584# /init.alpine as backup, and replace /init with our findroot script
585# that scans devices and mounts the squashfs root.
586# (Appending a separate cpio archive after gzip doesn't work reliably
587# on all kernels/hardware.)
588INITRD_FILE="$ROOTFS_DIR/boot/initramfs-lts"
589if [ -f "$INITRD_FILE" ]; then
590 INJECT_DIR=$(mktemp -d /tmp/alpine-initrd-inject-XXXX)
591
592 # Extract existing initramfs
593 echo -e " Extracting initramfs for injection..."
594 mkdir -p "$INJECT_DIR/root"
595 (cd "$INJECT_DIR/root" && zcat "$INITRD_FILE" | cpio -id 2>/dev/null)
596
597 # Create /sysroot mount point (Alpine uses /newroot, we need /sysroot)
598 mkdir -p "$INJECT_DIR/root/sysroot"
599
600 # Rename original Alpine init as backup
601 if [ -f "$INJECT_DIR/root/init" ]; then
602 mv "$INJECT_DIR/root/init" "$INJECT_DIR/root/init.alpine"
603 echo -e " Renamed original /init → /init.alpine"
604 fi
605
606 # Replace /init with our findroot script
607 cat > "$INJECT_DIR/root/init" << 'FINDROOTEOF'
608#!/bin/sh
609mount -t proc proc /proc 2>/dev/null
610mount -t sysfs sysfs /sys 2>/dev/null
611mount -t devtmpfs dev /dev 2>/dev/null
612# Trigger device discovery
613mdev -s 2>/dev/null; sleep 1
614# Try partition 2 of all common disk types in order
615for dev in /dev/sda2 /dev/vda2 /dev/nvme0n1p2 /dev/mmcblk0p2 /dev/hda2; do
616 if [ -b "$dev" ] && mount -t squashfs -o ro "$dev" /sysroot 2>/dev/null; then
617 # Mount essential tmpfs dirs before init (root is read-only)
618 mount -t tmpfs -o nosuid,nodev,mode=0755 tmpfs /sysroot/run 2>/dev/null
619 mount -t tmpfs -o nosuid,nodev,mode=1777 tmpfs /sysroot/tmp 2>/dev/null
620 mount -t tmpfs -o nosuid,nodev,mode=0755 tmpfs /sysroot/var/log 2>/dev/null
621 mount -t tmpfs -o nosuid,nodev,mode=1777 tmpfs /sysroot/var/tmp 2>/dev/null
622 mount -t tmpfs -o nosuid,nodev,mode=0755 tmpfs /sysroot/var/cache 2>/dev/null
623 # OpenRC needs writable /run/openrc for state tracking
624 mkdir -p /sysroot/run/openrc
625 exec switch_root /sysroot /sbin/init
626 fi
627done
628echo "findroot: could not find squashfs root partition"
629exec /bin/sh
630FINDROOTEOF
631 chmod +x "$INJECT_DIR/root/init"
632
633 # Repack as a single gzip cpio archive
634 echo -e " Repacking initramfs with findroot as /init..."
635 (cd "$INJECT_DIR/root" && find . | cpio -o -H newc --quiet | gzip -1 > "$INITRD_FILE")
636 rm -rf "$INJECT_DIR"
637 echo -e " ${GREEN}Replaced /init in initramfs with findroot${NC}"
638fi
639
640# ══════════════════════════════════════════
641# Step 4: Build SquashFS + prepare boot files
642# ══════════════════════════════════════════
643echo -e "${CYAN}[4/6] Building SquashFS image...${NC}"
644
645# 4a. Copy kernel and initramfs out before SquashFS compression
646KERNEL_SRC=$(ls "$ROOTFS_DIR/boot/vmlinuz-"*lts* 2>/dev/null | head -1)
647INITRD_SRC=$(ls "$ROOTFS_DIR/boot/initramfs-"*lts* 2>/dev/null | head -1)
648
649if [ -z "$KERNEL_SRC" ]; then
650 echo -e "${RED}Error: No kernel found in rootfs${NC}"
651 ls "$ROOTFS_DIR/boot/" 2>&1
652 exit 1
653fi
654
655cp "$KERNEL_SRC" "$WORK_DIR/vmlinuz"
656echo -e " Kernel: $(basename $KERNEL_SRC)"
657
658if [ -n "$INITRD_SRC" ] && [ -f "$INITRD_SRC" ]; then
659 cp "$INITRD_SRC" "$WORK_DIR/initramfs"
660 echo -e " Initramfs: $(basename $INITRD_SRC)"
661else
662 # Generate a minimal initramfs if not provided
663 echo -e " ${YELLOW}No initramfs found, generating...${NC}"
664 if command -v mkinitfs >/dev/null 2>&1; then
665 KVER=$(basename "$KERNEL_SRC" | sed 's/vmlinuz-//')
666 chroot "$ROOTFS_DIR" mkinitfs -o /boot/initramfs "$KVER" 2>/dev/null || true
667 INITRD_SRC=$(ls "$ROOTFS_DIR/boot/initramfs"* 2>/dev/null | head -1)
668 [ -f "$INITRD_SRC" ] && cp "$INITRD_SRC" "$WORK_DIR/initramfs"
669 fi
670 if [ ! -f "$WORK_DIR/initramfs" ]; then
671 echo -e "${RED}Error: Could not generate initramfs${NC}"
672 exit 1
673 fi
674fi
675
676# 4b. Remove boot directory from rootfs (kernel/initramfs go on EFI partition)
677rm -rf "$ROOTFS_DIR/boot"
678mkdir -p "$ROOTFS_DIR/boot"
679
680# 4c. Clean up to minimize image size
681rm -rf "$ROOTFS_DIR/var/cache/apk"/*
682rm -rf "$ROOTFS_DIR/usr/share/doc"/*
683rm -rf "$ROOTFS_DIR/usr/share/man"/*
684rm -rf "$ROOTFS_DIR/usr/share/info"/*
685find "$ROOTFS_DIR/usr/share/locale" -mindepth 1 -maxdepth 1 ! -name 'en*' -exec rm -rf {} + 2>/dev/null || true
686
687ROOTFS_SIZE=$(du -sm "$ROOTFS_DIR" | awk '{print $1}')
688echo -e " Rootfs size before compression: ${ROOTFS_SIZE}MB"
689
690# 4d. Build SquashFS
691# SquashFS is built into Alpine's linux-lts kernel (CONFIG_SQUASHFS=y).
692# Root device is found at boot by /findroot (injected into initramfs).
693SFS_PATH="$WORK_DIR/rootfs.squashfs"
694mksquashfs "$ROOTFS_DIR/" "$SFS_PATH" -comp lz4 -Xhc -noappend -no-progress -quiet
695SFS_SIZE=$(stat -c%s "$SFS_PATH")
696echo -e " ${GREEN}SquashFS built: $(numfmt --to=iec $SFS_SIZE)${NC}"
697
698# ══════════════════════════════════════════
699# Step 5: Build image and/or flash USB
700# ══════════════════════════════════════════
701echo -e "${CYAN}[5/6] Building output media...${NC}"
702
703build_target() {
704 local target="$1"
705 local label="$2"
706
707 # Unmount existing partitions
708 for part in "${target}"*; do
709 umount "$part" 2>/dev/null || true
710 done
711
712 # Wipe and partition: EFI + ROOT (SquashFS) + PIECE
713 echo -e " Creating partition table on ${label}..."
714 wipefs -a "$target" >/dev/null 2>&1
715
716 local dev_bytes
717 dev_bytes=$(blockdev --getsize64 "$target" 2>/dev/null || stat -c%s "$target" 2>/dev/null)
718 local dev_mib=$((dev_bytes / 1048576))
719
720 # EFI: 128MB (kernel + initramfs + grub), ROOT: bulk, PIECE: 20MB at end
721 local efi_end=129 # 1MiB start + 128MiB
722 local piece_start=$((dev_mib - 20))
723
724 parted -s "$target" mklabel gpt
725 parted -s "$target" mkpart '"EFI"' fat32 1MiB "${efi_end}MiB"
726 parted -s "$target" set 1 esp on
727 parted -s "$target" mkpart '"ROOT"' "${efi_end}MiB" "${piece_start}MiB"
728 parted -s "$target" mkpart '"PIECE"' ext4 "${piece_start}MiB" 100%
729
730 sleep 2
731 partprobe "$target" 2>/dev/null || true
732 sleep 2
733
734 # Detect partition names
735 local p1="${target}1"
736 local p2="${target}2"
737 local p3="${target}3"
738 [ -b "$p1" ] || p1="${target}p1"
739 [ -b "$p2" ] || p2="${target}p2"
740 [ -b "$p3" ] || p3="${target}p3"
741
742 # Format
743 mkfs.vfat -F 32 -n BOOT "$p1" >/dev/null
744 # p2 (ROOT) gets raw SquashFS written later — no filesystem format needed
745 mkfs.ext4 -L FEDAC-PIECE -q "$p3"
746 echo -e " ${GREEN}${label}: partitions created (EFI + ROOT + PIECE)${NC}"
747
748 # Write piece files to FEDAC-PIECE partition
749 PIECE_MOUNT_TMP=$(mktemp -d /tmp/alpine-piece-XXXX)
750 mount "$p3" "$PIECE_MOUNT_TMP"
751 cp "$BUNDLE_PATH" "$PIECE_MOUNT_TMP/piece.html"
752 chmod 777 "$PIECE_MOUNT_TMP"
753 sync
754 umount "$PIECE_MOUNT_TMP"
755 rmdir "$PIECE_MOUNT_TMP"
756 PIECE_MOUNT_TMP=""
757 echo -e " ${GREEN}${label}: piece.html written to FEDAC-PIECE${NC}"
758
759 # Write SquashFS rootfs directly to ROOT partition (raw)
760 echo -e " Writing SquashFS rootfs to ROOT partition..."
761 dd if="$SFS_PATH" of="$p2" bs=4M conv=fsync 2>/dev/null
762 echo -e " ${GREEN}${label}: SquashFS rootfs written${NC}"
763
764 # Set up EFI partition
765 EFI_MOUNT=$(mktemp -d /tmp/alpine-efi-XXXX)
766 mount "$p1" "$EFI_MOUNT"
767
768 # Copy kernel and initramfs
769 mkdir -p "$EFI_MOUNT/boot"
770 cp "$WORK_DIR/vmlinuz" "$EFI_MOUNT/boot/vmlinuz"
771 cp "$WORK_DIR/initramfs" "$EFI_MOUNT/boot/initramfs"
772
773 # Install GRUB EFI
774 mkdir -p "$EFI_MOUNT/EFI/BOOT"
775
776 # Try to get grub from rootfs or host
777 if [ -f "$ROOTFS_DIR/usr/lib/grub/x86_64-efi/monolithic/grubx64.efi" ]; then
778 cp "$ROOTFS_DIR/usr/lib/grub/x86_64-efi/monolithic/grubx64.efi" "$EFI_MOUNT/EFI/BOOT/BOOTX64.EFI"
779 elif command -v grub2-mkimage >/dev/null 2>&1; then
780 grub2-mkimage -O x86_64-efi -o "$EFI_MOUNT/EFI/BOOT/BOOTX64.EFI" \
781 normal linux fat part_gpt efi_gop efi_uga search search_label all_video gzio 2>/dev/null || true
782 elif command -v grub-mkimage >/dev/null 2>&1; then
783 grub-mkimage -O x86_64-efi -o "$EFI_MOUNT/EFI/BOOT/BOOTX64.EFI" \
784 normal linux fat part_gpt efi_gop efi_uga search search_label all_video gzio 2>/dev/null || true
785 fi
786
787 # If we still don't have a GRUB EFI binary, try copying from host
788 if [ ! -f "$EFI_MOUNT/EFI/BOOT/BOOTX64.EFI" ]; then
789 for candidate in \
790 /boot/efi/EFI/fedora/grubx64.efi \
791 /usr/lib/grub/x86_64-efi/monolithic/grubx64.efi \
792 /usr/share/grub/x86_64-efi/grubx64.efi; do
793 if [ -f "$candidate" ]; then
794 cp "$candidate" "$EFI_MOUNT/EFI/BOOT/BOOTX64.EFI"
795 break
796 fi
797 done
798 fi
799
800 if [ ! -f "$EFI_MOUNT/EFI/BOOT/BOOTX64.EFI" ]; then
801 echo -e "${RED}Warning: No GRUB EFI binary found — image may not boot${NC}"
802 fi
803
804 # GRUB config — Alpine uses direct kernel boot (no live image)
805 cat > "$EFI_MOUNT/EFI/BOOT/grub.cfg" << GRUBEOF
806set default=0
807set timeout=0
808insmod all_video
809insmod gzio
810insmod part_gpt
811insmod fat
812set gfxmode=auto
813set gfxpayload=keep
814terminal_input console
815terminal_output gfxterm
816menuentry "FedOS Alpine" {
817 linux /boot/vmlinuz rdinit=/init console=tty0 ro quiet loglevel=0 mitigations=off
818 initrd /boot/initramfs
819}
820GRUBEOF
821
822 # Also put grub.cfg where some firmware looks
823 mkdir -p "$EFI_MOUNT/EFI/fedora"
824 cp "$EFI_MOUNT/EFI/BOOT/grub.cfg" "$EFI_MOUNT/EFI/fedora/grub.cfg"
825
826 sync
827 umount "$EFI_MOUNT"
828 rmdir "$EFI_MOUNT"
829 EFI_MOUNT=""
830 echo -e " ${GREEN}${label}: EFI configured${NC}"
831}
832
833if [ -n "$IMAGE_PATH" ]; then
834 echo -e " Building disk image: ${GREEN}${IMAGE_PATH}${NC} (${IMAGE_SIZE_GB}GiB)"
835 truncate -s "${IMAGE_SIZE_GB}G" "$IMAGE_PATH"
836 LOOP_DEV=$(losetup --find --show --partscan "$IMAGE_PATH")
837 build_target "$LOOP_DEV" "Disk image"
838 losetup -d "$LOOP_DEV"
839 LOOP_DEV=""
840 echo -e " ${GREEN}Disk image ready${NC}"
841fi
842
843if [ -n "$DEVICE" ]; then
844 echo ""
845 echo -e "USB target: ${YELLOW}$DEVICE${NC}"
846 lsblk "$DEVICE" 2>/dev/null || true
847 echo ""
848
849 if [ "$SKIP_CONFIRM" = false ]; then
850 echo -e "${RED}ALL DATA ON $DEVICE WILL BE DESTROYED${NC}"
851 read -p "Continue? [y/N] " confirm
852 [ "$confirm" = "y" ] || [ "$confirm" = "Y" ] || { echo "Aborted."; exit 0; }
853 fi
854
855 if [ -n "$IMAGE_PATH" ]; then
856 echo -e " Flashing image to USB..."
857 dd if="$IMAGE_PATH" of="$DEVICE" bs=4M status=progress conv=fsync
858 sync
859 echo -e " ${GREEN}USB flashed from image${NC}"
860 else
861 build_target "$DEVICE" "USB"
862 fi
863fi
864
865# ══════════════════════════════════════════
866# Step 6: Finalize
867# ══════════════════════════════════════════
868echo -e "${CYAN}[6/6] Finalizing...${NC}"
869sync
870
871# Generate manifest for base images (used by oven /os endpoint)
872if [ "$BASE_IMAGE_MODE" = true ] && [ -n "$IMAGE_PATH" ] && [ -f "$IMAGE_PATH" ]; then
873 MANIFEST_PATH="${IMAGE_PATH%.img}-manifest.json"
874 IMG_SIZE=$(stat -c%s "$IMAGE_PATH")
875 IMG_SHA256=$(sha256sum "$IMAGE_PATH" | awk '{print $1}')
876 # Get FEDAC-PIECE partition offset/size from GPT table (robust, no fdisk guessing).
877 PIECE_META=$(python3 -c "
878import subprocess
879out = subprocess.check_output(['parted', '-s', '-m', '$IMAGE_PATH', 'unit', 'B', 'print'], text=True)
880part3 = None
881for raw in out.splitlines():
882 line = raw.strip()
883 if not line or not line[0].isdigit() or ':' not in line:
884 continue
885 cols = line.split(':')
886 if len(cols) < 4:
887 continue
888 num = cols[0]
889 start = int(cols[1].rstrip('B'))
890 size = int(cols[3].rstrip('B'))
891 name = cols[5].rstrip(';').strip().upper() if len(cols) > 5 else ''
892 if name == 'PIECE':
893 print(f'{start}:{size}')
894 raise SystemExit(0)
895 if num == '3':
896 part3 = (start, size)
897if part3:
898 print(f'{part3[0]}:{part3[1]}')
899" 2>/dev/null || true)
900 PIECE_OFFSET=${PIECE_META%%:*}
901 PIECE_SIZE=${PIECE_META##*:}
902 [ -n "${PIECE_OFFSET:-}" ] || PIECE_OFFSET="0"
903 [ -n "${PIECE_SIZE:-}" ] || PIECE_SIZE=$((20 * 1024 * 1024))
904 cat > "$MANIFEST_PATH" << MANIFEST_EOF
905{
906 "version": "$(date +%Y-%m-%d)",
907 "flavor": "alpine",
908 "alpine": "${ALPINE_VERSION}",
909 "piecePartitionOffset": ${PIECE_OFFSET},
910 "piecePartitionSize": ${PIECE_SIZE},
911 "totalSize": ${IMG_SIZE},
912 "sha256": "${IMG_SHA256}"
913}
914MANIFEST_EOF
915 echo -e " ${GREEN}Manifest written: ${MANIFEST_PATH}${NC}"
916fi
917
918if [ "$DO_EJECT" = true ] && [ -n "$DEVICE" ]; then
919 eject "$DEVICE" 2>/dev/null || true
920 echo -e " ${GREEN}Ejected${NC}"
921fi
922
923# Clean up work dir
924if [ "$OWN_ROOTFS" = true ]; then
925 echo -e " Cleaning work dir..."
926 rm -rf "$WORK_DIR"
927fi
928
929echo ""
930echo -e "${GREEN}╔═══════════════════════════════════════════════╗${NC}"
931echo -e "${GREEN}║ Alpine Kiosk Build Ready ║${NC}"
932echo -e "${GREEN}║ Piece: ${CYAN}${PIECE_CODE}${GREEN} (offline, ~400MB) ║${NC}"
933echo -e "${GREEN}╚═══════════════════════════════════════════════╝${NC}"
934[ -n "$IMAGE_PATH" ] && echo -e " Image: ${GREEN}${IMAGE_PATH}${NC}"
935[ -n "$DEVICE" ] && echo -e " USB: ${GREEN}${DEVICE}${NC}"
936echo ""