#!/bin/bash # make-kiosk-piece-usb.sh — Create a FedAC kiosk USB with a bundled KidLisp piece # # Pipeline: fetch piece bundle from oven → inject into Fedora live rootfs → flash to USB # # Boot flow: # Power on → GRUB (instant) → PALS Plymouth splash → mutter (no desktop) # → Firefox fullscreen → bundled piece (offline, no WiFi needed) # # Usage: # sudo bash fedac/scripts/make-kiosk-piece-usb.sh [] [options] # # Examples: # sudo bash fedac/scripts/make-kiosk-piece-usb.sh roz /dev/sdb # sudo bash fedac/scripts/make-kiosk-piece-usb.sh '$cow' --image /tmp/fedac-roz.img # sudo bash fedac/scripts/make-kiosk-piece-usb.sh roz /dev/sdb --image /tmp/fedac-roz.img # # Options: # --rootfs Use existing extracted rootfs (skip ISO download + extract) # --iso Use local ISO instead of downloading # --bundle Use local HTML bundle instead of fetching from oven # --rebuild-bundle Rebuild local bundle with in-repo bundler before use # --image Build a bootable disk image file (.img) # --image-size Image size in GiB (default: 16) # --base-image Build base image without a specific piece (placeholder only) # --density Default pack/runtime density query value (default: 8) # --work-base Directory for large temp work files (default: /tmp) # --no-eject Don't eject when done # --yes Skip confirmation prompts # # Requirements: # erofs-utils (mkfs.erofs), curl, dracut (for initrd rebuild) # ~5GB free temp space (or less if using --rootfs) set -euo pipefail RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' PINK='\033[38;5;205m' NC='\033[0m' SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" FEDAC_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" OVERLAY_DIR="$FEDAC_DIR/overlays/kiosk" REPO_ROOT="$(cd "$FEDAC_DIR/.." && pwd)" # Fedora 43 Workstation FEDORA_VERSION="43" FEDORA_RELEASE="43-1.6" FEDORA_ISO_NAME="Fedora-Workstation-Live-${FEDORA_RELEASE}.x86_64.iso" FEDORA_URL="https://dl.fedoraproject.org/pub/fedora/linux/releases/${FEDORA_VERSION}/Workstation/x86_64/iso/${FEDORA_ISO_NAME}" FEDORA_SHA256="2a4a16c009244eb5ab2198700eb04103793b62407e8596f30a3e0cc8ac294d77" CACHE_DIR="${HOME}/Downloads" # Oven pack/bundle endpoints (pack is preferred; bundle kept as fallback) OVEN_PACK_URL="https://oven.aesthetic.computer/pack-html" OVEN_BUNDLE_URL="https://oven.aesthetic.computer/bundle-html" PACK_DENSITY_DEFAULT="8" usage() { echo -e "${PINK}FedAC Kiosk Piece USB Creator${NC}" echo "" echo "Creates a live USB that boots into a fullscreen KidLisp piece (offline)." echo "" echo "Usage: sudo $0 [] [options]" echo "" echo " KidLisp piece code (e.g., roz, \$cow)" echo " Optional USB block device (e.g., /dev/sdb)" echo "" echo "Options:" echo " --rootfs Use existing extracted rootfs directory" echo " --iso Use existing ISO instead of downloading" echo " --bundle Use local HTML bundle (skip oven fetch)" echo " --rebuild-bundle Rebuild local bundle with in-repo bundler before use" echo " --density Default pack/runtime density query value (default: 8)" echo " --no-eject Don't eject the USB when done" echo " --yes Skip confirmation prompts" echo " --help Show this help" exit 1 } cleanup() { echo -e "\n${YELLOW}Cleaning up mounts...${NC}" for mp in "${ISO_MOUNT:-}" "${ROOTFS_MOUNT:-}" "${EFI_MOUNT:-}" "${LIVE_MOUNT:-}"; do [ -n "$mp" ] && mountpoint -q "$mp" 2>/dev/null && umount "$mp" 2>/dev/null || true done if [ -n "${LOOP_DEV:-}" ]; then losetup -d "$LOOP_DEV" 2>/dev/null || true LOOP_DEV="" fi } trap cleanup EXIT # ── Parse args ── PIECE_CODE="" DEVICE="" ROOTFS_DIR="" ISO_PATH="" LOCAL_BUNDLE_PATH="" IMAGE_PATH="" IMAGE_SIZE_GB="16" WORK_BASE="/tmp" DO_EJECT=true SKIP_CONFIRM=false BASE_IMAGE_MODE=false PACK_DENSITY="$PACK_DENSITY_DEFAULT" REBUILD_BUNDLE=false while [ $# -gt 0 ]; do case "$1" in --rootfs) ROOTFS_DIR="$2"; shift 2 ;; --iso) ISO_PATH="$2"; shift 2 ;; --bundle) LOCAL_BUNDLE_PATH="$2"; shift 2 ;; --rebuild-bundle) REBUILD_BUNDLE=true; shift ;; --image) IMAGE_PATH="$2"; shift 2 ;; --image-size) IMAGE_SIZE_GB="$2"; shift 2 ;; --density) PACK_DENSITY="$2"; shift 2 ;; --work-base) WORK_BASE="$2"; shift 2 ;; --base-image) BASE_IMAGE_MODE=true; shift ;; --no-eject) DO_EJECT=false; shift ;; --yes) SKIP_CONFIRM=true; shift ;; --help|-h) usage ;; /dev/*) DEVICE="$1"; shift ;; *) if [ -z "$PIECE_CODE" ]; then PIECE_CODE="$1"; shift; else echo -e "${RED}Unknown arg: $1${NC}"; usage; fi ;; esac done if [ "$BASE_IMAGE_MODE" = true ]; then [ -n "$PIECE_CODE" ] || PIECE_CODE="__base__" else [ -n "$PIECE_CODE" ] || { echo -e "${RED}Error: No piece code specified${NC}"; usage; } fi [ -n "$DEVICE" ] || [ -n "$IMAGE_PATH" ] || { echo -e "${RED}Error: Specify a USB device and/or --image path${NC}" usage } [ -z "$DEVICE" ] || [ -b "$DEVICE" ] || { echo -e "${RED}Error: $DEVICE is not a block device${NC}"; exit 1; } [ -z "$IMAGE_PATH" ] || [[ "$IMAGE_SIZE_GB" =~ ^[0-9]+$ ]] || { echo -e "${RED}Error: --image-size must be an integer GiB value${NC}" exit 1 } [ -z "$LOCAL_BUNDLE_PATH" ] || [ -f "$LOCAL_BUNDLE_PATH" ] || { echo -e "${RED}Error: --bundle '$LOCAL_BUNDLE_PATH' not found${NC}" exit 1 } [[ "$PACK_DENSITY" =~ ^[1-9][0-9]*$ ]] || { echo -e "${RED}Error: --density must be a positive integer${NC}" exit 1 } [ -d "$WORK_BASE" ] || mkdir -p "$WORK_BASE" [ -d "$WORK_BASE" ] || { echo -e "${RED}Error: --work-base '$WORK_BASE' is not accessible${NC}" exit 1 } if [ "$(id -u)" -ne 0 ]; then echo -e "${RED}Error: Must run as root (sudo)${NC}" exit 1 fi # Safety: refuse system disks if [ -n "$DEVICE" ]; then case "$DEVICE" in /dev/nvme*|/dev/vda|/dev/xvda) echo -e "${RED}REFUSED: $DEVICE looks like a system disk.${NC}" exit 1 ;; /dev/sda) RM_FLAG="$(lsblk -dn -o RM "$DEVICE" 2>/dev/null || echo "")" if [ "$RM_FLAG" != "1" ]; then echo -e "${RED}REFUSED: $DEVICE looks like a system disk.${NC}" exit 1 fi echo -e "${YELLOW}Warning: allowing /dev/sda because lsblk reports RM=1 (removable).${NC}" ;; esac fi # Check tools for tool in mkfs.erofs curl; do command -v "$tool" &>/dev/null || { echo -e "${RED}Error: $tool not found${NC}"; exit 1; } done if [ -n "$IMAGE_PATH" ]; then for tool in losetup; do command -v "$tool" &>/dev/null || { echo -e "${RED}Error: $tool not found${NC}"; exit 1; } done fi echo -e "${PINK}╔═══════════════════════════════════════════════╗${NC}" echo -e "${PINK}║ FedAC Kiosk Piece USB Creator ║${NC}" echo -e "${PINK}║ Piece: ${CYAN}${PIECE_CODE}${PINK} → offline bootable USB ║${NC}" echo -e "${PINK}╚═══════════════════════════════════════════════╝${NC}" echo "" WORK_DIR=$(mktemp -d "$WORK_BASE/fedac-kiosk-XXXX") echo -e "Work dir: $WORK_DIR" echo "" # Build bundle with local in-repo bundler (fallback when oven is unreachable). build_local_bundle() { local piece_code="$1" local output_path="$2" local bundler_script="$FEDAC_DIR/scripts/build-local-piece-bundle.mjs" if ! command -v node >/dev/null 2>&1; then echo -e "${RED}Error: node is required for local bundle rebuild${NC}" return 1 fi if [ ! -f "$bundler_script" ]; then echo -e "${RED}Error: local bundler script not found: $bundler_script${NC}" return 1 fi echo -e " ${YELLOW}Building bundle locally (oven unavailable or rebuild requested)...${NC}" node "$bundler_script" "$piece_code" --out "$output_path" --density "$PACK_DENSITY" --nocompress --keeplabel } # ══════════════════════════════════════════ # Step 1: Fetch piece bundle from oven # ══════════════════════════════════════════ BUNDLE_PATH="$WORK_DIR/piece.html" if [ "$BASE_IMAGE_MODE" = true ]; then echo -e "${CYAN}[1/6] Base image mode — generating placeholder piece...${NC}" cat > "$BUNDLE_PATH" << 'PLACEHOLDER_EOF' FedAC Base

No piece loaded. Build with aesthetic.computer/os

PLACEHOLDER_EOF echo -e " ${GREEN}Placeholder piece.html generated${NC}" elif [ -n "$LOCAL_BUNDLE_PATH" ]; then if [ "$REBUILD_BUNDLE" = true ]; then echo -e "${CYAN}[1/6] Rebuilding local bundle: ${LOCAL_BUNDLE_PATH}${NC}" build_local_bundle "$PIECE_CODE" "$LOCAL_BUNDLE_PATH" else echo -e "${CYAN}[1/6] Using local bundle: ${LOCAL_BUNDLE_PATH}${NC}" fi cp "$LOCAL_BUNDLE_PATH" "$BUNDLE_PATH" BUNDLE_SIZE=$(stat -c%s "$BUNDLE_PATH") echo -e " ${GREEN}Local bundle copied: $(numfmt --to=iec $BUNDLE_SIZE)${NC}" else # Always use local bundler first (ensures keeplabel and latest code). # Falls back to oven only if local build fails (e.g., no node available). echo -e "${CYAN}[1/6] Building piece bundle locally: ${PIECE_CODE}...${NC}" if build_local_bundle "$PIECE_CODE" "$BUNDLE_PATH"; then BUNDLE_SIZE=$(stat -c%s "$BUNDLE_PATH") echo -e " ${GREEN}Local bundle built: $(numfmt --to=iec $BUNDLE_SIZE)${NC}" else echo -e "${YELLOW}Local bundle build failed — falling back to oven...${NC}" HTTP_CODE="000" USED_ENDPOINT="" USED_MODE="" if [[ "$PIECE_CODE" == \$* ]]; then BUNDLE_MODES=(code piece) else BUNDLE_MODES=(piece code) fi for endpoint in "$OVEN_PACK_URL" "$OVEN_BUNDLE_URL"; do for mode in "${BUNDLE_MODES[@]}"; do BUNDLE_URL="${endpoint}?${mode}=${PIECE_CODE}&nocompress=1&density=${PACK_DENSITY}&keeplabel=1" echo -e " URL: $BUNDLE_URL" HTTP_CODE="$(curl -s --connect-timeout 8 --max-time 25 --retry 1 --retry-delay 1 \ -o "$BUNDLE_PATH" -w "%{http_code}" "$BUNDLE_URL" || echo "000")" if [ "$HTTP_CODE" = "200" ]; then USED_ENDPOINT="$endpoint" USED_MODE="$mode" break 2 fi echo -e " ${YELLOW}${mode} via ${endpoint##*/} failed (HTTP ${HTTP_CODE})${NC}" done done if [ "$HTTP_CODE" != "200" ]; then echo -e "${RED}Failed to build bundle locally and from oven.${NC}" echo " Check that '$PIECE_CODE' is valid and Node/oven are available." exit 1 fi BUNDLE_SIZE=$(stat -c%s "$BUNDLE_PATH") echo -e " ${GREEN}Bundle fetched: $(numfmt --to=iec $BUNDLE_SIZE)${NC}" echo -e " ${GREEN}Source: ${USED_ENDPOINT##*/} (${USED_MODE}), density=${PACK_DENSITY}${NC}" fi fi # Quick sanity check — bundle should contain HTML if ! head -1 "$BUNDLE_PATH" | grep -qi "/tmp/fedac-rootfs-mount.err; then # Copy rootfs to work dir echo -e " Copying rootfs (this takes a while)..." cp -a "$ROOTFS_MOUNT"/* "$ROOTFS_DIR"/ umount "$ROOTFS_MOUNT" rmdir "$ROOTFS_MOUNT" ROOTFS_MOUNT="" else MOUNT_ERR="$(cat /tmp/fedac-rootfs-mount.err 2>/dev/null || true)" rm -f /tmp/fedac-rootfs-mount.err echo -e " ${YELLOW}Mounting LiveOS image failed (${MOUNT_ERR})${NC}" echo -e " ${CYAN}Falling back to direct extraction...${NC}" rmdir "$ROOTFS_MOUNT" 2>/dev/null || true ROOTFS_MOUNT="" IMG_INFO="$(file -b "$SQUASH_IMG" 2>/dev/null || true)" if echo "$IMG_INFO" | grep -qi "erofs"; then command -v fsck.erofs >/dev/null 2>&1 || { echo -e "${RED}Error: fsck.erofs not found for EROFS extraction${NC}" exit 1 } fsck.erofs --extract="$ROOTFS_DIR" --force --overwrite "$SQUASH_IMG" >/tmp/fedac-erofs-extract.log 2>&1 tail -n 20 /tmp/fedac-erofs-extract.log || true rm -f /tmp/fedac-erofs-extract.log else command -v unsquashfs >/dev/null 2>&1 || { echo -e "${RED}Error: unsquashfs not found for squashfs extraction${NC}" exit 1 } unsquashfs -f -d "$ROOTFS_DIR" "$SQUASH_IMG" fi fi umount "$ISO_MOUNT" rmdir "$ISO_MOUNT" ISO_MOUNT="" echo -e " ${GREEN}Rootfs extracted${NC}" fi # ══════════════════════════════════════════ # Step 3: Inject kiosk config into rootfs # ══════════════════════════════════════════ echo -e "${CYAN}[3/6] Injecting kiosk config...${NC}" # 3a. Place the piece bundle mkdir -p "$ROOTFS_DIR/usr/local/share/kiosk" cp "$BUNDLE_PATH" "$ROOTFS_DIR/usr/local/share/kiosk/piece.html" echo -e " ${GREEN}Piece bundle installed${NC}" # Install kiosk shell wrapper (HUD overlay for volume/battery) SHELL_HTML="$FEDAC_DIR/overlays/kiosk/kiosk-shell.html" if [ -f "$SHELL_HTML" ]; then cp "$SHELL_HTML" "$ROOTFS_DIR/usr/local/share/kiosk/kiosk-shell.html" sed -i "s|__DENSITY__|${PACK_DENSITY}|g" "$ROOTFS_DIR/usr/local/share/kiosk/kiosk-shell.html" echo -e " ${GREEN}Kiosk shell (HUD overlay) installed${NC}" fi # Use piece server (localhost:8080) so the shell wrapper can access system APIs. # The piece server serves kiosk-shell.html at / which iframes piece.html. # kiosk-session.sh waits for the piece server before launching Firefox. KIOSK_PIECE_URL="http://localhost:8080/" # NOTE: Wayland compositor runtime is installed AFTER the rootfs strip step # (step 3i) so package removals cannot accidentally remove launcher deps. # Volume key daemon is pure Python — no external packages needed. # 3b. Kiosk session script cat > "$ROOTFS_DIR/usr/local/bin/kiosk-session.sh" << 'SESSEOF' #!/bin/bash # FedAC Kiosk Session - Wayland primary (Weston), Cage fallback. # Write logs to persistent FEDAC-PIECE partition (readable from another machine) # FEDAC-PIECE is auto-mounted by systemd (mnt-piece.mount unit) — no blkid/mount needed here. PIECE_LOG="" if mountpoint -q /mnt/piece 2>/dev/null && touch /mnt/piece/.writetest 2>/dev/null; then rm -f /mnt/piece/.writetest PIECE_LOG="/mnt/piece/kiosk.log" fi LOG="${PIECE_LOG:-/tmp/kiosk.log}" TTY_DEV="$(tty 2>/dev/null || true)" [ -n "$TTY_DEV" ] || TTY_DEV="/dev/tty1" if [ -w "$TTY_DEV" ]; then exec > >(tee -a "$LOG" "$TTY_DEV") 2>&1 else exec > "$LOG" 2>&1 fi echo "[kiosk] $(date) — kiosk-session.sh starting" echo "[kiosk] log file: $LOG" export XDG_SESSION_TYPE=wayland export XDG_RUNTIME_DIR="/run/user/$(id -u)" export MOZ_ENABLE_WAYLAND=1 export MOZ_WEBRENDER=0 export MOZ_X11_EGL=0 export GDK_BACKEND=wayland export LIBGL_ALWAYS_SOFTWARE=1 export MESA_LOADER_DRIVER_OVERRIDE=llvmpipe export MOZ_DBUS_REMOTE=0 export NO_AT_BRIDGE=1 export CLUTTER_DISABLE_ANIMATIONS=1 export WLR_RENDERER=pixman export WLR_NO_HARDWARE_CURSORS=1 PROFILE="/home/liveuser/.mozilla/firefox/kiosk" WESTON_BIN="$(command -v weston 2>/dev/null || true)" CAGE_BIN="$(command -v cage 2>/dev/null || true)" WAYLAND_LOG="${LOG%.log}-wayland.log" FIREFOX_LOG="${LOG%.log}-firefox.log" echo "[kiosk] checking binaries: weston=${WESTON_BIN:-MISSING} cage=${CAGE_BIN:-MISSING} firefox=$(command -v firefox 2>/dev/null || echo MISSING)" echo "[kiosk] wayland log: $WAYLAND_LOG" echo "[kiosk] firefox log: $FIREFOX_LOG" mkdir -p "$PROFILE/chrome" cat > "$PROFILE/user.js" << 'USERJSEOF' user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true); user_pref("browser.sessionstore.resume_from_crash", false); user_pref("browser.tabs.drawInTitlebar", false); user_pref("browser.tabs.inTitlebar", 0); user_pref("browser.startup.homepage", "__KIOSK_PIECE_URL__"); user_pref("browser.startup.page", 1); user_pref("browser.fullscreen.autohide", false); user_pref("full-screen-api.warning.timeout", 0); user_pref("full-screen-api.transition.timeout", 0); user_pref("media.autoplay.default", 0); user_pref("media.autoplay.blocking_policy", 0); user_pref("media.autoplay.block-webaudio", false); user_pref("layers.acceleration.disabled", true); user_pref("gfx.webrender.force-disabled", true); user_pref("accessibility.typeaheadfind", false); user_pref("accessibility.typeaheadfind.manual", false); user_pref("accessibility.typeaheadfind.linksonly", false); // Block notification prompts, allow everything else (kiosk = trust all) user_pref("permissions.default.desktop-notification", 2); user_pref("permissions.default.geo", 1); user_pref("permissions.default.camera", 1); user_pref("permissions.default.microphone", 1); user_pref("permissions.default.xr", 1); user_pref("dom.webnotifications.enabled", false); user_pref("dom.push.enabled", false); user_pref("dom.push.connection.enabled", false); // MIDI: allow without prompting (including SysEx) user_pref("midi.prompt_on_sysex_access", false); user_pref("midi.testing", true); user_pref("dom.webmidi.enabled", true); // Suppress ALL info bars, notification bars, and popup prompts user_pref("browser.uitour.enabled", false); user_pref("browser.discovery.enabled", false); user_pref("extensions.getAddons.showPane", false); user_pref("browser.protections_panel.infoMessage.seen", true); user_pref("browser.contentblocking.introCount", 99); user_pref("browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons", false); user_pref("browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features", false); user_pref("security.insecure_connection_text.enabled", false); user_pref("security.insecure_connection_text.pbmode.enabled", false); user_pref("security.insecure_field_warning.contextual.enabled", false); // Pre-grant permissions for localhost so no prompt appears user_pref("permissions.manager.defaultsUrl", ""); USERJSEOF # Pre-populate permissions.sqlite — grant all permissions for localhost:8080 # This prevents ANY permission popups from appearing on first load. python3 -c " import sqlite3, os db = '$PROFILE/permissions.sqlite' conn = sqlite3.connect(db) c = conn.cursor() c.execute('CREATE TABLE IF NOT EXISTS moz_perms (id INTEGER PRIMARY KEY, origin TEXT, type TEXT, permission INTEGER, expireType INTEGER, expireTime INTEGER, modificationTime INTEGER)') # permission=1=ALLOW, 2=DENY; for kiosk we deny notifications, allow autoplay perms = [ ('http://localhost:8080', 'desktop-notification', 2, 0, 0), ('http://localhost:8080', 'autoplay-media', 1, 0, 0), ('http://localhost:8080', 'geo', 1, 0, 0), ('http://localhost:8080', 'camera', 1, 0, 0), ('http://localhost:8080', 'microphone', 1, 0, 0), ('http://localhost:8080', 'midi', 1, 0, 0), ('http://localhost:8080', 'midi-sysex', 1, 0, 0), ('http://localhost:8080', 'popup', 1, 0, 0), ('http://localhost:8080', 'persistent-storage', 1, 0, 0), ] import time ts = int(time.time() * 1000) for origin, ptype, perm, exp_type, exp_time in perms: c.execute('INSERT INTO moz_perms (origin, type, permission, expireType, expireTime, modificationTime) VALUES (?, ?, ?, ?, ?, ?)', (origin, ptype, perm, exp_type, exp_time, ts)) conn.commit() conn.close() " echo "[kiosk] pre-populated permissions.sqlite for localhost:8080" cat > "$PROFILE/chrome/userChrome.css" << 'CHROMEEOF' /* FedAC Kiosk: hide ALL browser chrome, toolbars, and notification bars */ #TabsToolbar, #titlebar, #toolbar-menubar, #nav-bar, #PersonalToolbar, #navigator-toolbox { visibility: collapse !important; min-height: 0 !important; max-height: 0 !important; height: 0 !important; overflow: hidden !important; } /* Kill all notification/info bars — the thin line at top */ notification, .notificationbox-stack, #notification-popup-box, .notification-anchor-icon, #PopupAutoComplete, .popup-notification-panel, #appMenu-notification-popup, #identity-popup, #tracking-protection-icon-container, hbox.infobar, .infobar, notification[value] { display: none !important; height: 0 !important; max-height: 0 !important; opacity: 0 !important; } /* Ensure no top border/line from the browser frame */ #main-window, #browser { border-top: none !important; } CHROMEEOF # Wait briefly for mounted piece files (mount can be late on some hardware). for i in $(seq 1 20); do [ -f /mnt/piece/piece.html ] && break [ "$i" = "1" ] && echo "[kiosk] waiting for /mnt/piece/piece.html..." sleep 0.5 done # Check for piece on FEDAC-PIECE partition (already mounted rw above for logging). # Symlink ALL html files — the oven injects piece.html (shell) + piece-app.html (bundle), # and the shell iframe needs piece-app.html resolvable from the same directory. if [ -f /mnt/piece/piece.html ]; then for f in /mnt/piece/*.html; do [ -f "$f" ] && ln -sf "$f" "/usr/local/share/kiosk/$(basename "$f")" done echo "[kiosk] linked piece files from FEDAC-PIECE partition" ls -la /usr/local/share/kiosk/*.html 2>&1 else echo "[kiosk] WARNING: /mnt/piece/piece.html missing — using bundled rootfs fallback" fi # Paint PALS logo to framebuffer (visible during compositor startup) if [ -x /usr/local/bin/pals-fb-splash ]; then /usr/local/bin/pals-fb-splash 2>/dev/null || true fi # Ensure user audio stack is available. mkdir -p "$XDG_RUNTIME_DIR" if command -v systemctl >/dev/null 2>&1; then systemctl --user start pipewire.service pipewire-pulse.service wireplumber.service >/dev/null 2>&1 || true fi if [ ! -S "$XDG_RUNTIME_DIR/pulse/native" ] && command -v pipewire >/dev/null 2>&1; then echo "[kiosk] pulse socket missing; starting pipewire fallback daemons" pipewire >> "$LOG" 2>&1 & sleep 0.2 command -v wireplumber >/dev/null 2>&1 && wireplumber >> "$LOG" 2>&1 & command -v pipewire-pulse >/dev/null 2>&1 && pipewire-pulse >> "$LOG" 2>&1 & fi echo "[kiosk] XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR" echo "[kiosk] KIOSK_PIECE_URL=__KIOSK_PIECE_URL__" ls -la /usr/local/share/kiosk/ 2>&1 || echo "[kiosk] /usr/local/share/kiosk/ missing" # Wait for piece server (port 8080) — with socket activation this should be instant. for i in $(seq 1 30); do 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 echo "[kiosk] piece server ready" break fi [ "$i" -ge 5 ] && echo "[kiosk] waiting for piece server... ($i/30)" sleep 0.5 done # Fallback: if systemd service isn't running, start piece server ourselves 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 echo "[kiosk] piece server not ready after 15s — starting fallback" python3 /usr/local/bin/kiosk-piece-server.py & sleep 1 fi # Build the Firefox launch wrapper once and run it inside a Wayland compositor. cat > /tmp/fedac-firefox-kiosk.sh << 'WLEOF' #!/bin/sh exec firefox --kiosk --no-remote --new-instance \ --profile "__PROFILE__" \ "__KIOSK_PIECE_URL__" WLEOF sed -i "s|__PROFILE__|$PROFILE|g" /tmp/fedac-firefox-kiosk.sh chmod +x /tmp/fedac-firefox-kiosk.sh WESTON_SHELL="" for cand in \ /usr/lib64/libweston-*/kiosk-shell.so \ /usr/lib/libweston-*/kiosk-shell.so \ /usr/lib64/libweston-*/fullscreen-shell.so \ /usr/lib/libweston-*/fullscreen-shell.so do [ -f "$cand" ] && WESTON_SHELL="$cand" && break done echo "[kiosk] weston shell: ${WESTON_SHELL:-default}" launch_with_weston() { local socket="fedac-wayland" local args=(--backend=drm --socket="$socket" --idle-time=0) [ -n "$WESTON_SHELL" ] && args+=(--shell="$WESTON_SHELL") rm -f "$XDG_RUNTIME_DIR/$socket" "$WESTON_BIN" "${args[@]}" >> "$WAYLAND_LOG" 2>&1 & local weston_pid="$!" for i in $(seq 1 60); do [ -S "$XDG_RUNTIME_DIR/$socket" ] && break sleep 0.1 done if [ ! -S "$XDG_RUNTIME_DIR/$socket" ]; then echo "[kiosk] weston socket did not appear" kill "$weston_pid" >/dev/null 2>&1 || true wait "$weston_pid" >/dev/null 2>&1 || true return 1 fi WAYLAND_DISPLAY="$socket" /tmp/fedac-firefox-kiosk.sh >> "$FIREFOX_LOG" 2>&1 local code="$?" kill "$weston_pid" >/dev/null 2>&1 || true wait "$weston_pid" >/dev/null 2>&1 || true return "$code" } launch_with_cage() { "$CAGE_BIN" /tmp/fedac-firefox-kiosk.sh >> "$WAYLAND_LOG" 2>&1 } if [ -n "$WESTON_BIN" ] || [ -n "$CAGE_BIN" ]; then while true; do if [ -n "$WESTON_BIN" ]; then echo "[kiosk] launching firefox in Wayland mode (weston)" launch_with_weston CODE="$?" echo "[kiosk] weston/firefox exited (code=$CODE); restarting in 2s" else echo "[kiosk] launching firefox in Wayland mode (cage)" launch_with_cage CODE="$?" echo "[kiosk] cage/firefox exited (code=$CODE); restarting in 2s" fi tail -n 40 "$FIREFOX_LOG" 2>/dev/null || true tail -n 40 "$WAYLAND_LOG" 2>/dev/null || true sleep 2 done fi echo "[kiosk] FATAL: no working launcher (weston/cage)" echo "[kiosk] staying on tty for debug; log=$LOG" while true; do sleep 60; done SESSEOF sed -i "s|__KIOSK_PIECE_URL__|$KIOSK_PIECE_URL|g" "$ROOTFS_DIR/usr/local/bin/kiosk-session.sh" chmod +x "$ROOTFS_DIR/usr/local/bin/kiosk-session.sh" echo -e " ${GREEN}Kiosk session script installed${NC}" # Root preflight: mount FEDAC-PIECE and link any injected html files before # tty1 autologin starts Firefox. cat > "$ROOTFS_DIR/usr/local/bin/fedac-piece-sync.sh" << 'PIECESYNCEOF' #!/bin/bash set -euo pipefail LOG="/tmp/fedac-piece-sync.log" log() { echo "[piece-sync] $(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOG"; } mkdir -p /mnt/piece /usr/local/share/kiosk if ! mountpoint -q /mnt/piece 2>/dev/null; then if mount /dev/disk/by-label/FEDAC-PIECE /mnt/piece 2>/dev/null || mount -L FEDAC-PIECE /mnt/piece 2>/dev/null; then log "mounted FEDAC-PIECE" else log "mount failed" fi fi if [ -f /mnt/piece/piece.html ]; then linked=0 shopt -s nullglob for f in /mnt/piece/*.html; do ln -sf "$f" "/usr/local/share/kiosk/$(basename "$f")" linked=$((linked + 1)) done shopt -u nullglob log "linked ${linked} html file(s)" else log "piece.html missing on /mnt/piece" fi PIECESYNCEOF chmod +x "$ROOTFS_DIR/usr/local/bin/fedac-piece-sync.sh" echo -e " ${GREEN}Piece sync preflight installed${NC}" # 3c. Disable GDM and force tty1 autologin -> kiosk-session.sh # This path is resilient on Fedora live images even when graphical.target isn't reached. mkdir -p "$ROOTFS_DIR/etc/systemd/system/getty@tty1.service.d" # Mask GDM so it never starts and switch to multi-user (no graphical desktop) ln -sf /dev/null "$ROOTFS_DIR/etc/systemd/system/gdm.service" ln -sf /dev/null "$ROOTFS_DIR/etc/systemd/system/display-manager.service" # Force multi-user.target as default — prevents GNOME desktop flash on boot ln -sf /usr/lib/systemd/system/multi-user.target "$ROOTFS_DIR/etc/systemd/system/default.target" # Disable SELinux — the erofs live rootfs lacks correct xattr labels, causing # pam_unix to fail with "User not known to the underlying authentication module". # For an offline kiosk, SELinux provides no benefit and adds boot complexity. if [ -f "$ROOTFS_DIR/etc/selinux/config" ]; then sed -i 's/^SELINUX=enforcing/SELINUX=disabled/' "$ROOTFS_DIR/etc/selinux/config" echo -e " ${GREEN}SELinux disabled in rootfs config${NC}" fi cat > "$ROOTFS_DIR/etc/systemd/system/getty@tty1.service.d/autologin.conf" << 'AGEOF' [Service] ExecStartPre=-/usr/local/bin/fedac-piece-sync.sh ExecStart= ExecStart=-/sbin/agetty --noissue --nonewline --autologin liveuser --noclear %I $TERM Type=idle AGEOF # Some minimal roots don't ship a liveuser account; create it for tty1 autologin. if ! chroot "$ROOTFS_DIR" /usr/bin/getent passwd liveuser >/dev/null 2>&1; then chroot "$ROOTFS_DIR" /usr/sbin/useradd -m -s /bin/bash liveuser >/dev/null 2>&1 || true fi chroot "$ROOTFS_DIR" /usr/sbin/usermod -s /bin/bash liveuser >/dev/null 2>&1 || true chroot "$ROOTFS_DIR" /usr/bin/passwd -d liveuser >/dev/null 2>&1 || true # Unlock root account so emergency shell is accessible for debugging. # passwd -d sets empty password, but sulogin needs the shadow entry unlocked too. chroot "$ROOTFS_DIR" /usr/bin/passwd -d root >/dev/null 2>&1 || true sed -i 's|^root:[^:]*:|root::|' "$ROOTFS_DIR/etc/shadow" # Ensure PAM allows passwordless autologin for liveuser. # Fedora 43 pam_unix.so rejects empty-password accounts unless "nullok" is present. # Use authselect (Fedora's PAM manager) so the change survives boot-time regeneration, # then also patch the files directly as a belt-and-suspenders fallback. if chroot "$ROOTFS_DIR" /usr/bin/authselect current >/dev/null 2>&1; then # Get current profile and add with-nullok feature CURRENT_PROFILE=$(chroot "$ROOTFS_DIR" /usr/bin/authselect current -r 2>/dev/null || echo "sssd") chroot "$ROOTFS_DIR" /usr/bin/authselect select "$CURRENT_PROFILE" with-nullok --force >/dev/null 2>&1 || true echo -e " ${GREEN}authselect: enabled with-nullok on profile $CURRENT_PROFILE${NC}" fi # Belt-and-suspenders: also patch the files directly in case authselect isn't available. for pamfile in "$ROOTFS_DIR/etc/pam.d/system-auth" "$ROOTFS_DIR/etc/pam.d/password-auth"; do if [ -f "$pamfile" ]; then sed -i '/pam_unix\.so/ { /nullok/! s/pam_unix\.so/pam_unix.so nullok/ }' "$pamfile" fi done echo -e " ${GREEN}PAM nullok configured for passwordless autologin${NC}" # 3d. Hardware volume key daemon — pure Python, reads raw Linux input events. # No actkbd or external packages needed. Runs as root, talks to liveuser's PipeWire. cat > "$ROOTFS_DIR/usr/local/bin/kiosk-volume-keyd" << 'VKDEOF' #!/usr/bin/python3 """FedAC kiosk volume key daemon. Reads raw input_event structs from all keyboard /dev/input/event* devices, intercepts KEY_MUTE (113), KEY_VOLUMEDOWN (114), KEY_VOLUMEUP (115), and adjusts the PipeWire/PulseAudio/ALSA volume for liveuser. Zero external dependencies — stdlib only. """ import glob, os, select, struct, subprocess, sys # Linux input_event: struct timeval (16 bytes on 64-bit), __u16 type, __u16 code, __s32 value EVENT_SIZE = struct.calcsize("llHHi") EV_KEY = 0x01 KEY_DOWN = 1 # value=1 is key press KEY_MUTE = 113 KEY_VOLUMEDOWN = 114 KEY_VOLUMEUP = 115 USER = "liveuser" UID = None RUNTIME_DIR = None def find_uid(): global UID, RUNTIME_DIR try: import pwd UID = pwd.getpwnam(USER).pw_uid except (KeyError, ImportError): UID = 1000 RUNTIME_DIR = f"/run/user/{UID}" def run_as_user(cmd): """Run a command as liveuser with their audio env.""" env = os.environ.copy() env["XDG_RUNTIME_DIR"] = RUNTIME_DIR env["DBUS_SESSION_BUS_ADDRESS"] = f"unix:path={RUNTIME_DIR}/bus" try: subprocess.run( ["runuser", "-u", USER, "--"] + cmd, env=env, timeout=5, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) except Exception: pass def vol_up(): run_as_user(["wpctl", "set-volume", "-l", "1.5", "@DEFAULT_AUDIO_SINK@", "5%+"]) def vol_down(): run_as_user(["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", "5%-"]) def vol_mute(): run_as_user(["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "toggle"]) ACTIONS = {KEY_MUTE: vol_mute, KEY_VOLUMEDOWN: vol_down, KEY_VOLUMEUP: vol_up} def is_keyboard(evdev_path): """Check if device has EV_KEY capability (bit 1 in capabilities/ev).""" sysfs = f"/sys/class/input/{os.path.basename(evdev_path)}/device/capabilities/ev" try: caps = int(open(sysfs).read().strip(), 16) return bool(caps & (1 << EV_KEY)) except Exception: return False def main(): find_uid() # Open all keyboard-capable input devices fds = {} for path in sorted(glob.glob("/dev/input/event*")): if is_keyboard(path): try: fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK) fds[fd] = path except OSError: pass if not fds: print("kiosk-volume-keyd: no keyboard devices found", file=sys.stderr) sys.exit(1) print(f"kiosk-volume-keyd: monitoring {len(fds)} device(s)", file=sys.stderr) poll = select.poll() for fd in fds: poll.register(fd, select.POLLIN) while True: try: events = poll.poll(5000) except (KeyboardInterrupt, SystemExit): break for fd, mask in events: if mask & select.POLLIN: try: data = os.read(fd, EVENT_SIZE * 16) except OSError: continue for off in range(0, len(data) - EVENT_SIZE + 1, EVENT_SIZE): _sec, _usec, ev_type, code, value = struct.unpack_from("llHHi", data, off) if ev_type == EV_KEY and value == KEY_DOWN and code in ACTIONS: ACTIONS[code]() for fd in fds: os.close(fd) if __name__ == "__main__": main() VKDEOF chmod +x "$ROOTFS_DIR/usr/local/bin/kiosk-volume-keyd" # Systemd service for the volume key daemon cat > "$ROOTFS_DIR/etc/systemd/system/kiosk-volume-keyd.service" << 'SVCEOF' [Unit] Description=FedAC kiosk volume key daemon After=systemd-udevd.service [Service] Type=simple ExecStart=/usr/local/bin/kiosk-volume-keyd Restart=on-failure RestartSec=2 [Install] WantedBy=multi-user.target SVCEOF ln -sf /etc/systemd/system/kiosk-volume-keyd.service "$ROOTFS_DIR/etc/systemd/system/multi-user.target.wants/kiosk-volume-keyd.service" echo -e " ${GREEN}Volume keys mapped (kiosk-volume-keyd)${NC}" # 3e. Piece server — serves kiosk HTML files + volume API on port 8080. cp "$OVERLAY_DIR/kiosk-piece-server.py" "$ROOTFS_DIR/usr/local/bin/kiosk-piece-server.py" chmod +x "$ROOTFS_DIR/usr/local/bin/kiosk-piece-server.py" cp "$OVERLAY_DIR/kiosk-piece-server.service" "$ROOTFS_DIR/etc/systemd/system/kiosk-piece-server.service" cp "$OVERLAY_DIR/kiosk-piece-server.socket" "$ROOTFS_DIR/etc/systemd/system/kiosk-piece-server.socket" # Enable socket activation — systemd binds port 8080 immediately at boot, # then starts the service on first connection (eliminates "waiting for piece server" delay). mkdir -p "$ROOTFS_DIR/etc/systemd/system/sockets.target.wants" ln -sf /etc/systemd/system/kiosk-piece-server.socket "$ROOTFS_DIR/etc/systemd/system/sockets.target.wants/kiosk-piece-server.socket" # Also keep direct service enable as fallback (service starts on its own too) mkdir -p "$ROOTFS_DIR/etc/systemd/system/multi-user.target.wants" ln -sf /etc/systemd/system/kiosk-piece-server.service "$ROOTFS_DIR/etc/systemd/system/multi-user.target.wants/kiosk-piece-server.service" echo -e " ${GREEN}Piece server installed + socket-activated (port 8080)${NC}" # 3e2. Auto-mount FEDAC-PIECE partition at boot via systemd mount unit. # The kiosk session runs as liveuser who can't mount — systemd mounts it as root. mkdir -p "$ROOTFS_DIR/mnt/piece" cat > "$ROOTFS_DIR/etc/systemd/system/mnt-piece.mount" << 'MOUNTEOF' [Unit] Description=Mount FEDAC-PIECE partition After=systemd-udev-settle.service local-fs-pre.target Wants=systemd-udev-settle.service Before=local-fs.target kiosk-piece-server.service [Mount] What=/dev/disk/by-label/FEDAC-PIECE Where=/mnt/piece Type=ext4 Options=rw,noatime,nofail,x-systemd.device-timeout=30s [Install] WantedBy=local-fs.target MOUNTEOF mkdir -p "$ROOTFS_DIR/etc/systemd/system/local-fs.target.wants" ln -sf /etc/systemd/system/mnt-piece.mount "$ROOTFS_DIR/etc/systemd/system/local-fs.target.wants/mnt-piece.mount" echo -e " ${GREEN}FEDAC-PIECE auto-mount unit installed${NC}" mkdir -p "$ROOTFS_DIR/home/liveuser" cat > "$ROOTFS_DIR/home/liveuser/.bash_profile" << 'BPROFEOF' #!/bin/bash TTY="$(tty 2>/dev/null || true)" if [ -z "${DISPLAY:-}" ] && { [ "${XDG_VTNR:-}" = "1" ] || [ "$TTY" = "/dev/tty1" ]; }; then [ -n "$TTY" ] && echo "[fedac] starting kiosk session on $TTY" >"$TTY" 2>/dev/null || true exec /usr/local/bin/kiosk-session.sh fi BPROFEOF chmod 644 "$ROOTFS_DIR/home/liveuser/.bash_profile" chown liveuser:liveuser "$ROOTFS_DIR/home/liveuser/.bash_profile" 2>/dev/null || true echo -e " ${GREEN}GDM masked, tty1 autologin configured${NC}" # 3d. Slim livesys-gnome — no GDM config, just cleanup + schema compile cat > "$ROOTFS_DIR/usr/libexec/livesys/sessions.d/livesys-gnome" << 'LSEOF' #!/usr/bin/sh # livesys-gnome: FedAC Kiosk — minimal live session cleanup # Suppress GNOME initial setup / welcome screens mkdir -p ~liveuser/.config echo yes > ~liveuser/.config/gnome-initial-setup-done rm -f /etc/xdg/autostart/gnome-initial-setup*.desktop rm -f /etc/xdg/autostart/org.gnome.Tour.desktop rm -f ~liveuser/.config/autostart/org.fedoraproject.welcome-screen.desktop rm -f ~liveuser/.config/autostart/fedora-welcome.desktop # Compile glib schemas (mutter reads these at startup) glib-compile-schemas /usr/share/glib-2.0/schemas 2>/dev/null || true chown -R liveuser:liveuser ~liveuser/ 2>/dev/null || true LSEOF chmod +x "$ROOTFS_DIR/usr/libexec/livesys/sessions.d/livesys-gnome" echo -e " ${GREEN}livesys-gnome trimmed (no GDM)${NC}" # 3e. Disable unnecessary services for faster boot for svc in \ abrtd abrt-journal-core abrt-oops abrt-vmcore abrt-xorg \ avahi-daemon cups cups-browsed \ flatpak-system-update \ ModemManager \ sssd sssd-kcm \ thermald tuned \ rsyslog \ mdmonitor mcelog smartd atd \ NetworkManager-wait-online \ gnome-session gnome-shell gnome-session-manager \ gnome-initial-setup gnome-initial-setup-first-login \ gnome-software-service; do ln -sf /dev/null "$ROOTFS_DIR/etc/systemd/system/${svc}.service" 2>/dev/null || true done # Also mask any graphical session targets that could pull in a desktop for target in graphical.target; do ln -sf /dev/null "$ROOTFS_DIR/etc/systemd/system/${target}" 2>/dev/null || true done echo -e " ${GREEN}Unnecessary services + graphical.target masked${NC}" # Blacklist iwlwifi — ThinkPad BIOS has WGDS but no WRDS ACPI table, # causing iwlwifi to crash on boot. Kiosk is offline so WiFi isn't needed. mkdir -p "$ROOTFS_DIR/etc/modprobe.d" cat > "$ROOTFS_DIR/etc/modprobe.d/fedac-blacklist.conf" << 'BLEOF' # FedAC kiosk: disable WiFi (iwlwifi crashes on WGDS without WRDS) blacklist iwlwifi blacklist iwlmvm blacklist iwldvm BLEOF echo -e " ${GREEN}iwlwifi blacklisted (offline kiosk, avoids WRDS/WGDS crash)${NC}" # 3f. Firefox policies (no default tabs, no welcome) mkdir -p "$ROOTFS_DIR/usr/lib64/firefox/distribution" cat > "$ROOTFS_DIR/usr/lib64/firefox/distribution/policies.json" << 'FPEOF' { "policies": { "OverrideFirstRunPage": "", "OverridePostUpdatePage": "", "Homepage": { "URL": "__KIOSK_PIECE_URL__", "StartPage": "homepage" }, "NewTabPage": false, "DisableProfileImport": true, "DontCheckDefaultBrowser": true, "NoDefaultBookmarks": true, "DisableTelemetry": true, "DisableFirefoxStudies": true, "DisablePocket": true, "DisplayBookmarksToolbar": "never", "DisplayMenuBar": "never", "SearchBar": "none", "Permissions": { "Notifications": { "BlockNewRequests": true, "Locked": true }, "Location": { "Allow": ["http://localhost:8080"], "BlockNewRequests": false }, "Camera": { "Allow": ["http://localhost:8080"], "BlockNewRequests": false }, "Microphone": { "Allow": ["http://localhost:8080"], "BlockNewRequests": false }, "Autoplay": { "Default": "allow-audio-video", "Allow": ["http://localhost:8080"], "Locked": true } }, "PopupBlocking": { "Default": true }, "FirefoxSuggest": { "WebSuggestions": false, "SponsoredSuggestions": false, "ImproveSuggest": false } } } FPEOF sed -i "s|__KIOSK_PIECE_URL__|$KIOSK_PIECE_URL|g" "$ROOTFS_DIR/usr/lib64/firefox/distribution/policies.json" cat > "$ROOTFS_DIR/usr/lib64/firefox/defaults/pref/autoconfig.js" << 'ACEOF' pref("general.config.filename", "firefox.cfg"); pref("general.config.obscure_value", 0); ACEOF cat > "$ROOTFS_DIR/usr/lib64/firefox/firefox.cfg" << 'CFGEOF' // FedAC Kiosk Firefox config defaultPref("browser.sessionstore.resume_from_crash", false); defaultPref("browser.startup.homepage_override.mstone", "ignore"); defaultPref("browser.startup.page", 1); defaultPref("browser.startup.homepage", "__KIOSK_PIECE_URL__"); defaultPref("browser.startup.blankWindow", false); defaultPref("browser.display.background_color", "#000000"); defaultPref("browser.tabs.warnOnClose", false); defaultPref("browser.tabs.drawInTitlebar", false); defaultPref("browser.tabs.inTitlebar", 0); defaultPref("browser.shell.checkDefaultBrowser", false); defaultPref("datareporting.policy.dataSubmissionEnabled", false); defaultPref("toolkit.telemetry.reportingpolicy.firstRun", false); defaultPref("browser.newtabpage.enabled", false); defaultPref("browser.aboutwelcome.enabled", false); defaultPref("toolkit.legacyUserProfileCustomizations.stylesheets", true); defaultPref("accessibility.typeaheadfind", false); defaultPref("accessibility.typeaheadfind.manual", false); defaultPref("accessibility.typeaheadfind.linksonly", false); defaultPref("gfx.webrender.force-disabled", true); defaultPref("gfx.webrender.software", false); defaultPref("layers.acceleration.disabled", true); // Allow Web Audio API autoplay (kiosk — no user gesture needed) defaultPref("media.autoplay.default", 0); defaultPref("media.autoplay.blocking_policy", 0); defaultPref("media.autoplay.allow-extension-background-pages", true); // Suppress notification prompts, allow hardware access (kiosk = trust all) defaultPref("permissions.default.desktop-notification", 2); defaultPref("permissions.default.geo", 1); defaultPref("permissions.default.camera", 1); defaultPref("permissions.default.microphone", 1); defaultPref("dom.webnotifications.enabled", false); defaultPref("dom.push.enabled", false); // MIDI: allow without prompting (including SysEx) defaultPref("midi.prompt_on_sysex_access", false); defaultPref("midi.testing", true); defaultPref("dom.webmidi.enabled", true); defaultPref("browser.urlbar.suggest.searches", false); defaultPref("security.insecure_connection_text.enabled", false); defaultPref("security.insecure_connection_text.pbmode.enabled", false); defaultPref("browser.contentblocking.introCount", 99); CFGEOF sed -i "s|__KIOSK_PIECE_URL__|$KIOSK_PIECE_URL|g" "$ROOTFS_DIR/usr/lib64/firefox/firefox.cfg" echo -e " ${GREEN}Firefox policies installed${NC}" # 3g. Plymouth PALS logo PALS_LOGO="$REPO_ROOT/fedac/plymouth/pals.png" if [ -f "$PALS_LOGO" ]; then cp "$PALS_LOGO" "$ROOTFS_DIR/usr/share/plymouth/themes/spinner/watermark.png" # Center the logo and set black background sed -i 's/^WatermarkVerticalAlignment=.*/WatermarkVerticalAlignment=.5/' \ "$ROOTFS_DIR/usr/share/plymouth/themes/spinner/spinner.plymouth" cat > "$ROOTFS_DIR/etc/plymouth/plymouthd.conf" << 'PLYEOF' [Daemon] Theme=spinner ShowDelay=0 PLYEOF echo -e " ${GREEN}PALS Plymouth theme installed${NC}" fi # 3h. dconf kiosk settings mkdir -p "$ROOTFS_DIR/etc/dconf/db/local.d" "$ROOTFS_DIR/etc/dconf/profile" cp "$OVERLAY_DIR/00-kiosk" "$ROOTFS_DIR/etc/dconf/db/local.d/00-kiosk" 2>/dev/null || true cp "$OVERLAY_DIR/dconf-profile-user" "$ROOTFS_DIR/etc/dconf/profile/user" 2>/dev/null || true echo -e " ${GREEN}All kiosk config injected${NC}" # ══════════════════════════════════════════ # Step 3i: Strip bloat from rootfs (kiosk only needs browser + Wayland + audio) # ══════════════════════════════════════════ echo -e "${CYAN} Stripping unnecessary packages from rootfs...${NC}" ROOTFS_BEFORE=$(du -sb "$ROOTFS_DIR" 2>/dev/null | awk '{print $1}') # Mount /dev, /proc, /sys for chroot dnf operations mount --bind /dev "$ROOTFS_DIR/dev" 2>/dev/null || true mount --bind /proc "$ROOTFS_DIR/proc" 2>/dev/null || true mount --bind /sys "$ROOTFS_DIR/sys" 2>/dev/null || true # Remove large unnecessary packages via dnf (resolves dependencies cleanly) STRIP_PKGS=( # Office suite (~300MB) "libreoffice*" # Java (~248MB) "java-*-openjdk*" # Virtualization (~110MB) "qemu*" "edk2-ovmf" "gnome-boxes" "libvirt*" "podman*" # LLVM (~145MB) "llvm-libs" # GNOME apps we don't need (~150MB+) "gnome-software*" "gnome-maps" "gnome-weather" "gnome-contacts" "gnome-calendar" "gnome-clocks" "gnome-connections" "gnome-characters" "gnome-font-viewer" "gnome-logs" "gnome-calculator" "gnome-text-editor" "gnome-tour" "gnome-photos" "gnome-music" "gnome-user-docs" "simple-scan" "baobab" "totem*" "cheese" "rhythmbox" "shotwell" "evince*" "yelp*" "loupe" "snapshot" "gnome-console" # Crash reporter (~30MB) "abrt*" "gnome-abrt" # IBus input methods (~156MB) — kiosk doesn't need multilingual input "ibus*" # WebKit (not needed, Firefox is our browser) (~180MB) "webkit2gtk*" "webkitgtk*" "javascriptcoregtk*" # Container tools "buildah" "skopeo" "toolbox" "containers-common*" # Evolution data server (email/calendar backend) "evolution*" # Flatpak "flatpak*" # Printing "cups*" # Misc heavy packages "unicode-ucd" "cldr-emoji-annotation" "anaconda*" "livecd-tools" "liveinst*" "mediawriter" ) chroot "$ROOTFS_DIR" /usr/bin/dnf -y remove "${STRIP_PKGS[@]}" 2>&1 | tail -5 || true # Remove non-English locales (~220MB) echo -e " Stripping non-English locales..." find "$ROOTFS_DIR/usr/share/locale" -mindepth 1 -maxdepth 1 -type d \ ! -name "en" ! -name "en_US" ! -name "en_GB" -exec rm -rf {} + 2>/dev/null || true # Remove docs, man pages, help (~250MB) rm -rf "$ROOTFS_DIR/usr/share/doc" rm -rf "$ROOTFS_DIR/usr/share/man" rm -rf "$ROOTFS_DIR/usr/share/help" rm -rf "$ROOTFS_DIR/usr/share/gtk-doc" rm -rf "$ROOTFS_DIR/usr/share/info" # Remove Firefox langpacks (~43MB) — kiosk is English-only rm -rf "$ROOTFS_DIR/usr/lib64/firefox/langpacks" 2>/dev/null || true chroot "$ROOTFS_DIR" /usr/bin/dnf -y remove "firefox-langpacks" 2>&1 | tail -1 || true # Remove all langpacks except English (~238MB) chroot "$ROOTFS_DIR" /usr/bin/dnf -y remove "glibc-all-langpacks" 2>&1 | tail -1 || true chroot "$ROOTFS_DIR" /usr/bin/dnf -y install "glibc-langpack-en" 2>&1 | tail -1 || true # Remove CJK fonts (~57MB) — not needed for kiosk rm -rf "$ROOTFS_DIR/usr/share/fonts/google-noto-serif-cjk-vf-fonts" 2>/dev/null || true rm -rf "$ROOTFS_DIR/usr/share/fonts/google-noto-sans-cjk-vf-fonts" 2>/dev/null || true # Trim firmware — keep only common wifi + GPU, remove exotic/unused (~300MB savings) FIRMWARE_KEEP="intel|iwlwifi|amd|nvidia|ath|rtl|brcm|realtek|i915|radeon|amdgpu|mediatek" for fw_dir in "$ROOTFS_DIR/usr/lib/firmware"/*/; do fw_name=$(basename "$fw_dir") if ! echo "$fw_name" | grep -qiE "$FIRMWARE_KEEP"; then rm -rf "$fw_dir" 2>/dev/null || true fi done # Remove large individual firmware blobs we don't need rm -rf "$ROOTFS_DIR/usr/lib/firmware/liquidio" 2>/dev/null || true rm -rf "$ROOTFS_DIR/usr/lib/firmware/netronome" 2>/dev/null || true rm -rf "$ROOTFS_DIR/usr/lib/firmware/mellanox" 2>/dev/null || true rm -rf "$ROOTFS_DIR/usr/lib/firmware/dpaa2" 2>/dev/null || true rm -rf "$ROOTFS_DIR/usr/lib/firmware/qcom" 2>/dev/null || true rm -rf "$ROOTFS_DIR/usr/lib/firmware/cnm" 2>/dev/null || true rm -rf "$ROOTFS_DIR/usr/lib/firmware/qed" 2>/dev/null || true rm -rf "$ROOTFS_DIR/usr/lib/firmware/mrvl" 2>/dev/null || true rm -rf "$ROOTFS_DIR/usr/lib/firmware/cxgb4" 2>/dev/null || true # Clean dnf cache inside rootfs chroot "$ROOTFS_DIR" /usr/bin/dnf clean all 2>/dev/null || true rm -rf "$ROOTFS_DIR/var/cache/dnf" 2>/dev/null || true rm -rf "$ROOTFS_DIR/var/log/"* 2>/dev/null || true # Unmount chroot binds umount "$ROOTFS_DIR/sys" 2>/dev/null || true umount "$ROOTFS_DIR/proc" 2>/dev/null || true umount "$ROOTFS_DIR/dev" 2>/dev/null || true ROOTFS_AFTER=$(du -sb "$ROOTFS_DIR" 2>/dev/null | awk '{print $1}') SAVED=$(( (ROOTFS_BEFORE - ROOTFS_AFTER) / 1048576 )) echo -e " ${GREEN}Stripped ${SAVED}MB from rootfs ($(numfmt --to=iec $ROOTFS_BEFORE) → $(numfmt --to=iec $ROOTFS_AFTER))${NC}" # ── Install Wayland runtime required by kiosk-session.sh ─────────────── echo -e " Installing Wayland runtime into rootfs (post-strip)..." # Re-mount chroot binds for dnf mount --bind /dev "$ROOTFS_DIR/dev" 2>/dev/null || true mount --bind /proc "$ROOTFS_DIR/proc" 2>/dev/null || true mount --bind /sys "$ROOTFS_DIR/sys" 2>/dev/null || true mount -t tmpfs tmpfs "$ROOTFS_DIR/tmp" 2>/dev/null || true cp /etc/resolv.conf "$ROOTFS_DIR/etc/resolv.conf" 2>/dev/null || true if ! grep -q '^nameserver ' "$ROOTFS_DIR/etc/resolv.conf" 2>/dev/null; then cat > "$ROOTFS_DIR/etc/resolv.conf" << 'RSVEOF' nameserver 1.1.1.1 nameserver 8.8.8.8 RSVEOF elif grep -q '127\.0\.0\.53' "$ROOTFS_DIR/etc/resolv.conf" 2>/dev/null; then grep -q '^nameserver 1.1.1.1$' "$ROOTFS_DIR/etc/resolv.conf" 2>/dev/null || echo "nameserver 1.1.1.1" >> "$ROOTFS_DIR/etc/resolv.conf" grep -q '^nameserver 8.8.8.8$' "$ROOTFS_DIR/etc/resolv.conf" 2>/dev/null || echo "nameserver 8.8.8.8" >> "$ROOTFS_DIR/etc/resolv.conf" fi DNF_INSTALL_FAILED=false if [ -x "$ROOTFS_DIR/usr/bin/dnf" ]; then if ! chroot "$ROOTFS_DIR" /usr/bin/dnf -y install \ weston cage; then DNF_INSTALL_FAILED=true fi elif command -v dnf >/dev/null 2>&1; then if ! dnf -y --installroot="$ROOTFS_DIR" --releasever="$FEDORA_VERSION" install \ weston cage; then DNF_INSTALL_FAILED=true fi else DNF_INSTALL_FAILED=true fi umount "$ROOTFS_DIR/tmp" "$ROOTFS_DIR/sys" "$ROOTFS_DIR/proc" "$ROOTFS_DIR/dev" 2>/dev/null || true if [ "$DNF_INSTALL_FAILED" = true ]; then echo -e " ${RED}ERROR: Failed to install Wayland runtime into rootfs${NC}" echo -e " ${RED}Check network/DNS on the build machine, then re-run.${NC}" exit 1 fi # ── Verify critical binaries survived the strip ────────────────────── echo -e " Verifying critical binaries..." MISSING="" for bin in firefox pipewire weston; do OK=false [ -x "$ROOTFS_DIR/usr/bin/$bin" ] && OK=true if [ "$OK" = true ]; then echo -e " ${GREEN}✓ $bin${NC}" else echo -e " ${RED}✗ $bin MISSING${NC}" MISSING="$MISSING $bin" fi done if [ -x "$ROOTFS_DIR/usr/bin/cage" ]; then echo -e " ${GREEN}✓ cage${NC}" else echo -e " ${YELLOW}• cage missing (weston-only fallback)${NC}" fi if [ -n "$MISSING" ]; then echo -e " ${RED}ERROR: Missing critical binaries:${MISSING}${NC}" echo -e " ${RED}Aborting build to avoid flashing a non-booting kiosk image.${NC}" exit 1 fi # ══════════════════════════════════════════ # Step 4: Build SquashFS + initrd # ══════════════════════════════════════════ echo -e "${CYAN}[4/6] Building SquashFS image...${NC}" EROFS_PATH="$WORK_DIR/squashfs.img" if command -v mksquashfs >/dev/null 2>&1; then mksquashfs "$ROOTFS_DIR/" "$EROFS_PATH" -comp zstd -Xcompression-level 3 -noappend else echo -e "${RED}Error: mksquashfs not found. Install squashfs-tools.${NC}" exit 1 fi EROFS_SIZE=$(stat -c%s "$EROFS_PATH") echo -e " ${GREEN}SquashFS built: $(numfmt --to=iec $EROFS_SIZE)${NC}" # ── Framebuffer splash: paint PALS logo to /dev/fb0 ────────────────── # Pre-convert PALS PNG → raw BGRA for fast framebuffer blitting. # This is used both in the initrd (pre-Plymouth) and in kiosk-session.sh. PALS_FB_DIR="$ROOTFS_DIR/usr/local/share/pals-fb" mkdir -p "$PALS_FB_DIR" if [ -f "$REPO_ROOT/fedac/plymouth/pals.png" ]; then python3 - "$REPO_ROOT/fedac/plymouth/pals.png" "$PALS_FB_DIR/logo.bgra" << 'PYEOF' import struct, sys, zlib def read_png(path): """Minimal PNG reader → RGBA pixels. No PIL needed.""" with open(path, 'rb') as f: sig = f.read(8) assert sig == b'\x89PNG\r\n\x1a\n', "Not a PNG" chunks = {} idat = b'' while True: hdr = f.read(8) if len(hdr) < 8: break length = struct.unpack('>I', hdr[:4])[0] ctype = hdr[4:8] data = f.read(length) f.read(4) # crc if ctype == b'IHDR': w, h, bd, ct = struct.unpack('>IIBB', data[:10]) chunks['IHDR'] = (w, h, bd, ct) elif ctype == b'IDAT': idat += data elif ctype == b'IEND': break w, h, bd, ct = chunks['IHDR'] raw = zlib.decompress(idat) # ct=6 is RGBA, ct=2 is RGB bpp = 4 if ct == 6 else 3 stride = 1 + w * bpp # filter byte + pixel data pixels = bytearray(w * h * 4) prev_row = bytearray(w * bpp) for y in range(h): off = y * stride filt = raw[off] row = bytearray(raw[off+1:off+1+w*bpp]) if filt == 1: # Sub for i in range(bpp, len(row)): row[i] = (row[i] + row[i-bpp]) & 0xFF elif filt == 2: # Up for i in range(len(row)): row[i] = (row[i] + prev_row[i]) & 0xFF elif filt == 3: # Average for i in range(len(row)): a = row[i-bpp] if i >= bpp else 0 row[i] = (row[i] + (a + prev_row[i]) // 2) & 0xFF elif filt == 4: # Paeth for i in range(len(row)): a = row[i-bpp] if i >= bpp else 0 b = prev_row[i] c = prev_row[i-bpp] if i >= bpp else 0 p = a + b - c pa, pb, pc = abs(p-a), abs(p-b), abs(p-c) pr = a if pa <= pb and pa <= pc else (b if pb <= pc else c) row[i] = (row[i] + pr) & 0xFF for x in range(w): si = x * bpp r, g, b_val = row[si], row[si+1], row[si+2] a = row[si+3] if bpp == 4 else 255 di = (y * w + x) * 4 pixels[di:di+4] = bytes([r, g, b_val, a]) prev_row = row return w, h, bytes(pixels) src, dst = sys.argv[1], sys.argv[2] w, h, rgba = read_png(src) # Convert RGBA → BGRA for Linux framebuffer bgra = bytearray(len(rgba)) for i in range(0, len(rgba), 4): bgra[i] = rgba[i+2] # B bgra[i+1] = rgba[i+1] # G bgra[i+2] = rgba[i] # R bgra[i+3] = rgba[i+3] # A with open(dst, 'wb') as f: f.write(struct.pack(' "$ROOTFS_DIR/usr/local/bin/pals-fb-splash" << 'FBEOF' #!/usr/bin/python3 """Paint PALS logo centered on dark purple to /dev/fb0.""" import os, struct, sys LOGO_PATH = "/usr/local/share/pals-fb/logo.bgra" BG_COLOR = (0x2e, 0x0a, 0x1a, 0xFF) # BGRA for #1a0a2e def main(): if not os.path.exists("/dev/fb0"): return try: sz = open("/sys/class/graphics/fb0/virtual_size").read().strip().split(",") sw, sh = int(sz[0]), int(sz[1]) except Exception: return bpp_bits = 32 try: bpp_bits = int(open("/sys/class/graphics/fb0/bits_per_pixel").read().strip()) except Exception: pass if bpp_bits != 32: return stride = sw * 4 # Fill screen with dark purple row = bytes(BG_COLOR) * sw with open("/dev/fb0", "wb") as fb: for _ in range(sh): fb.write(row) # Blit PALS logo centered if not os.path.exists(LOGO_PATH): return with open(LOGO_PATH, "rb") as f: lw, lh = struct.unpack("= sh: break fb.seek((oy + y) * stride + ox * 4) src_off = y * lw * 4 row_data = logo_data[src_off:src_off + lw * 4] # Alpha-blend each pixel over background blended = bytearray(len(row_data)) for x in range(lw): if ox + x >= sw: break pi = x * 4 b, g, r, a = row_data[pi], row_data[pi+1], row_data[pi+2], row_data[pi+3] if a == 255: blended[pi:pi+4] = bytes([b, g, r, 255]) elif a == 0: blended[pi:pi+4] = bytes(BG_COLOR) else: inv = 255 - a blended[pi] = (b * a + BG_COLOR[0] * inv + 127) // 255 blended[pi+1] = (g * a + BG_COLOR[1] * inv + 127) // 255 blended[pi+2] = (r * a + BG_COLOR[2] * inv + 127) // 255 blended[pi+3] = 255 fb.write(blended[:min(lw, sw - ox) * 4]) if __name__ == "__main__": main() FBEOF chmod +x "$ROOTFS_DIR/usr/local/bin/pals-fb-splash" # Create dracut module for early fb splash (runs before Plymouth) DRACUT_MOD="$ROOTFS_DIR/usr/lib/dracut/modules.d/00pals-fb" mkdir -p "$DRACUT_MOD" cat > "$DRACUT_MOD/module-setup.sh" << 'DRACEOF' #!/bin/bash check() { return 0; } depends() { return 0; } install() { inst_simple /usr/local/bin/pals-fb-splash inst_simple /usr/local/share/pals-fb/logo.bgra inst_hook pre-trigger 00 "$moddir/pals-fb-hook.sh" } DRACEOF cat > "$DRACUT_MOD/pals-fb-hook.sh" << 'HOOKEOF' #!/bin/sh # Paint PALS logo to framebuffer before Plymouth starts. # Runs in initrd with minimal environment. if [ -x /usr/local/bin/pals-fb-splash ] && [ -e /dev/fb0 ]; then /usr/local/bin/pals-fb-splash 2>/dev/null & fi HOOKEOF chmod +x "$DRACUT_MOD/module-setup.sh" "$DRACUT_MOD/pals-fb-hook.sh" echo -e " ${GREEN}Framebuffer splash installed (initrd + runtime)${NC}" # Build initrd with PALS Plymouth theme + fb splash echo -e " Building initrd with PALS Plymouth + fb splash..." KVER=$(ls "$ROOTFS_DIR/lib/modules/" | head -1) mount --bind /proc "$ROOTFS_DIR/proc" mount --bind /sys "$ROOTFS_DIR/sys" mount --bind /dev "$ROOTFS_DIR/dev" chroot "$ROOTFS_DIR" dracut --force --no-hostonly \ --add "dmsquash-live plymouth pals-fb" \ /tmp/live-initrd.img "$KVER" 2>/dev/null || true cp "$ROOTFS_DIR/tmp/live-initrd.img" "$WORK_DIR/initrd" cp "$ROOTFS_DIR/boot/vmlinuz-$KVER" "$WORK_DIR/linux" rm -f "$ROOTFS_DIR/tmp/live-initrd.img" umount "$ROOTFS_DIR/proc" "$ROOTFS_DIR/sys" "$ROOTFS_DIR/dev" echo -e " ${GREEN}Initrd built with PALS theme + fb splash${NC}" build_target() { local target="$1" local label="$2" # Unmount existing partitions for part in "${target}"*; do umount "$part" 2>/dev/null || true done # Wipe and partition: 512MB FAT32 EFI + main ext4 + 20MB FEDAC-PIECE echo -e " Creating partition table on ${label}..." wipefs -a "$target" >/dev/null 2>&1 # Compute LIVE partition end to avoid negative offsets (which confuse # parted on some versions / loop devices). local dev_bytes dev_bytes=$(blockdev --getsize64 "$target" 2>/dev/null || stat -c%s "$target" 2>/dev/null) local dev_mib=$((dev_bytes / 1048576)) local live_end=$((dev_mib - 20)) # leave 20 MiB for PIECE partition parted -s "$target" mklabel gpt parted -s "$target" mkpart '"EFI"' fat32 1MiB 513MiB parted -s "$target" set 1 esp on parted -s "$target" mkpart '"LIVE"' ext4 513MiB "${live_end}MiB" parted -s "$target" mkpart '"PIECE"' ext4 "${live_end}MiB" 100% sleep 2 partprobe "$target" 2>/dev/null || true sleep 2 # Detect partition names local p1="${target}1" local p2="${target}2" local p3="${target}3" [ -b "$p1" ] || p1="${target}p1" [ -b "$p2" ] || p2="${target}p2" [ -b "$p3" ] || p3="${target}p3" # Format mkfs.vfat -F 32 -n BOOT "$p1" >/dev/null mkfs.ext4 -L FEDAC-LIVE -q "$p2" mkfs.ext4 -L FEDAC-PIECE -q "$p3" echo -e " ${GREEN}${label}: partitions created (EFI + LIVE + PIECE)${NC}" # Write piece.html to FEDAC-PIECE partition local PIECE_MOUNT PIECE_MOUNT=$(mktemp -d /tmp/fedac-piece-XXXX) mount "$p3" "$PIECE_MOUNT" cp "$BUNDLE_PATH" "$PIECE_MOUNT/piece.html" [ -f "$FEDAC_DIR/overlays/kiosk/kiosk-shell.html" ] && \ sed "s|__DENSITY__|${PACK_DENSITY}|g" "$FEDAC_DIR/overlays/kiosk/kiosk-shell.html" > "$PIECE_MOUNT/kiosk-shell.html" chmod 777 "$PIECE_MOUNT" sync umount "$PIECE_MOUNT" rmdir "$PIECE_MOUNT" echo -e " ${GREEN}${label}: piece.html written to FEDAC-PIECE${NC}" # Flash EROFS LIVE_MOUNT=$(mktemp -d /tmp/fedac-live-XXXX) mount "$p2" "$LIVE_MOUNT" mkdir -p "$LIVE_MOUNT/LiveOS" cp "$EROFS_PATH" "$LIVE_MOUNT/LiveOS/squashfs.img" sync umount "$LIVE_MOUNT" rmdir "$LIVE_MOUNT" LIVE_MOUNT="" echo -e " ${GREEN}${label}: EROFS written${NC}" # Set up EFI partition EFI_MOUNT=$(mktemp -d /tmp/fedac-efi-XXXX) mount "$p1" "$EFI_MOUNT" mkdir -p "$EFI_MOUNT/EFI/BOOT" # Use shimx64.efi as BOOTX64.EFI for Secure Boot support if [ -f "$ROOTFS_DIR/boot/efi/EFI/fedora/shimx64.efi" ]; then cp "$ROOTFS_DIR/boot/efi/EFI/fedora/shimx64.efi" "$EFI_MOUNT/EFI/BOOT/BOOTX64.EFI" # GRUB must sit alongside the shim so it can chain-load it cp "$ROOTFS_DIR/boot/efi/EFI/fedora/grubx64.efi" "$EFI_MOUNT/EFI/BOOT/grubx64.efi" elif [ -f "$ROOTFS_DIR/boot/efi/EFI/fedora/grubx64.efi" ]; then cp "$ROOTFS_DIR/boot/efi/EFI/fedora/grubx64.efi" "$EFI_MOUNT/EFI/BOOT/BOOTX64.EFI" elif [ -f "$ROOTFS_DIR/usr/lib/grub/x86_64-efi/monolithic/grubx64.efi" ]; then cp "$ROOTFS_DIR/usr/lib/grub/x86_64-efi/monolithic/grubx64.efi" "$EFI_MOUNT/EFI/BOOT/BOOTX64.EFI" fi mkdir -p "$EFI_MOUNT/loader" cp "$WORK_DIR/linux" "$EFI_MOUNT/loader/linux" cp "$WORK_DIR/initrd" "$EFI_MOUNT/loader/initrd" cat > "$EFI_MOUNT/EFI/BOOT/grub.cfg" << 'GRUBEOF' set default=0 set timeout=0 insmod all_video menuentry "FedAC Kiosk" { linux /loader/linux quiet root=live:LABEL=FEDAC-LIVE rd.live.image rd.live.overlay.nouserconfirmprompt selinux=0 initrd /loader/initrd } GRUBEOF mkdir -p "$EFI_MOUNT/EFI/fedora" cp "$EFI_MOUNT/EFI/BOOT/grub.cfg" "$EFI_MOUNT/EFI/fedora/grub.cfg" sync umount "$EFI_MOUNT" rmdir "$EFI_MOUNT" EFI_MOUNT="" echo -e " ${GREEN}${label}: EFI configured${NC}" } # ══════════════════════════════════════════ # Step 5: Build image and/or flash USB # ══════════════════════════════════════════ echo -e "${CYAN}[5/6] Building output media...${NC}" if [ -n "$IMAGE_PATH" ]; then echo -e " Building disk image: ${GREEN}${IMAGE_PATH}${NC} (${IMAGE_SIZE_GB}GiB)" truncate -s "${IMAGE_SIZE_GB}G" "$IMAGE_PATH" LOOP_DEV=$(losetup --find --show --partscan "$IMAGE_PATH") build_target "$LOOP_DEV" "Disk image" losetup -d "$LOOP_DEV" LOOP_DEV="" echo -e " ${GREEN}Disk image ready${NC}" fi if [ -n "$DEVICE" ]; then echo "" echo -e "USB target: ${YELLOW}$DEVICE${NC}" lsblk "$DEVICE" 2>/dev/null || true echo "" if [ "$SKIP_CONFIRM" = false ]; then echo -e "${RED}ALL DATA ON $DEVICE WILL BE DESTROYED${NC}" read -p "Continue? [y/N] " confirm [ "$confirm" = "y" ] || [ "$confirm" = "Y" ] || { echo "Aborted."; exit 0; } fi if [ -n "$IMAGE_PATH" ]; then echo -e " Flashing image to USB..." dd if="$IMAGE_PATH" of="$DEVICE" bs=4M status=progress conv=fsync sync echo -e " ${GREEN}USB flashed from image${NC}" else build_target "$DEVICE" "USB" fi fi # ══════════════════════════════════════════ # Step 6: Finalize # ══════════════════════════════════════════ echo -e "${CYAN}[6/6] Finalizing...${NC}" sync # Generate manifest for base images (used by oven /os endpoint) if [ "$BASE_IMAGE_MODE" = true ] && [ -n "$IMAGE_PATH" ] && [ -f "$IMAGE_PATH" ]; then MANIFEST_PATH="${IMAGE_PATH%.img}-manifest.json" IMG_SIZE=$(stat -c%s "$IMAGE_PATH") IMG_SHA256=$(sha256sum "$IMAGE_PATH" | awk '{print $1}') # Get FEDAC-PIECE partition offset/size from GPT table (robust, no fdisk guessing). PIECE_META=$(python3 -c " import subprocess out = subprocess.check_output(['parted', '-s', '-m', '$IMAGE_PATH', 'unit', 'B', 'print'], text=True) part3 = None for raw in out.splitlines(): line = raw.strip() if not line or not line[0].isdigit() or ':' not in line: continue cols = line.split(':') if len(cols) < 4: continue num = cols[0] start = int(cols[1].rstrip('B')) size = int(cols[3].rstrip('B')) name = cols[5].rstrip(';').strip().upper() if len(cols) > 5 else '' if name == 'PIECE': print(f'{start}:{size}') raise SystemExit(0) if num == '3': part3 = (start, size) if part3: print(f'{part3[0]}:{part3[1]}') " 2>/dev/null || true) PIECE_OFFSET=${PIECE_META%%:*} PIECE_SIZE=${PIECE_META##*:} [ -n "${PIECE_OFFSET:-}" ] || PIECE_OFFSET="0" [ -n "${PIECE_SIZE:-}" ] || PIECE_SIZE=$((20 * 1024 * 1024)) cat > "$MANIFEST_PATH" << MANIFEST_EOF { "version": "$(date +%Y-%m-%d)", "fedora": "${FEDORA_RELEASE}", "piecePartitionOffset": ${PIECE_OFFSET}, "piecePartitionSize": ${PIECE_SIZE}, "totalSize": ${IMG_SIZE}, "sha256": "${IMG_SHA256}" } MANIFEST_EOF echo -e " ${GREEN}Manifest written: ${MANIFEST_PATH}${NC}" fi if [ "$DO_EJECT" = true ] && [ -n "$DEVICE" ]; then eject "$DEVICE" 2>/dev/null || true echo -e " ${GREEN}Ejected${NC}" fi # Clean up work dir (but not external rootfs) if [ "$OWN_ROOTFS" = true ]; then echo -e " Cleaning work dir..." rm -rf "$WORK_DIR" else rm -f "$WORK_DIR/piece.html" "$WORK_DIR/squashfs.img" "$WORK_DIR/initrd" "$WORK_DIR/linux" rmdir "$WORK_DIR" 2>/dev/null || true fi echo "" echo -e "${GREEN}╔═══════════════════════════════════════════════╗${NC}" echo -e "${GREEN}║ FedAC Kiosk Build Ready ║${NC}" echo -e "${GREEN}║ Piece: ${CYAN}${PIECE_CODE}${GREEN} (offline) ║${NC}" echo -e "${GREEN}╚═══════════════════════════════════════════════╝${NC}" echo "" if [ -n "$IMAGE_PATH" ]; then echo -e "Image:" echo -e " ${CYAN}${IMAGE_PATH}${NC}" if [ "$BASE_IMAGE_MODE" = true ]; then echo -e "Manifest:" echo -e " ${CYAN}${MANIFEST_PATH}${NC}" echo "" echo -e "Upload to DO Spaces for oven /os endpoint:" echo -e " ${YELLOW}aws s3 cp ${IMAGE_PATH} s3://releases-aesthetic-computer/os/fedac-base-latest.img --endpoint-url https://sfo3.digitaloceanspaces.com --acl public-read${NC}" echo -e " ${YELLOW}aws s3 cp ${MANIFEST_PATH} s3://releases-aesthetic-computer/os/fedac-base-manifest.json --endpoint-url https://sfo3.digitaloceanspaces.com --acl public-read${NC}" fi fi if [ -n "$DEVICE" ]; then echo -e "USB:" echo -e " ${CYAN}${DEVICE}${NC}" fi echo "" echo -e "Boot flow:" echo -e " Power on → GRUB (instant) → ${PINK}PALS splash${NC}" echo -e " → ${PINK}Firefox fullscreen → ${PIECE_CODE}${NC} (offline)" echo "" echo "Plug into target machine → boot from USB → done."