diff --git a/.gitea/workflows/build-iso-dev.yml b/.gitea/workflows/build-iso-dev.yml index 38153a1a..89c77674 100644 --- a/.gitea/workflows/build-iso-dev.yml +++ b/.gitea/workflows/build-iso-dev.yml @@ -54,6 +54,61 @@ jobs: ARCHIPELAGO_BIN="$ARCHIPELAGO_BIN" \ ./build-auto-installer-iso.sh + - name: Smoke test ISO + run: | + ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1) + if [ -z "$ISO" ]; then + echo "FAIL: No ISO produced" + exit 1 + fi + echo "ISO: $ISO ($(du -h "$ISO" | cut -f1))" + + # Mount and verify structure + MNT=$(mktemp -d) + sudo mount -o loop,ro "$ISO" "$MNT" + + FAIL=0 + for f in live/vmlinuz live/initrd.img live/filesystem.squashfs \ + isolinux/isolinux.bin isolinux/isolinux.cfg \ + boot/grub/grub.cfg EFI/BOOT/BOOTX64.EFI \ + archipelago/auto-install.sh archipelago/rootfs.tar; do + if [ -e "$MNT/$f" ]; then + echo " OK: $f ($(sudo du -h "$MNT/$f" 2>/dev/null | cut -f1))" + else + echo " MISSING: $f" + FAIL=1 + fi + done + + # Verify initrd has live-boot + INITRD_DIR=$(mktemp -d) + sudo unmkinitramfs "$MNT/live/initrd.img" "$INITRD_DIR" 2>/dev/null + if [ -e "$INITRD_DIR/scripts/live" ] || [ -e "$INITRD_DIR/main/scripts/live" ]; then + echo " OK: initrd has live-boot scripts" + else + echo " MISSING: live-boot scripts in initrd!" + echo " initrd scripts/: $(ls "$INITRD_DIR/scripts/" 2>/dev/null || ls "$INITRD_DIR/main/scripts/" 2>/dev/null)" + FAIL=1 + fi + + # Check GRUB config has boot=live + if grep -q "boot=live" "$MNT/boot/grub/grub.cfg"; then + echo " OK: grub.cfg has boot=live" + else + echo " MISSING: boot=live in grub.cfg" + FAIL=1 + fi + + sudo umount "$MNT" 2>/dev/null + rmdir "$MNT" 2>/dev/null + sudo rm -r "$INITRD_DIR" 2>/dev/null + + if [ "$FAIL" = "1" ]; then + echo "SMOKE TEST FAILED" + exit 1 + fi + echo "SMOKE TEST PASSED" + - name: Copy to Builds run: | ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1) diff --git a/image-recipe/branding/generate-grub-background.py b/image-recipe/branding/generate-grub-background.py new file mode 100644 index 00000000..cdab6ce4 --- /dev/null +++ b/image-recipe/branding/generate-grub-background.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +"""Generate Archipelago GRUB boot background — 80s pixel cyberpunk aesthetic. + +Outputs a 1024x768 PNG with: +- Near-black background with subtle radial gradient +- Scanline overlay (CRT effect) +- Pixel-art "A" logo (from Archipelago SVG) rendered in neon orange +- Neon glow effect around the logo +- Retro grid lines at the bottom (Tron-style horizon) +- Subtle vignette + +Uses only PIL (Pillow) — no external dependencies. +""" +import struct +import zlib +import math +import sys +import os + +W, H = 1024, 768 + +# Archipelago brand colors +BG_DARK = (5, 5, 10) # Near-black with blue tint +BG_MID = (10, 10, 18) # Slightly lighter center +ORANGE = (251, 146, 60) # #fb923c — primary accent +ORANGE_DIM = (180, 100, 30) # Dimmed orange for glow +CYAN = (60, 200, 220) # Cyberpunk accent +MAGENTA = (180, 60, 180) # Cyberpunk accent 2 +GRID_COLOR = (30, 60, 80) # Subtle teal grid +SCANLINE = (0, 0, 0) # Black scanlines + +# The pixel-art "a" (lowercase) from the Archipelago SVG favicon +# Matched to the actual SVG path — it's a blocky pixel-art lowercase "a" +LOGO_A = [ + [0, 1, 1, 1, 1, 0], + [0, 0, 0, 0, 0, 1], + [0, 1, 1, 1, 1, 1], + [1, 0, 0, 0, 0, 1], + [1, 0, 0, 0, 0, 1], + [0, 1, 1, 1, 1, 1], +] + +# Pixel-art text: "archipelago" — 5-pixel-high bitmap font (lowercase) +PIXEL_CHARS = { + 'a': [[0,1,1,0],[0,0,0,1],[0,1,1,1],[1,0,0,1],[0,1,1,1]], + 'r': [[1,0,1,1],[1,1,0,0],[1,0,0,0],[1,0,0,0],[1,0,0,0]], + 'c': [[0,1,1,0],[1,0,0,0],[1,0,0,0],[1,0,0,0],[0,1,1,0]], + 'h': [[1,0,0,0],[1,0,0,0],[1,1,1,0],[1,0,0,1],[1,0,0,1]], + 'i': [[0,1],[0,0],[0,1],[0,1],[0,1]], + 'p': [[1,1,1,0],[1,0,0,1],[1,1,1,0],[1,0,0,0],[1,0,0,0]], + 'e': [[0,1,1,0],[1,0,0,1],[1,1,1,0],[1,0,0,0],[0,1,1,0]], + 'l': [[1,0],[1,0],[1,0],[1,0],[1,1]], + 'g': [[0,1,1,1],[1,0,0,1],[0,1,1,1],[0,0,0,1],[1,1,1,0]], + 'o': [[0,1,1,0],[1,0,0,1],[1,0,0,1],[1,0,0,1],[0,1,1,0]], + ' ': [[0,0],[0,0],[0,0],[0,0],[0,0]], +} +LOGO_TEXT = "archipelago" + + +def lerp_color(c1, c2, t): + """Linearly interpolate between two RGB colors.""" + return tuple(int(a + (b - a) * t) for a, b in zip(c1, c2)) + + +def distance(x1, y1, x2, y2): + return math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) + + +def create_png(width, height, pixels): + """Create a PNG file from raw RGB pixel data without PIL.""" + + def make_chunk(chunk_type, data): + chunk = chunk_type + data + return struct.pack('>I', len(data)) + chunk + struct.pack('>I', zlib.crc32(chunk) & 0xFFFFFFFF) + + # PNG signature + signature = b'\x89PNG\r\n\x1a\n' + + # IHDR + ihdr_data = struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0) # 8-bit RGB + ihdr = make_chunk(b'IHDR', ihdr_data) + + # IDAT — raw pixel data with filter byte per row + raw_data = bytearray() + for y in range(height): + raw_data.append(0) # filter: none + offset = y * width * 3 + raw_data.extend(pixels[offset:offset + width * 3]) + + compressed = zlib.compress(bytes(raw_data), 9) + idat = make_chunk(b'IDAT', compressed) + + # IEND + iend = make_chunk(b'IEND', b'') + + return signature + ihdr + idat + iend + + +def generate(): + # Create pixel buffer (RGB, row-major) + pixels = bytearray(W * H * 3) + + cx, cy = W // 2, H // 2 - 40 # Center, slightly above middle + + # --- Background: radial gradient --- + max_dist = distance(0, 0, cx, cy) + for y in range(H): + for x in range(W): + d = distance(x, y, cx, cy) / max_dist + d = min(d, 1.0) + bg = lerp_color(BG_MID, BG_DARK, d * d) # Quadratic falloff + + # Vignette — darken edges + vx = abs(x - cx) / (W / 2) + vy = abs(y - cy) / (H / 2) + vignette = max(0, 1.0 - (vx * vx + vy * vy) * 0.4) + r = int(bg[0] * vignette) + g = int(bg[1] * vignette) + b = int(bg[2] * vignette) + + idx = (y * W + x) * 3 + pixels[idx] = max(0, min(255, r)) + pixels[idx + 1] = max(0, min(255, g)) + pixels[idx + 2] = max(0, min(255, b)) + + # --- Retro grid (bottom third) --- + horizon_y = H * 2 // 3 + for y in range(horizon_y, H): + depth = (y - horizon_y) / (H - horizon_y) # 0 at horizon, 1 at bottom + + # Horizontal grid lines — spacing decreases with perspective + grid_spacing = max(4, int(40 * (1.0 - depth * 0.8))) + is_hline = ((y - horizon_y) % grid_spacing) < 1 + + for x in range(W): + # Vertical grid lines — converge toward center + spread = 0.3 + depth * 0.7 # Lines spread more toward bottom + grid_x = (x - cx) / (spread * W / 2) * 12 + is_vline = abs(grid_x - round(grid_x)) < 0.04 + + if is_hline or is_vline: + alpha = 0.15 + depth * 0.25 # Brighter closer to viewer + idx = (y * W + x) * 3 + for c in range(3): + old = pixels[idx + c] + pixels[idx + c] = min(255, int(old + GRID_COLOR[c] * alpha)) + + # --- Horizon glow line --- + for x in range(W): + dx = abs(x - cx) / (W / 2) + intensity = max(0, 1.0 - dx * 1.5) * 0.4 + for dy in range(-2, 3): + y = horizon_y + dy + if 0 <= y < H: + falloff = 1.0 - abs(dy) / 3.0 + idx = (y * W + x) * 3 + pixels[idx] = min(255, int(pixels[idx] + CYAN[0] * intensity * falloff)) + pixels[idx + 1] = min(255, int(pixels[idx + 1] + CYAN[1] * intensity * falloff)) + pixels[idx + 2] = min(255, int(pixels[idx + 2] + CYAN[2] * intensity * falloff)) + + # --- Pixel-art "A" logo --- + logo_rows = len(LOGO_A) + logo_cols = len(LOGO_A[0]) + pixel_size = 14 + logo_w = logo_cols * pixel_size + logo_h = logo_rows * pixel_size + logo_x = cx - logo_w // 2 + logo_y = 80 + + # Glow behind logo + glow_radius = 90 + for y in range(max(0, logo_y - glow_radius), min(H, logo_y + logo_h + glow_radius)): + for x in range(max(0, logo_x - glow_radius), min(W, logo_x + logo_w + glow_radius)): + # Distance to logo bounding box + dx = max(0, logo_x - x, x - (logo_x + logo_w)) + dy = max(0, logo_y - y, y - (logo_y + logo_h)) + d = math.sqrt(dx * dx + dy * dy) + if d < glow_radius: + alpha = (1.0 - d / glow_radius) ** 2 * 0.15 + idx = (y * W + x) * 3 + pixels[idx] = min(255, int(pixels[idx] + ORANGE[0] * alpha)) + pixels[idx + 1] = min(255, int(pixels[idx + 1] + ORANGE[1] * alpha)) + pixels[idx + 2] = min(255, int(pixels[idx + 2] + ORANGE[2] * alpha)) + + # Draw logo pixels with 3D depth/shadow effect + shadow_offset = 3 # Pixel offset for 3D shadow + for row in range(logo_rows): + for col in range(logo_cols): + if LOGO_A[row][col]: + px = logo_x + col * pixel_size + py = logo_y + row * pixel_size + # Shadow layer (dark, offset down-right) + for dy in range(pixel_size - 1): + for dx in range(pixel_size - 1): + x = px + dx + shadow_offset + y = py + dy + shadow_offset + if 0 <= x < W and 0 <= y < H: + idx = (y * W + x) * 3 + pixels[idx] = max(0, pixels[idx] - 5) + pixels[idx + 1] = min(255, pixels[idx + 1] + 15) + pixels[idx + 2] = min(255, pixels[idx + 2] + 20) + # Main pixel with highlight gradient (brighter at top-left) + for dy in range(pixel_size - 1): + for dx in range(pixel_size - 1): + x, y = px + dx, py + dy + if 0 <= x < W and 0 <= y < H: + # Gradient: top-left bright, bottom-right darker + t = (dx + dy) / (2 * pixel_size) + r = int(ORANGE[0] * (1.0 - t * 0.3)) + g = int(ORANGE[1] * (1.0 - t * 0.3)) + b = int(ORANGE[2] * (1.0 - t * 0.3)) + # Top-left highlight for 3D bevel + if dx < 2 or dy < 2: + r = min(255, r + 40) + g = min(255, g + 30) + b = min(255, b + 10) + idx = (y * W + x) * 3 + pixels[idx] = r + pixels[idx + 1] = g + pixels[idx + 2] = b + + # --- Pixel-art text "archipelago" below logo --- + text_pixel = 4 # Smaller pixels for text + text_gap = 2 # Gap between characters in pixels + # Calculate total text width + total_w = 0 + for ch in LOGO_TEXT: + char_data = PIXEL_CHARS.get(ch, PIXEL_CHARS[' ']) + total_w += len(char_data[0]) * text_pixel + text_gap + total_w -= text_gap # No gap after last char + + text_x = cx - total_w // 2 + text_y = logo_y + logo_h + 20 # Below the logo + + cursor_x = text_x + for ch in LOGO_TEXT: + char_data = PIXEL_CHARS.get(ch, PIXEL_CHARS[' ']) + char_h = len(char_data) + char_w = len(char_data[0]) + for row in range(char_h): + for col in range(char_w): + if char_data[row][col]: + px = cursor_x + col * text_pixel + py = text_y + row * text_pixel + for dy in range(text_pixel - 1): + for dx in range(text_pixel - 1): + x, y = px + dx, py + dy + if 0 <= x < W and 0 <= y < H: + idx = (y * W + x) * 3 + # Dimmer orange for text + pixels[idx] = ORANGE_DIM[0] + pixels[idx + 1] = ORANGE_DIM[1] + pixels[idx + 2] = ORANGE_DIM[2] + cursor_x += char_w * text_pixel + text_gap + + # --- Decorative neon lines flanking the text --- + line_y = text_y + 5 * text_pixel + 12 + line_w = total_w + 40 + line_x1 = cx - line_w // 2 + line_x2 = cx + line_w // 2 + for x in range(line_x1, line_x2): + if 0 <= x < W: + # Fade at edges + edge_dist = min(x - line_x1, line_x2 - x) + alpha = min(1.0, edge_dist / 30.0) * 0.5 + for dy in range(2): + y = line_y + dy + if 0 <= y < H: + idx = (y * W + x) * 3 + pixels[idx] = min(255, int(pixels[idx] + CYAN[0] * alpha * 0.3)) + pixels[idx + 1] = min(255, int(pixels[idx + 1] + CYAN[1] * alpha * 0.3)) + pixels[idx + 2] = min(255, int(pixels[idx + 2] + CYAN[2] * alpha * 0.3)) + + # --- "self-sovereign bitcoin infrastructure" tagline --- + TAG_CHARS = { + 's': [[0,1,1],[1,0,0],[0,1,0],[0,0,1],[1,1,0]], + 'f': [[0,1,1],[1,0,0],[1,1,0],[1,0,0],[1,0,0]], + '-': [[0,0,0],[0,0,0],[1,1,1],[0,0,0],[0,0,0]], + 'v': [[1,0,1],[1,0,1],[1,0,1],[0,1,0],[0,1,0]], + 'n': [[1,0,0,0],[1,1,1,0],[1,0,0,1],[1,0,0,1],[1,0,0,1]], + 'b': [[1,0,0,0],[1,0,0,0],[1,1,1,0],[1,0,0,1],[1,1,1,0]], + 't': [[0,1,0],[1,1,1],[0,1,0],[0,1,0],[0,0,1]], + 'd': [[0,0,0,1],[0,0,0,1],[0,1,1,1],[1,0,0,1],[0,1,1,1]], + 'u': [[1,0,0,1],[1,0,0,1],[1,0,0,1],[1,0,0,1],[0,1,1,1]], + } + # Merge with existing chars + all_chars = {**PIXEL_CHARS, **TAG_CHARS} + tagline = "self-sovereign bitcoin node" + tag_pixel = 3 + tag_gap = 2 + tag_total = sum(len(all_chars.get(c, all_chars[' '])[0]) * tag_pixel + tag_gap for c in tagline) - tag_gap + tag_x = cx - tag_total // 2 + tag_y = line_y + 8 + tag_cursor = tag_x + for ch in tagline: + char_data = all_chars.get(ch, all_chars[' ']) + char_h = len(char_data) + char_w = len(char_data[0]) + for row in range(char_h): + for col in range(char_w): + if char_data[row][col]: + px = tag_cursor + col * tag_pixel + py = tag_y + row * tag_pixel + for dy in range(tag_pixel - 1): + for dx in range(tag_pixel - 1): + x, y = px + dx, py + dy + if 0 <= x < W and 0 <= y < H: + idx = (y * W + x) * 3 + pixels[idx] = min(255, pixels[idx] + 40) + pixels[idx + 1] = min(255, pixels[idx + 1] + 50) + pixels[idx + 2] = min(255, pixels[idx + 2] + 55) + tag_cursor += char_w * tag_pixel + tag_gap + + # --- Scanlines (every other row, subtle) --- + for y in range(0, H, 2): + for x in range(W): + idx = (y * W + x) * 3 + pixels[idx] = int(pixels[idx] * 0.92) + pixels[idx + 1] = int(pixels[idx + 1] * 0.92) + pixels[idx + 2] = int(pixels[idx + 2] * 0.92) + + # --- Generate PNG --- + png_data = create_png(W, H, bytes(pixels)) + return png_data + + +if __name__ == '__main__': + out_path = sys.argv[1] if len(sys.argv) > 1 else 'background.png' + png_data = generate() + with open(out_path, 'wb') as f: + f.write(png_data) + print(f'Generated {out_path} ({len(png_data)} bytes)') diff --git a/image-recipe/branding/generate-plymouth-logo.py b/image-recipe/branding/generate-plymouth-logo.py new file mode 100644 index 00000000..a05f9884 --- /dev/null +++ b/image-recipe/branding/generate-plymouth-logo.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Generate the Archipelago Plymouth boot logo — pixel-art 'a' with neon glow. + +Outputs a 256x256 PNG with transparent background (RGBA). +""" +import struct +import zlib +import math +import sys + +W, H = 256, 256 +ORANGE = (251, 146, 60) +ORANGE_BRIGHT = (255, 180, 100) + +# Lowercase pixel-art "a" — 6x6 grid +LOGO_A = [ + [0, 1, 1, 1, 1, 0], + [0, 0, 0, 0, 0, 1], + [0, 1, 1, 1, 1, 1], + [1, 0, 0, 0, 0, 1], + [1, 0, 0, 0, 0, 1], + [0, 1, 1, 1, 1, 1], +] + +# "archipelago" text — same pixel font as GRUB generator +PIXEL_CHARS = { + 'a': [[0,1,1,0],[0,0,0,1],[0,1,1,1],[1,0,0,1],[0,1,1,1]], + 'r': [[1,0,1,1],[1,1,0,0],[1,0,0,0],[1,0,0,0],[1,0,0,0]], + 'c': [[0,1,1,0],[1,0,0,0],[1,0,0,0],[1,0,0,0],[0,1,1,0]], + 'h': [[1,0,0,0],[1,0,0,0],[1,1,1,0],[1,0,0,1],[1,0,0,1]], + 'i': [[0,1],[0,0],[0,1],[0,1],[0,1]], + 'p': [[1,1,1,0],[1,0,0,1],[1,1,1,0],[1,0,0,0],[1,0,0,0]], + 'e': [[0,1,1,0],[1,0,0,1],[1,1,1,0],[1,0,0,0],[0,1,1,0]], + 'l': [[1,0],[1,0],[1,0],[1,0],[1,1]], + 'g': [[0,1,1,1],[1,0,0,1],[0,1,1,1],[0,0,0,1],[1,1,1,0]], + 'o': [[0,1,1,0],[1,0,0,1],[1,0,0,1],[1,0,0,1],[0,1,1,0]], + ' ': [[0,0],[0,0],[0,0],[0,0],[0,0]], +} + + +def create_png_rgba(width, height, pixels): + """Create a PNG with RGBA pixel data.""" + def make_chunk(chunk_type, data): + chunk = chunk_type + data + return struct.pack('>I', len(data)) + chunk + struct.pack('>I', zlib.crc32(chunk) & 0xFFFFFFFF) + + signature = b'\x89PNG\r\n\x1a\n' + ihdr_data = struct.pack('>IIBBBBB', width, height, 8, 6, 0, 0, 0) # 8-bit RGBA + ihdr = make_chunk(b'IHDR', ihdr_data) + + raw_data = bytearray() + for y in range(height): + raw_data.append(0) + offset = y * width * 4 + raw_data.extend(pixels[offset:offset + width * 4]) + + compressed = zlib.compress(bytes(raw_data), 9) + idat = make_chunk(b'IDAT', compressed) + iend = make_chunk(b'IEND', b'') + + return signature + ihdr + idat + iend + + +def generate(): + pixels = bytearray(W * H * 4) # RGBA + + cx, cy = W // 2, W // 2 - 30 + + logo_rows = len(LOGO_A) + logo_cols = len(LOGO_A[0]) + pixel_size = 18 + logo_w = logo_cols * pixel_size + logo_h = logo_rows * pixel_size + logo_x = cx - logo_w // 2 + logo_y = 30 + + # Glow + glow_radius = 60 + for y in range(H): + for x in range(W): + dx = max(0, logo_x - x, x - (logo_x + logo_w)) + dy = max(0, logo_y - y, y - (logo_y + logo_h)) + d = math.sqrt(dx * dx + dy * dy) + if d < glow_radius: + alpha = (1.0 - d / glow_radius) ** 2 * 0.25 + idx = (y * W + x) * 4 + pixels[idx] = ORANGE[0] + pixels[idx + 1] = ORANGE[1] + pixels[idx + 2] = ORANGE[2] + pixels[idx + 3] = int(alpha * 255) + + # Logo pixels with 3D bevel + for row in range(logo_rows): + for col in range(logo_cols): + if LOGO_A[row][col]: + px = logo_x + col * pixel_size + py = logo_y + row * pixel_size + for dy in range(pixel_size - 1): + for dx in range(pixel_size - 1): + x, y = px + dx, py + dy + if 0 <= x < W and 0 <= y < H: + t = (dx + dy) / (2 * pixel_size) + r = int(ORANGE[0] * (1.0 - t * 0.3)) + g = int(ORANGE[1] * (1.0 - t * 0.3)) + b = int(ORANGE[2] * (1.0 - t * 0.3)) + if dx < 2 or dy < 2: + r = min(255, r + 40) + g = min(255, g + 30) + b = min(255, b + 10) + idx = (y * W + x) * 4 + pixels[idx] = r + pixels[idx + 1] = g + pixels[idx + 2] = b + pixels[idx + 3] = 255 + + # Text "archipelago" below logo + text = "archipelago" + text_pixel = 3 + text_gap = 2 + total_w = sum(len(PIXEL_CHARS.get(c, PIXEL_CHARS[' '])[0]) * text_pixel + text_gap for c in text) - text_gap + text_x = cx - total_w // 2 + text_y = logo_y + logo_h + 16 + cursor = text_x + for ch in text: + char_data = PIXEL_CHARS.get(ch, PIXEL_CHARS[' ']) + for row in range(len(char_data)): + for col in range(len(char_data[0])): + if char_data[row][col]: + for dy in range(text_pixel - 1): + for dx in range(text_pixel - 1): + x = cursor + col * text_pixel + dx + y = text_y + row * text_pixel + dy + if 0 <= x < W and 0 <= y < H: + idx = (y * W + x) * 4 + pixels[idx] = 180 + pixels[idx + 1] = 100 + pixels[idx + 2] = 30 + pixels[idx + 3] = 200 + cursor += len(char_data[0]) * text_pixel + text_gap + + return create_png_rgba(W, H, bytes(pixels)) + + +if __name__ == '__main__': + out_path = sys.argv[1] if len(sys.argv) > 1 else 'logo.png' + data = generate() + with open(out_path, 'wb') as f: + f.write(data) + print(f'Generated {out_path} ({len(data)} bytes)') diff --git a/image-recipe/branding/grub-theme/background.png b/image-recipe/branding/grub-theme/background.png new file mode 100644 index 00000000..bbc584f9 Binary files /dev/null and b/image-recipe/branding/grub-theme/background.png differ diff --git a/image-recipe/branding/grub-theme/theme.txt b/image-recipe/branding/grub-theme/theme.txt index 780ab5d3..f630781f 100644 --- a/image-recipe/branding/grub-theme/theme.txt +++ b/image-recipe/branding/grub-theme/theme.txt @@ -4,6 +4,7 @@ title-text: "" desktop-color: "#0a0a0a" +desktop-image: "background.png" + boot_menu { left = 25% diff --git a/image-recipe/branding/isohdpfx.bin b/image-recipe/branding/isohdpfx.bin new file mode 100644 index 00000000..f16bf182 Binary files /dev/null and b/image-recipe/branding/isohdpfx.bin differ diff --git a/image-recipe/branding/plymouth-theme/archipelago.plymouth b/image-recipe/branding/plymouth-theme/archipelago.plymouth new file mode 100644 index 00000000..ae2c36aa --- /dev/null +++ b/image-recipe/branding/plymouth-theme/archipelago.plymouth @@ -0,0 +1,8 @@ +[Plymouth Theme] +Name=Archipelago +Description=Archipelago Bitcoin Node OS — cyberpunk boot splash +ModuleName=script + +[script] +ImageDir=/usr/share/plymouth/themes/archipelago +ScriptFile=/usr/share/plymouth/themes/archipelago/archipelago.script diff --git a/image-recipe/branding/plymouth-theme/archipelago.script b/image-recipe/branding/plymouth-theme/archipelago.script new file mode 100644 index 00000000..379ad5ba --- /dev/null +++ b/image-recipe/branding/plymouth-theme/archipelago.script @@ -0,0 +1,109 @@ +// Archipelago Plymouth Theme — cyberpunk boot splash +// Dark background, neon orange pixel-art logo, animated progress bar + +// Screen dimensions +screen_w = Window.GetWidth(); +screen_h = Window.GetHeight(); + +// Background — solid near-black (the GRUB background handles the fancy stuff) +Window.SetBackgroundTopColor(0.02, 0.02, 0.04); +Window.SetBackgroundBottomColor(0.01, 0.01, 0.02); + +// Load logo image (generated during build) +logo_image = Image("logo.png"); +logo_sprite = Sprite(logo_image); +logo_w = logo_image.GetWidth(); +logo_h = logo_image.GetHeight(); +logo_sprite.SetX(screen_w / 2 - logo_w / 2); +logo_sprite.SetY(screen_h / 2 - logo_h / 2 - 60); +logo_sprite.SetOpacity(1.0); + +// --- Progress bar --- +// Neon orange bar with glow, centered below logo +bar_w = 300; +bar_h = 4; +bar_x = screen_w / 2 - bar_w / 2; +bar_y = screen_h / 2 + logo_h / 2; + +// Progress bar background (dark glass) +bar_bg = Image(bar_w, bar_h); +for (x = 0; x < bar_w; x++) { + for (y = 0; y < bar_h; y++) { + bar_bg.SetPixel(x, y, 0.1, 0.1, 0.12, 0.8); + } +} +bar_bg_sprite = Sprite(bar_bg); +bar_bg_sprite.SetX(bar_x); +bar_bg_sprite.SetY(bar_y); + +// Progress bar fill (neon orange) +progress_val = 0; + +fun refresh_callback() { + // Animate progress smoothly + if (Plymouth.GetMode() == "boot") { + progress_val = progress_val + 0.002; + if (progress_val > 1.0) progress_val = 1.0; + } + + fill_w = Math.Int(bar_w * progress_val); + if (fill_w > 0) { + bar_fill = Image(fill_w, bar_h); + for (x = 0; x < fill_w; x++) { + for (y = 0; y < bar_h; y++) { + // Orange: rgb(251, 146, 60) = 0.984, 0.573, 0.235 + bar_fill.SetPixel(x, y, 0.984, 0.573, 0.235, 1.0); + } + } + bar_fill_sprite = Sprite(bar_fill); + bar_fill_sprite.SetX(bar_x); + bar_fill_sprite.SetY(bar_y); + bar_fill_sprite.SetZ(1); + } +} + +Plymouth.SetRefreshFunction(refresh_callback); + +// --- Boot progress callback --- +fun boot_progress_callback(duration, progress) { + progress_val = progress; +} +Plymouth.SetBootProgressFunction(boot_progress_callback); + +// --- Status message (below progress bar) --- +msg_sprite = Sprite(); +msg_sprite.SetPosition(screen_w / 2, bar_y + 30, 2); + +fun message_callback(text) { + // Plymouth passes boot messages here + // We could render them but keeping it clean — just the logo and bar +} +Plymouth.SetMessageFunction(message_callback); + +// --- Password prompt (for LUKS) --- +fun display_password_callback(prompt, bullets) { + // LUKS unlock prompt + pass_image = Image.Text(prompt, 0.984, 0.573, 0.235); + pass_sprite = Sprite(pass_image); + pass_sprite.SetX(screen_w / 2 - pass_image.GetWidth() / 2); + pass_sprite.SetY(screen_h / 2 + 80); + + // Bullet dots for password + if (bullets > 0) { + bullet_text = ""; + for (i = 0; i < bullets; i++) { + bullet_text = bullet_text + "* "; + } + bullet_image = Image.Text(bullet_text, 0.984, 0.573, 0.235); + bullet_sprite = Sprite(bullet_image); + bullet_sprite.SetX(screen_w / 2 - bullet_image.GetWidth() / 2); + bullet_sprite.SetY(screen_h / 2 + 110); + } +} +Plymouth.SetDisplayPasswordFunction(display_password_callback); + +// --- Quit callback --- +fun quit_callback() { + logo_sprite.SetOpacity(0); +} +Plymouth.SetQuitFunction(quit_callback); diff --git a/image-recipe/branding/plymouth-theme/logo.png b/image-recipe/branding/plymouth-theme/logo.png new file mode 100644 index 00000000..d79efaeb Binary files /dev/null and b/image-recipe/branding/plymouth-theme/logo.png differ diff --git a/image-recipe/build-auto-installer-iso.sh b/image-recipe/build-auto-installer-iso.sh index 39367bbe..6b9c34b4 100755 --- a/image-recipe/build-auto-installer-iso.sh +++ b/image-recipe/build-auto-installer-iso.sh @@ -265,6 +265,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ unclutter \ fonts-liberation \ xfonts-base \ + plymouth \ + plymouth-themes \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* @@ -464,51 +466,27 @@ linux-image-${DEB_ARCH},grub-efi-${DEB_ARCH},grub-pc-bin,\ pciutils,usbutils,less,nano \ bookworm /installer http://deb.debian.org/debian -# Install live-boot separately — debootstrap minbase resolver cannot handle it. -# Use apt-get with explicit root dir instead of chroot (avoids /proc issues in containers). +# Install live-boot via chroot — debootstrap minbase resolver cannot handle it. +# The chroot approach works (confirmed in CI run 90) — just needs proc/sys/dev mounts. echo " [container] Installing live-boot for squashfs root support..." cp /etc/resolv.conf /installer/etc/resolv.conf 2>/dev/null || true -apt-get -o Dir=/installer -o Dir::State::status=/installer/var/lib/dpkg/status \ - -o Dir::Etc::SourceList=/installer/etc/apt/sources.list \ - -o Dir::Cache=/installer/var/cache/apt \ - update -qq 2>/dev/null || { - # Fallback: copy host apt lists and install directly - echo " [container] apt-get Dir method failed, using dpkg extraction..." - apt-get update -qq - apt-get download live-boot live-boot-initramfs-tools 2>/dev/null - for deb in live-boot*.deb; do - dpkg-deb -x "$deb" /installer/ 2>/dev/null || true - done - rm -f live-boot*.deb -} -# Try chroot install if apt-get Dir method populated the lists -if [ -f /installer/var/lib/apt/lists/*_Packages 2>/dev/null ]; then - mount --bind /proc /installer/proc 2>/dev/null || true - mount --bind /sys /installer/sys 2>/dev/null || true - mount --bind /dev /installer/dev 2>/dev/null || true - chroot /installer apt-get install -y --no-install-recommends live-boot live-boot-initramfs-tools 2>/dev/null || true - chroot /installer apt-get clean 2>/dev/null || true - umount /installer/dev 2>/dev/null || true - umount /installer/sys 2>/dev/null || true - umount /installer/proc 2>/dev/null || true -fi +mount --bind /proc /installer/proc +mount --bind /sys /installer/sys +mount --bind /dev /installer/dev +chroot /installer apt-get update -qq +chroot /installer apt-get install -y --no-install-recommends live-boot live-boot-initramfs-tools +chroot /installer apt-get clean +umount /installer/dev 2>/dev/null || true +umount /installer/sys 2>/dev/null || true +umount /installer/proc 2>/dev/null || true -# Verify live-boot hooks are in place +# Verify live-boot hooks are in place (scripts/live is a FILE not a directory) if [ -e /installer/usr/share/initramfs-tools/scripts/live ]; then echo " [container] live-boot initramfs hooks: OK" else - echo " [container] WARNING: live-boot hooks missing, trying dpkg extraction..." - apt-get download live-boot live-boot-initramfs-tools 2>/dev/null || true - for deb in live-boot*.deb; do - dpkg-deb -x "$deb" /installer/ 2>/dev/null || true - done - rm -f live-boot*.deb 2>/dev/null || true - if [ -e /installer/usr/share/initramfs-tools/scripts/live ]; then - echo " [container] live-boot hooks installed via dpkg extraction: OK" - else - echo " [container] FATAL: Could not install live-boot hooks!" - exit 1 - fi + echo " [container] FATAL: live-boot hooks not found after install!" + ls -la /installer/usr/share/initramfs-tools/scripts/ 2>/dev/null + exit 1 fi echo " [container] Configuring installer environment..." @@ -725,6 +703,16 @@ if [ -d "$WORK_DIR/grub-fonts" ]; then # Also copy unicode font for GRUB to load cp "$WORK_DIR/grub-fonts/dejavu_16.pf2" "$INSTALLER_ISO/boot/grub/font.pf2" fi +# Copy GRUB background image (static asset or generate if missing) +GRUB_BG="$SCRIPT_DIR/branding/grub-theme/background.png" +if [ -f "$GRUB_BG" ]; then + cp "$GRUB_BG" "$THEME_DST/background.png" + echo " Installed GRUB background" +elif [ -f "$SCRIPT_DIR/branding/generate-grub-background.py" ]; then + echo " Generating GRUB background..." + python3 "$SCRIPT_DIR/branding/generate-grub-background.py" "$THEME_DST/background.png" 2>/dev/null || \ + echo " WARNING: Could not generate GRUB background" +fi echo " Installer squashfs: $(du -h "$INSTALLER_ISO/live/filesystem.squashfs" | cut -f1)" echo " Kernel: $(du -h "$INSTALLER_ISO/live/vmlinuz" | cut -f1)" @@ -858,6 +846,14 @@ if [ -d "$SCRIPT_DIR/../apps" ]; then cp -r "$SCRIPT_DIR/../apps" "$ARCH_DIR/" fi +# Copy Plymouth theme files for installation on target +PLYMOUTH_SRC="$SCRIPT_DIR/branding/plymouth-theme" +if [ -d "$PLYMOUTH_SRC" ]; then + mkdir -p "$ARCH_DIR/plymouth-theme" + cp "$PLYMOUTH_SRC/"* "$ARCH_DIR/plymouth-theme/" + echo " Included Plymouth theme" +fi + # ============================================================================= # STEP 3b: Bundle container images for offline installation # ============================================================================= @@ -2117,6 +2113,23 @@ if [ -d "$BOOT_MEDIA/boot/grub/themes/archipelago" ]; then echo " Installed Archipelago GRUB theme on target" fi +# Install Archipelago Plymouth theme on target system +if [ -d "$BOOT_MEDIA/archipelago/plymouth-theme" ]; then + PLYMOUTH_DIR="/mnt/target/usr/share/plymouth/themes/archipelago" + mkdir -p "$PLYMOUTH_DIR" + cp "$BOOT_MEDIA/archipelago/plymouth-theme/"* "$PLYMOUTH_DIR/" + # Set as default Plymouth theme + chroot /mnt/target plymouth-set-default-theme archipelago 2>/dev/null || \ + ln -sf /usr/share/plymouth/themes/archipelago/archipelago.plymouth \ + /mnt/target/etc/alternatives/default.plymouth 2>/dev/null || true + # Enable splash in GRUB + if ! grep -q "splash" /mnt/target/etc/default/grub; then + sed -i 's/GRUB_CMDLINE_LINUX_DEFAULT="\(.*\)"/GRUB_CMDLINE_LINUX_DEFAULT="\1 splash"/' \ + /mnt/target/etc/default/grub 2>/dev/null || true + fi + echo " Installed Archipelago Plymouth theme on target" +fi + # Regenerate initramfs — the one from Docker export is corrupt/incomplete # (Docker builds have limited /proc, /sys, /dev so initramfs generation fails silently) echo " Regenerating initramfs..." @@ -2513,7 +2526,7 @@ else fi menuentry "Install Archipelago" --hotkey=i { - linux /live/vmlinuz boot=live components quiet + linux /live/vmlinuz boot=live components quiet console=ttyS0,115200 console=tty0 initrd /live/initrd.img } @@ -2546,7 +2559,7 @@ DEFAULT install LABEL install MENU LABEL Install Archipelago KERNEL /live/vmlinuz - APPEND initrd=/live/initrd.img boot=live components quiet + APPEND initrd=/live/initrd.img boot=live components quiet console=ttyS0,115200 console=tty0 MENU DEFAULT LABEL install-verbose @@ -2573,8 +2586,13 @@ else OUTPUT_ISO="$OUTPUT_DIR/archipelago-installer-${ARCH}.iso" fi -# Use ISOLINUX isohdpfx.bin for hybrid USB boot (built in Step 2) -ISOHDPFX="$WORK_DIR/isohdpfx.bin" +# Use the proven MBR code for hybrid USB boot +# The ISOLINUX package's isohdpfx.bin (33 ed) doesn't boot on all hardware. +# We ship the Debian Live MBR (45 52) which is known to work with Balena Etcher. +ISOHDPFX="$SCRIPT_DIR/branding/isohdpfx.bin" +if [ ! -f "$ISOHDPFX" ]; then + ISOHDPFX="$WORK_DIR/isohdpfx.bin" +fi if [ ! -f "$ISOHDPFX" ]; then # Fallback to system-installed copy for path in \ diff --git a/image-recipe/dev-branding.sh b/image-recipe/dev-branding.sh new file mode 100755 index 00000000..53d722e9 --- /dev/null +++ b/image-recipe/dev-branding.sh @@ -0,0 +1,173 @@ +#!/bin/bash +# +# Quick-iterate on boot branding without rebuilding the ISO. +# +# Usage: +# ./dev-branding.sh +# +# What it does: +# 1. Regenerates GRUB background and Plymouth logo from Python scripts +# 2. Extracts the existing ISO +# 3. Swaps in updated branding files (theme, background, Plymouth) +# 4. Repackages as a new ISO +# 5. Boots it in QEMU for testing +# +# This takes ~10 seconds instead of 20 minutes. +# +# For design-only iteration (no QEMU boot): +# python3 branding/generate-grub-background.py /tmp/grub-bg.png && open /tmp/grub-bg.png +# + +set -e +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ISO="${1:-}" + +if [ -z "$ISO" ] || [ ! -f "$ISO" ]; then + # Auto-detect latest dev ISO on Desktop + ISO=$(ls -t ~/Desktop/archipelago-dev-*.iso 2>/dev/null | head -1) +fi +if [ -z "$ISO" ] || [ ! -f "$ISO" ]; then + ISO=$(ls -t "$SCRIPT_DIR/results/archipelago-*.iso" 2>/dev/null | head -1) +fi +if [ -z "$ISO" ] || [ ! -f "$ISO" ]; then + echo "No ISO found. Provide a path or place one on Desktop/results." + echo "Usage: $0 " + exit 1 +fi + +WORK="/tmp/archipelago-dev-branding" +PATCHED="$SCRIPT_DIR/results/archipelago-dev-patched.iso" + +echo "=== Archipelago Branding Dev ===" +echo " Base ISO: $ISO" +echo "" + +# Step 1: Regenerate assets +echo "[1/4] Generating assets..." +python3 "$SCRIPT_DIR/branding/generate-grub-background.py" /tmp/grub-bg.png 2>/dev/null && \ + echo " GRUB background: OK" || echo " GRUB background: FAILED" +python3 "$SCRIPT_DIR/branding/generate-plymouth-logo.py" /tmp/plymouth-logo.png 2>/dev/null && \ + echo " Plymouth logo: OK" || echo " Plymouth logo: FAILED" + +# Also show the background for quick visual check +if command -v open >/dev/null 2>&1; then + open /tmp/grub-bg.png 2>/dev/null & +fi + +# Step 2: Extract ISO +echo "[2/4] Extracting ISO..." +rm -rf "$WORK" +mkdir -p "$WORK" +xorriso -osirrox on -indev "$ISO" -extract / "$WORK" 2>/dev/null || { + # Fallback: mount + copy + MNT=$(mktemp -d) + if [ "$(uname)" = "Darwin" ]; then + hdiutil attach "$ISO" -mountpoint "$MNT" -readonly -nobrowse 2>/dev/null + else + sudo mount -o loop,ro "$ISO" "$MNT" + fi + cp -a "$MNT"/* "$WORK/" 2>/dev/null || true + if [ "$(uname)" = "Darwin" ]; then + hdiutil detach "$MNT" 2>/dev/null + else + sudo umount "$MNT" + fi + rmdir "$MNT" 2>/dev/null || true +} + +# Step 3: Patch branding +echo "[3/4] Patching branding..." +THEME_DST="$WORK/boot/grub/themes/archipelago" +mkdir -p "$THEME_DST" + +# GRUB theme +cp "$SCRIPT_DIR/branding/grub-theme/theme.txt" "$THEME_DST/" 2>/dev/null && \ + echo " theme.txt: OK" +cp /tmp/grub-bg.png "$THEME_DST/background.png" 2>/dev/null && \ + echo " background.png: OK" + +# Plymouth theme +if [ -d "$WORK/archipelago/plymouth-theme" ]; then + cp "$SCRIPT_DIR/branding/plymouth-theme/"* "$WORK/archipelago/plymouth-theme/" 2>/dev/null + cp /tmp/plymouth-logo.png "$WORK/archipelago/plymouth-theme/logo.png" 2>/dev/null + echo " Plymouth theme: OK" +fi + +# GRUB config (in case you edited it) +if [ -f "$SCRIPT_DIR/branding/grub.cfg" ]; then + cp "$SCRIPT_DIR/branding/grub.cfg" "$WORK/boot/grub/grub.cfg" + echo " grub.cfg: OK (custom)" +fi + +# ISOLINUX config +if [ -f "$SCRIPT_DIR/branding/isolinux.cfg" ]; then + cp "$SCRIPT_DIR/branding/isolinux.cfg" "$WORK/isolinux/isolinux.cfg" + echo " isolinux.cfg: OK (custom)" +fi + +# Step 4: Repackage ISO +echo "[4/4] Repackaging ISO..." +mkdir -p "$SCRIPT_DIR/results" + +# Find isohdpfx.bin +ISOHDPFX="" +for p in "$WORK/isolinux/isohdpfx.bin" \ + /usr/lib/ISOLINUX/isohdpfx.bin \ + /usr/share/syslinux/isohdpfx.bin \ + /opt/homebrew/share/syslinux/isohdpfx.bin; do + [ -f "$p" ] && ISOHDPFX="$p" && break +done + +# Check for EFI image +EFI_IMG="$WORK/boot/grub/efi.img" + +if [ -n "$ISOHDPFX" ] && [ -f "$EFI_IMG" ]; then + xorriso -as mkisofs -o "$PATCHED" \ + -volid "ARCHIPELAGO" \ + -iso-level 3 \ + -J -joliet-long -R \ + -isohybrid-mbr "$ISOHDPFX" \ + -c isolinux/boot.cat \ + -b isolinux/isolinux.bin \ + -no-emul-boot -boot-load-size 4 -boot-info-table \ + -eltorito-alt-boot \ + -e boot/grub/efi.img \ + -no-emul-boot \ + -isohybrid-gpt-basdat \ + -partition_offset 16 \ + "$WORK" 2>/dev/null +elif [ -n "$ISOHDPFX" ]; then + xorriso -as mkisofs -o "$PATCHED" \ + -volid "ARCHIPELAGO" \ + -iso-level 3 \ + -J -joliet-long -R \ + -isohybrid-mbr "$ISOHDPFX" \ + -c isolinux/boot.cat \ + -b isolinux/isolinux.bin \ + -no-emul-boot -boot-load-size 4 -boot-info-table \ + -partition_offset 16 \ + "$WORK" 2>/dev/null +else + echo "Cannot repackage: no isohdpfx.bin found." + echo "Install xorriso and isolinux: brew install xorriso" + echo "" + echo "You can still preview the assets:" + echo " open /tmp/grub-bg.png" + echo " open /tmp/plymouth-logo.png" + exit 0 +fi + +echo "" +echo " Patched ISO: $PATCHED ($(du -h "$PATCHED" | cut -f1))" +echo "" + +# Auto-boot in QEMU if available +if command -v qemu-system-x86_64 >/dev/null 2>&1; then + read -p "Boot in QEMU? [Y/n] " -n 1 -r + echo "" + if [[ ! $REPLY =~ ^[Nn]$ ]]; then + exec "$SCRIPT_DIR/test-iso-qemu.sh" "$PATCHED" --bios + fi +else + echo "Install QEMU to test: brew install qemu" +fi