From a16ac37d205d6a10be36054bacf7c6b833feaf8c Mon Sep 17 00:00:00 2001 From: mozempk Date: Wed, 22 Apr 2026 23:53:16 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20commit=20=E2=80=94=20void-ins?= =?UTF-8?q?taller=20multi-profile=20(stable-cinnamon=20+=20mainline-niri)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 33 ++ Makefile | 58 +++ README.md | 195 ++++++++ config/install.conf | 91 ++++ config/packages.live.list | 19 + .../mainline-niri/customizations/niri.sh | 149 ++++++ config/profiles/mainline-niri/packages.list | 187 ++++++++ config/profiles/mainline-niri/profile.conf | 31 ++ config/profiles/stable-cinnamon/packages.list | 174 +++++++ config/profiles/stable-cinnamon/profile.conf | 20 + docs/TESTING-ON-REAL-HARDWARE.md | 190 ++++++++ installer/first-login.sh | 77 ++++ installer/install.sh | 118 +++++ installer/lib/bootstrap.sh | 123 +++++ installer/lib/common.sh | 98 ++++ installer/lib/customizations.sh | 432 ++++++++++++++++++ installer/lib/grub.sh | 56 +++ installer/lib/partition.sh | 130 ++++++ installer/lib/postinstall.sh | 351 ++++++++++++++ installer/lib/profiles.sh | 53 +++ installer/lib/tui.sh | 77 ++++ iso/Dockerfile | 19 + iso/_inner-build.sh | 61 +++ iso/build-iso.sh | 296 ++++++++++++ iso/patches/0001-cgroupv2-lazy-umount.patch | 38 ++ .../0002-mtools-efi-and-onefs-cp.patch | 62 +++ tests/boot-niri-interactive.sh | 20 + tests/interactive-qemu.sh | 34 ++ tests/lib/make-test-overlay.sh | 107 +++++ tests/make-test-disk.sh | 53 +++ tests/run-niri-install.sh | 94 ++++ tests/run-qemu-test.sh | 242 ++++++++++ tools/start-xbps-proxy.sh | 35 ++ tools/stop-xbps-proxy.sh | 19 + tools/xbps-proxy.py | 160 +++++++ 35 files changed, 3902 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 config/install.conf create mode 100644 config/packages.live.list create mode 100644 config/profiles/mainline-niri/customizations/niri.sh create mode 100644 config/profiles/mainline-niri/packages.list create mode 100644 config/profiles/mainline-niri/profile.conf create mode 100644 config/profiles/stable-cinnamon/packages.list create mode 100644 config/profiles/stable-cinnamon/profile.conf create mode 100644 docs/TESTING-ON-REAL-HARDWARE.md create mode 100644 installer/first-login.sh create mode 100755 installer/install.sh create mode 100755 installer/lib/bootstrap.sh create mode 100755 installer/lib/common.sh create mode 100644 installer/lib/customizations.sh create mode 100755 installer/lib/grub.sh create mode 100755 installer/lib/partition.sh create mode 100755 installer/lib/postinstall.sh create mode 100644 installer/lib/profiles.sh create mode 100755 installer/lib/tui.sh create mode 100644 iso/Dockerfile create mode 100755 iso/_inner-build.sh create mode 100755 iso/build-iso.sh create mode 100644 iso/patches/0001-cgroupv2-lazy-umount.patch create mode 100644 iso/patches/0002-mtools-efi-and-onefs-cp.patch create mode 100755 tests/boot-niri-interactive.sh create mode 100755 tests/interactive-qemu.sh create mode 100755 tests/lib/make-test-overlay.sh create mode 100755 tests/make-test-disk.sh create mode 100755 tests/run-niri-install.sh create mode 100755 tests/run-qemu-test.sh create mode 100755 tools/start-xbps-proxy.sh create mode 100755 tools/stop-xbps-proxy.sh create mode 100644 tools/xbps-proxy.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7ed45d --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# ── Secrets — NEVER commit ──────────────────────────────────────────── +secrets.env +secrets.env.local +*.pem +*.key +id_rsa* +id_ed25519* +id_ecdsa* +id_github* +id_gitea* +id_ovh* +authorized_keys + +# ── Generated build staging (build-iso.sh populates this at build time) ─ +build/includes/ +build/first-login.sh + +# ── Build artifacts ──────────────────────────────────────────────────── +out/ +*.iso +*.iso.sha256 +*.qcow2 +*.img +nohup.out + +# ── Cached downloads / XBPS package cache ───────────────────────────── +cache/ + +# ── Editor ───────────────────────────────────────────────────────────── +.vscode/ +*.swp +*.swo +.DS_Store diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f630a3d --- /dev/null +++ b/Makefile @@ -0,0 +1,58 @@ +# Void Installer — XPS 17 (xps9700) +# +# Targets: +# make iso build the auto-installing ISO (uses docker) +# make test-disk create a fresh QEMU test disk that mimics XPS 17 layout +# make test full automated QEMU smoke test +# make test-iso rebuild only the TEST ISO variant +# make qemu launch QEMU interactively with the latest ISO +# make shellcheck lint all installer/build shell scripts +# make clean remove build/, out/ (cache stays) +# make distclean also remove cache/ + +PROJECT_DIR := $(CURDIR) +OUT := $(PROJECT_DIR)/out +SECRETS := $(PROJECT_DIR)/secrets.env + +.PHONY: all iso test test-disk test-iso qemu shellcheck clean distclean check-secrets check-docker + +all: iso + +check-secrets: + @test -r $(SECRETS) || { echo "missing $(SECRETS) — copy from template"; exit 1; } + +check-docker: + @command -v docker >/dev/null || { echo "ERROR: docker not installed"; exit 1; } + @docker info >/dev/null 2>&1 || { echo "ERROR: docker daemon unreachable (in 'docker' group? systemctl start docker?)"; exit 1; } + +iso: check-secrets check-docker + $(PROJECT_DIR)/iso/build-iso.sh + +test-iso: check-secrets check-docker + REBUILD_ISO=1 $(PROJECT_DIR)/tests/run-qemu-test.sh + +test-disk: + $(PROJECT_DIR)/tests/make-test-disk.sh $(OUT)/test-disk.img + +test: check-secrets check-docker + @mkdir -p $(OUT) + $(PROJECT_DIR)/tests/run-qemu-test.sh + +qemu: + $(PROJECT_DIR)/tests/interactive-qemu.sh + +shellcheck: + @command -v shellcheck >/dev/null || { echo "shellcheck not installed"; exit 1; } + shellcheck -x \ + $(PROJECT_DIR)/installer/install.sh \ + $(PROJECT_DIR)/installer/lib/*.sh \ + $(PROJECT_DIR)/iso/build-iso.sh \ + $(PROJECT_DIR)/iso/_inner-build.sh \ + $(PROJECT_DIR)/tests/*.sh \ + $(PROJECT_DIR)/tests/lib/*.sh + +clean: + rm -rf $(PROJECT_DIR)/build $(OUT) + +distclean: clean + rm -rf $(PROJECT_DIR)/cache diff --git a/README.md b/README.md new file mode 100644 index 0000000..a74739f --- /dev/null +++ b/README.md @@ -0,0 +1,195 @@ +# void-installer — XPS 17 (xps9700) + +Auto-installing Void Linux ISO targeted at a Dell XPS 17 9700 dual-booting Windows. +Final installed system ships with: **Cinnamon**, **Docker**, **VS Code**, your +SSH config, NVIDIA PRIME render-offload, and Nix for Spotify/Discord/LocalSend. + +> ⚠️ **Destructive.** This installer wipes one partition. Read the +> [Safety](#safety) section before running on real hardware. + +--- + +## Layout produced + +| What | Where | +|------------------------------|--------------------------------------| +| Void root (btrfs `@`) | `/dev/nvme0n1p5` (replaces Mint) | +| `/home`, `/.snapshots`, etc. | btrfs subvolumes on the same device | +| EFI System Partition | `/dev/nvme0n1p1` *(shared, untouched)* | +| GRUB target | `\EFI\Void\` (Windows entry preserved) | +| Windows | `/dev/nvme0n1p3` *(left alone)* | + +--- + +## Repo layout + +``` +config/install.conf # all knobs (hostname, locale, kbd, GPU, etc.) +config/packages.target.list # xbps packages installed into target +config/packages.live.list # extra packages added to the LIVE iso +secrets.env # USER_PASSWORD / ROOT_PASSWORD (gitignored) +installer/ + install.sh # main entrypoint, runs in the live env + lib/ # tui, partition, bootstrap, grub, postinstall +iso/ + build-iso.sh # host-side: stages overlay, then runs mklive in docker + _inner-build.sh # invoked inside the docker container as root + Dockerfile # debian:stable-slim + mtools/xorriso/squashfs-tools + patches/ # patches applied to upstream void-mklive +tests/ + make-test-disk.sh # builds a qcow2 mimicking the XPS 17 partition table + run-qemu-test.sh # automated headless install + smoke tests + interactive-qemu.sh # GUI QEMU for manual exploration + lib/make-test-overlay.sh +Makefile # build / test entrypoints +``` + +--- + +## Quick start + +### Build dependencies (host) + +Any Linux with **bash, git, curl, docker** (and `qemu` + `ovmf` if you want to run +`make test` / `make qemu`). The mklive build runs inside a Debian container, so +you don't need `mtools`, `xorriso`, `squashfs-tools`, etc. on the host. + +- **Void**: `xbps-install -S git curl docker qemu ovmf` +- **Debian/Ubuntu/Mint**: `apt install git curl docker.io qemu-system-x86 qemu-utils ovmf` +- **Arch**: `pacman -S git curl docker qemu-full edk2-ovmf` + +Make sure your user is in the `docker` group (`sudo usermod -aG docker $USER`, +then log out / back in) so the build runs without `sudo`. + +### 1. Provide secrets + +`secrets.env` is gitignored and pre-populated for you with the values from the +chat. Edit if you want different passwords. + +### 2. Build the ISO + +```sh +make iso +# -> out/void-install-xps9700-YYYYMMDD.iso +``` + +### 3. Test in QEMU (recommended before flashing!) + +```sh +make test # full headless install + smoke tests (~15-30 min) +# or +make qemu # interactive QEMU window with the ISO booted +``` + +### 4. Flash to USB and boot the XPS 17 + +```sh +sudo dd if=out/void-install-xps9700-*.iso of=/dev/sdX bs=4M status=progress conv=fsync +``` + +Boot the XPS from the USB (F12 boot menu). The TUI shows detected partitions +with `[WINDOWS]` and `[EFI]` markers. Pick `/dev/nvme0n1p5`, type the device +path verbatim to confirm, and let it run. + +--- + +## Safety + +- The installer **refuses** to wipe any NTFS partition. +- A TUI confirmation step requires you to **type the full device path** before + any destructive action. +- The shared EFI partition is **mounted read-write but never reformatted**; + Windows boot files under `EFI/Microsoft/` are preserved. +- All passwords come from `/etc/installer-secrets.env` baked into the ISO at + build time (mode `0600`). They are not on the network and not in `argv`. + +If you want to be paranoid, run `make test` first — the smoke test asserts +that `EFI/Microsoft/Boot/bootmgfw.efi` survives the install on a simulated +disk with the same layout as the XPS. + +--- + +## Configuration + +Everything tunable lives in [config/install.conf](config/install.conf): + +- locale `en_US.UTF-8`, keymap `ch-fr_nodeadkeys`, timezone `Europe/Zurich` +- hostname `xps9700`, user `moze` in `wheel,docker,video,audio,...` +- btrfs subvolumes `@`, `@home`, `@snapshots`, `@var_log`, `@var_cache` +- GPU mode `prime-offload` (Intel UHD primary, NVIDIA GTX 1650 Ti via `prime-run`) +- zram swap at 50% RAM (zramen) +- nonfree + multilib + multilib-nonfree repos enabled + +--- + +## What's installed + +**xbps:** base-system, linux, intel-ucode, NetworkManager, cinnamon, lightdm, +docker, docker-compose, vscode (Microsoft), firefox, vlc, obs, flameshot, +nvidia + nvidia-libs-32bit, pipewire, sudo, git, curl, vim, tlp, nix, … +(full list in [config/packages.target.list](config/packages.target.list)) + +**nix profile (first boot, as moze):** spotify, discord, localsend. +The first-boot service runs once after `nix-daemon` is up, then exits. + +--- + +## Post-install + +After first reboot: + +1. Log in as `moze` (password: `void`). +2. The Cinnamon greeter appears (LightDM). +3. The first-boot Nix service installs Spotify/Discord/LocalSend in the + background (~5–15 min depending on connection). Watch with + `journalctl -fu first-boot-nix` is replaced by `tail -f /var/log/socklog/everything/current` on Void's runit; or just check `nix profile list` later. +4. Run NVIDIA-accelerated apps with `prime-run `. + +--- + +## Test harness + +`tests/run-qemu-test.sh` runs end-to-end: + +1. Builds a TEST ISO variant (overlay forces `UNATTENDED=1 TEST_MODE=1` and + bakes a one-off ssh keypair for the harness). +2. Builds a fresh `out/test-disk.qcow2` with the **same partition layout** + as the XPS (EFI + MSR + NTFS "Windows" placeholder + btrfs "Mint"). +3. Boots the ISO under QEMU/OVMF; installer runs unattended; VM powers off. +4. Boots the installed disk; ssh's in as `moze`; runs ~20 smoke assertions. + +```sh +make test # default +TIMEOUT_INSTALL=5400 make test # slower hosts +ACCEL=tcg make test # no KVM (e.g., nested virt without) +``` + +--- + +## Files of interest while debugging + +| File on installed system | Purpose | +|------------------------------------|------------------------------------------| +| `/var/log/void-installer.log` | full installer log | +| `/etc/sv/first-boot-nix/run` | one-shot nix profile installer | +| `/usr/local/bin/prime-run` | NVIDIA PRIME offload wrapper | +| `/etc/X11/xorg.conf.d/20-nvidia.conf` | NVIDIA display config | +| `/etc/runit/runsvdir/default/` | enabled services | + +| File on the live ISO | Purpose | +|------------------------------------|------------------------------------------| +| `/usr/local/share/installer/` | installer scripts + config + packages | +| `/etc/installer-secrets.env` | passwords (mode 0600) | +| `/etc/installer-ssh/` | snapshot of build-host `~/.ssh/` | + +--- + +## Caveats + +- 150 MB shared EFI is tight (was 91 % full before install). Void puts only + `\EFI\Void\grubx64.efi` (~150 KB) there; kernels live on btrfs `/boot`. +- Secure Boot is **off** (per the input). NVIDIA proprietary modules won't + load with SB on without manual MOK enrollment. +- The installer assumes `/dev/nvme0n1p5` is currently Linux Mint (btrfs). + If that ever changes, the TUI default will be wrong but the listing is + always live, and the confirmation step prevents accidents. diff --git a/config/install.conf b/config/install.conf new file mode 100644 index 0000000..4edaf33 --- /dev/null +++ b/config/install.conf @@ -0,0 +1,91 @@ +# Void installer configuration for the XPS 17 (xps9700) +# Sourced by installer/install.sh inside the live environment. +# All passwords come from /etc/installer-secrets.env (baked at ISO build). + +# ---------- Identity ---------- +HOSTNAME="xps9700" +USERNAME="moze" +USER_FULLNAME="moze" +USER_UID="1000" +USER_GROUPS="wheel,docker,video,audio,input,plugdev,network,kvm,users" +DEFAULT_SHELL="/bin/bash" + +# ---------- Locale ---------- +LOCALE="en_US.UTF-8" +LANG="en_US.UTF-8" +KEYMAP="ch-fr_nodeadkeys" # Swiss French keyboard +TIMEZONE="Europe/Zurich" +HARDWARECLOCK="UTC" + +# ---------- Repository ---------- +REPO_URL="https://repo-default.voidlinux.org/current" +ARCH="x86_64" # glibc +EXTRA_REPOS=(nonfree multilib multilib/nonfree) + +# During install, packages can be fetched via a local caching proxy (set by +# the test harness to http://10.0.2.2:3142/current). Empty = use REPO_URL. +INSTALL_REPO_URL="" + +# ---------- Disk layout ---------- +# Defaults match the detected XPS 17 layout. The TUI overrides these +# after explicit user confirmation. +DEFAULT_DISK="/dev/nvme0n1" +DEFAULT_ROOT_PART="/dev/nvme0n1p5" # Linux Mint -> Void +DEFAULT_EFI_PART="/dev/nvme0n1p1" # SHARED with Windows; never reformatted +DEFAULT_FS="btrfs" +# Btrfs subvolume layout. Each entry: ":" +# ("@" is the root subvolume; mountpoint is relative to the install root). +BTRFS_SUBVOLS=( + "@:/" + "@home:/home" + "@snapshots:/.snapshots" + "@var_log:/var/log" + "@var_cache:/var/cache" +) +BTRFS_MOUNT_OPTS="rw,noatime,ssd,compress=zstd:3,space_cache=v2,discard=async" +EFI_MOUNTPOINT="/boot/efi" + +# ---------- Boot ---------- +BOOTLOADER="grub" +BOOTLOADER_ID="Void" +ENABLE_OS_PROBER="yes" # detect Windows on /dev/nvme0n1p3 + +# ---------- Hardware ---------- +CPU_VENDOR="intel" # microcode -> intel-ucode +GPU_MODE="prime-offload" # Intel UHD primary, NVIDIA GTX 1650 Ti on demand +WIFI_FW="yes" +KERNEL_PKG="linux" + +# ---------- Services ---------- +SSHD_ENABLE="no" +NETWORK_MGR="NetworkManager" +DISPLAY_MANAGER="lightdm" +DESKTOP="cinnamon" +ZRAM_ENABLE="yes" +ZRAM_SIZE_PCT="50" # 50% of RAM + +# ---------- SSH config ---------- +SSH_SOURCE_DIR="/etc/installer-ssh" # baked into ISO from /home/moze/.ssh +SSH_TARGET_DIR_REL=".ssh" + +# ---------- Nix ---------- +ENABLE_NIX="yes" +# Apps installed via `nix profile install` after first boot for $USERNAME: +NIX_USER_PACKAGES=( + "nixpkgs#spotify" + "nixpkgs#discord" + "nixpkgs#localsend" + "nixpkgs#google-chrome" + "nixpkgs#mission-center" +) + +# ---------- Cinnamon customization ---------- +GTK_THEME="Gruvbox-Dark" +ICON_THEME="Gruvbox-Plus-Dark" +CURSOR_THEME="Bibata-Modern-Ice" +DEFAULT_TERMINAL="alacritty" +INITIAL_WALLPAPER="pxfuel.jpg" + +# ---------- Test mode flag ---------- +# Set TEST_MODE=1 in env when running under QEMU smoke tests. +TEST_MODE="${TEST_MODE:-0}" diff --git a/config/packages.live.list b/config/packages.live.list new file mode 100644 index 0000000..8125089 --- /dev/null +++ b/config/packages.live.list @@ -0,0 +1,19 @@ +# Extra packages included in the live ISO (installer environment only). +dialog +ncurses +util-linux +gptfdisk +parted +btrfs-progs +dosfstools +xtools +rsync +curl +git +vim +NetworkManager +xz +tar +ca-certificates +pciutils +usbutils diff --git a/config/profiles/mainline-niri/customizations/niri.sh b/config/profiles/mainline-niri/customizations/niri.sh new file mode 100644 index 0000000..be426ab --- /dev/null +++ b/config/profiles/mainline-niri/customizations/niri.sh @@ -0,0 +1,149 @@ +#!/bin/bash +# Niri-specific customizations. Sourced by customizations.sh after the generic +# helpers when PROFILE=mainline-niri. +# Available env: $TARGET, $USERNAME, $PROFILE, $PROFILE_DIR, all install.conf vars. + +_niri_write_kdl() { + local TARGET="$1" + local cfg="$TARGET/etc/skel/.config/niri" + install -d -m 0755 "$cfg" + cat > "$cfg/config.kdl" <<'EOF' +// niri config — generated by void-installer (mainline-niri profile). +input { + keyboard { + xkb { + layout "ch" + variant "fr" + } + } + touchpad { + tap + natural-scroll + dwt + } + mouse { + accel-speed 0.0 + } +} + +layout { + gaps 12 + center-focused-column "never" + preset-column-widths { + proportion 0.33333 + proportion 0.5 + proportion 0.66667 + } + default-column-width { proportion 0.5; } + focus-ring { + width 2 + active-color "#fabd2f" + inactive-color "#3c3836" + } + border { off; } +} + +prefer-no-csd + +spawn-at-startup "swaybg" "-i" "/usr/share/backgrounds/void-installer/pxfuel.jpg" +spawn-at-startup "mako" +spawn-at-startup "nm-applet" "--indicator" +spawn-at-startup "blueman-applet" +spawn-at-startup "/usr/libexec/polkit-gnome-authentication-agent-1" +spawn-at-startup "noctalia-shell" + +cursor { + xcursor-theme "Bibata-Modern-Ice" + xcursor-size 24 +} + +binds { + Mod+T { spawn "alacritty"; } + Mod+D { spawn "fuzzel"; } + Mod+Q { close-window; } + Mod+Shift+E { quit; } + Print { screenshot; } + Mod+H { focus-column-left; } + Mod+L { focus-column-right; } + Mod+J { focus-window-down; } + Mod+K { focus-window-up; } + Mod+Shift+H { move-column-left; } + Mod+Shift+L { move-column-right; } + Mod+1 { focus-workspace 1; } + Mod+2 { focus-workspace 2; } + Mod+3 { focus-workspace 3; } + Mod+4 { focus-workspace 4; } + Mod+Shift+1 { move-column-to-workspace 1; } + Mod+Shift+2 { move-column-to-workspace 2; } + XF86AudioRaiseVolume { spawn "wpctl" "set-volume" "@DEFAULT_AUDIO_SINK@" "5%+"; } + XF86AudioLowerVolume { spawn "wpctl" "set-volume" "@DEFAULT_AUDIO_SINK@" "5%-"; } + XF86AudioMute { spawn "wpctl" "set-mute" "@DEFAULT_AUDIO_SINK@" "toggle"; } + XF86MonBrightnessUp { spawn "brightnessctl" "set" "+5%"; } + XF86MonBrightnessDown { spawn "brightnessctl" "set" "5%-"; } +} +EOF + # Mirror into the actual user home. + install -d -m 0755 "$TARGET/home/$USERNAME/.config/niri" + cp "$cfg/config.kdl" "$TARGET/home/$USERNAME/.config/niri/config.kdl" + run_chroot "chown -R $USERNAME:$USERNAME /home/$USERNAME/.config/niri" || true + log "niri KDL config installed" +} + +_niri_write_env() { + local TARGET="$1" + cat > "$TARGET/etc/profile.d/wayland.sh" <<'EOF' +# Wayland defaults installed by void-installer (mainline-niri profile). +export QT_QPA_PLATFORM="wayland;xcb" +export GDK_BACKEND=wayland,x11 +export MOZ_ENABLE_WAYLAND=1 +export _JAVA_AWT_WM_NONREPARENTING=1 +export XDG_CURRENT_DESKTOP=niri +export XDG_SESSION_TYPE=wayland +EOF + chmod 0644 "$TARGET/etc/profile.d/wayland.sh" + log "wayland environment installed at /etc/profile.d/wayland.sh" +} + +_niri_setup_greetd() { + local TARGET="$1" + install -d -m 0755 "$TARGET/etc/greetd" + cat > "$TARGET/etc/greetd/config.toml" < "$TARGET/etc/xbps.d/10-noctalia.conf" <<'EOF' +repository=https://universalrepo.r1xelelo.workers.dev/void +EOF + + # If quickshell is somehow installed it conflicts with noctalia-qs. + run_chroot "xbps-remove -y quickshell 2>/dev/null || true" + + # Sync the new repo and install. Prefix with the proxy mirror configured + # by the installer environment so noctalia-shell deps still resolve fast. + if ! run_chroot "xbps-install -Sy"; then + log "WARN: noctalia repo sync failed; skipping noctalia-shell install" + return 0 + fi + if run_chroot "xbps-install -y noctalia-shell"; then + log "noctalia-shell installed from third-party repo" + else + log "WARN: noctalia-shell install failed (repo may be down); shell omitted" + fi +} + +_niri_write_kdl "$TARGET" +_niri_write_env "$TARGET" +_niri_setup_greetd "$TARGET" +_niri_install_noctalia "$TARGET" diff --git a/config/profiles/mainline-niri/packages.list b/config/profiles/mainline-niri/packages.list new file mode 100644 index 0000000..ec4f3c3 --- /dev/null +++ b/config/profiles/mainline-niri/packages.list @@ -0,0 +1,187 @@ +# Packages installed into the target system for the mainline-niri profile. +# Lines beginning with '#' or empty are skipped. + +# --- base / boot --- +base-system +linux-mainline +linux-firmware +linux-firmware-network +intel-ucode +grub-x86_64-efi +efibootmgr +os-prober +dracut +gptfdisk +parted +btrfs-progs +dosfstools + +# --- core userspace --- +sudo +bash +bash-completion +git +curl +wget +vim +nano +htop +tmux +unzip +zip +xz +rsync +pciutils +usbutils +lsof +strace +file +which +man-pages +mdocml +ca-certificates +xtools + +# --- networking --- +NetworkManager +NetworkManager-openvpn +openssh +iwd +nftables +chrony + +# --- audio (pipewire stack) --- +pipewire +wireplumber +alsa-pipewire +pavucontrol +alsa-utils +playerctl + +# --- graphics / wayland --- +wayland +wayland-protocols +xorg-server-xwayland +mesa-dri +mesa-vulkan-intel +intel-video-accel +vulkan-loader +libxkbcommon + +# --- nvidia (PRIME offload) --- +nvidia +nvidia-libs-32bit +nvidia-vaapi-driver + +# --- niri compositor + wayland ecosystem --- +niri +fuzzel +mako +swaybg +swayidle +swaylock +grim +slurp +wl-clipboard +xdg-desktop-portal +xdg-desktop-portal-gtk +xdg-desktop-portal-wlr +polkit-gnome +brightnessctl + +# --- noctalia shell runtime deps (noctalia-shell itself is installed in +# niri.sh from the third-party XBPS repo at universalrepo.r1xelelo.workers.dev). +ImageMagick +python3 +ddcutil +power-profiles-daemon +upower +cliphist +wlsunset +evolution-data-server + +# --- file manager (no nemo) --- +Thunar +thunar-volman +thunar-archive-plugin +gvfs +gvfs-mtp +gvfs-smb +file-roller +gnome-keyring +seahorse +network-manager-applet +blueman +bluez + +# --- display manager --- +# niri can be launched directly via TTY (`niri-session`) or via a wayland-aware +# greeter. We use greetd + tuigreet — lighter than lightdm under wayland. +greetd +tuigreet + +# --- fonts --- +noto-fonts-ttf +noto-fonts-emoji +noto-fonts-cjk +liberation-fonts-ttf +dejavu-fonts-ttf +font-awesome6 + +# --- containers --- +docker +docker-compose + +# --- terminal --- +alacritty + +# --- gtk theming deps --- +sassc +gnome-themes-extra +gtk-engine-murrine +dconf + +# --- media / utilities --- +vlc +obs + +# --- nix package manager --- +nix + +# --- zram / swap --- +zramen + +# --- power / laptop --- +tlp +tlp-rdw +acpi +acpid +upower + +# --- printing --- +cups +cups-filters +cups-pk-helper +ghostscript +foomatic-db +gutenprint +hplip +system-config-printer +sane +simple-scan + +# --- bluetooth audio --- +bluez-alsa + +# --- backups / snapshots --- +timeshift +grub-btrfs +inotify-tools + +# --- trackpad gestures --- +libinput-gestures +xdotool +python3-setproctitle + +# NOTE: waybar is intentionally absent — niri ships with its own panel +# helpers; if you want a bar add `waybar` once it lands in void-packages. diff --git a/config/profiles/mainline-niri/profile.conf b/config/profiles/mainline-niri/profile.conf new file mode 100644 index 0000000..84f63ae --- /dev/null +++ b/config/profiles/mainline-niri/profile.conf @@ -0,0 +1,31 @@ +# Mainline-niri profile. +# Linux mainline kernel + niri Wayland tiling compositor. +PROFILE_NAME="mainline-niri" +PROFILE_DESC="Linux mainline kernel + niri Wayland (scrolling tiler)" + +# Mainline kernel for best Wayland / GPU support. +KERNEL_PKG="linux-mainline" + +# Display server / DE. +DISPLAY_SERVER="wayland" +DESKTOP="niri" + +# niri has no dconf, no Cinnamon — most cinnamon helpers are skipped via +# this DESKTOP gate inside customizations.sh. + +# Package list. +PROFILE_PACKAGES_FILE="config/profiles/mainline-niri/packages.list" + +# Theme settings still apply (gtk3/gtk4 apps under wayland still read these). +GTK_THEME="Gruvbox-Dark" +ICON_THEME="Gruvbox-Plus-Dark" +DEFAULT_TERMINAL="alacritty" +CURSOR_THEME="Bibata-Modern-Ice" + +# Wayland shell — noctalia (installed via third-party XBPS repo by niri.sh). +WAYLAND_SHELL="noctalia" + +# Wayland env defaults (exported into /etc/environment by profile customisation). +QT_QPA_PLATFORM="wayland;xcb" +GDK_BACKEND="wayland,x11" +MOZ_ENABLE_WAYLAND="1" diff --git a/config/profiles/stable-cinnamon/packages.list b/config/profiles/stable-cinnamon/packages.list new file mode 100644 index 0000000..06aa917 --- /dev/null +++ b/config/profiles/stable-cinnamon/packages.list @@ -0,0 +1,174 @@ +# Packages installed into the target system via xbps-install. +# Lines beginning with '#' or empty are skipped. + +# --- base / boot --- +base-system +linux +linux-firmware +linux-firmware-network +intel-ucode +grub-x86_64-efi +efibootmgr +os-prober +dracut +gptfdisk +parted +btrfs-progs +dosfstools + +# --- core userspace --- +sudo +bash +bash-completion +git +curl +wget +vim +nano +htop +tmux +unzip +zip +xz +rsync +pciutils +usbutils +lsof +strace +file +which +man-pages +mdocml +ca-certificates +xtools + +# --- networking --- +NetworkManager +NetworkManager-openvpn +openssh +iwd +wpa_supplicant +nftables +chrony + +# --- audio --- +pipewire +wireplumber +alsa-pipewire +pavucontrol +alsa-utils + +# --- graphics / xorg --- +xorg-minimal +xorg-fonts +xorg-input-drivers +xf86-input-libinput +xf86-video-intel +mesa-dri +mesa-vulkan-intel +intel-video-accel +vulkan-loader + +# --- nvidia (PRIME offload) --- +nvidia +nvidia-libs-32bit +nvidia-vaapi-driver + +# --- desktop --- +cinnamon +xdg-user-dirs +xdg-utils +xdg-desktop-portal +xdg-desktop-portal-gtk +gvfs +gvfs-mtp +gvfs-smb +file-roller +gnome-keyring +seahorse +network-manager-applet +blueman +bluez + +# --- display manager --- +lightdm +lightdm-gtk3-greeter + +# --- fonts --- +noto-fonts-ttf +noto-fonts-emoji +noto-fonts-cjk +liberation-fonts-ttf +dejavu-fonts-ttf +font-awesome6 + +# --- containers --- +docker +docker-compose + +# --- editor --- +# Real Microsoft VS Code is installed from the official tarball in postinstall +# (see install_vscode_real); the Void `vscode` package is actually code-oss. + +# --- terminal --- +alacritty + +# --- gtk theming deps (for gruvbox theme) --- +sassc +gnome-themes-extra +gtk-engine-murrine +dconf +dconf-editor + +# --- media / utilities (xbps) --- +vlc +obs +flameshot +# firefox — replaced by google-chrome (installed via nix) + +# --- nix package manager (for spotify/discord/localsend) --- +nix + +# --- zram / swap --- +zramen + +# --- power / laptop --- +tlp +tlp-rdw +acpi +acpid +upower +brightnessctl + +# --- printing (CUPS + drivers + Cinnamon settings panel uses system-config-printer) --- +cups +cups-filters +cups-pk-helper +ghostscript +foomatic-db +gutenprint +hplip +system-config-printer +sane +simple-scan + +# --- bluetooth (blueman+bluez already above, ensure bluez-alsa/obex tools) --- +bluez-alsa + +# --- backups / snapshots --- +timeshift +grub-btrfs +inotify-tools + +# --- trackpad gestures (libinput-gestures + GUI) --- +libinput-gestures +wmctrl +xdotool +python3-setproctitle + +# --- screenshots (flameshot already above, also xclip for clipboard) --- +xclip + +# --- system / package upgrade GUI helpers (Octoxbps available via xtools) --- +# nothing extra needed; we ship a small custom xbps-upgrade applet + diff --git a/config/profiles/stable-cinnamon/profile.conf b/config/profiles/stable-cinnamon/profile.conf new file mode 100644 index 0000000..aded286 --- /dev/null +++ b/config/profiles/stable-cinnamon/profile.conf @@ -0,0 +1,20 @@ +# Stable Cinnamon profile (default). +# This is the ORIGINAL setup: stable Void kernel + Cinnamon DE + X11. +PROFILE_NAME="stable-cinnamon" +PROFILE_DESC="Stable Void kernel + Cinnamon (X11) — current production profile" + +# Kernel — use Void's stable LTS. +KERNEL_PKG="linux" + +# Display server / DE. +DISPLAY_SERVER="x11" +DESKTOP="cinnamon" + +# Package list (relative to repo root). +PROFILE_PACKAGES_FILE="config/profiles/stable-cinnamon/packages.list" + +# Default GTK theme + icons (overrides install.conf if set there). +GTK_THEME="Gruvbox-Dark" +ICON_THEME="Gruvbox-Plus-Dark" +DEFAULT_TERMINAL="alacritty" +CURSOR_THEME="Bibata-Modern-Ice" diff --git a/docs/TESTING-ON-REAL-HARDWARE.md b/docs/TESTING-ON-REAL-HARDWARE.md new file mode 100644 index 0000000..b913d13 --- /dev/null +++ b/docs/TESTING-ON-REAL-HARDWARE.md @@ -0,0 +1,190 @@ +# Testing the Void Installer ISO on Real Hardware + +End-to-end guide to take the freshly built `out/void-install.iso`, write it to a +USB stick, and run the installer on a real machine (e.g. the XPS 17 9700). + +> ⚠️ **DESTRUCTIVE.** The installer will reformat the partition you select. +> Make a Timeshift / dd backup of any disk you care about before booting from +> the stick. The installer **never** touches the EFI System Partition — your +> existing Windows bootloader stays put. + +--- + +## 1 · Prerequisites + +| Item | Detail | +| --- | --- | +| USB stick | ≥ 2 GiB, **all data on it will be lost** | +| Target machine | UEFI firmware, x86_64, ≥ 4 GiB RAM, ≥ 30 GiB free on the target partition | +| Host (this laptop) | Linux with `lsblk`, `dd`, `sync`, root access | +| Built ISO | `out/void-install.iso` (run `make iso` first if missing) | +| Network | Ethernet **or** known-good Wi-Fi credentials (NetworkManager nmtui works in the live env) | + +--- + +## 2 · Build the production ISO + +```bash +cd ~/Sources/void-installer +make iso # builds out/void-install.iso (~1.3 GiB) +sha256sum out/void-install.iso +``` + +The build runs entirely inside Docker, so the host stays clean. +Output: `out/void-install.iso`. Keep the sha256 — you'll verify it after `dd`. + +--- + +## 3 · Identify the USB device + +Plug the USB stick in. Then: + +```bash +lsblk -o NAME,SIZE,MODEL,TRAN,MOUNTPOINTS +``` + +Find the line with `usb` in the `TRAN` column. Typical names: `/dev/sdb`, +`/dev/sdc`. **Never** pick `/dev/sda` (your system disk) or anything starting +with `nvme0n1` (your NVMe). Confirm with the size (matches your stick). + +Set a shell variable so you can't typo it later: + +```bash +USB=/dev/sdX # ← replace X with the actual letter you saw above +echo "WILL ERASE: $USB" +lsblk "$USB" # double-check +``` + +If the stick has any mounted partitions, unmount them all: + +```bash +for p in $(lsblk -nro NAME "$USB" | tail -n +2); do + sudo umount "/dev/$p" 2>/dev/null || true +done +``` + +--- + +## 4 · Flash the ISO + +The Void ISO is a **hybrid ISO** (xorriso-isohybrid), so plain `dd` works for +both UEFI and legacy BIOS. **No partitioning, no formatting, no GUI tool +needed.** + +```bash +sudo dd if=out/void-install.iso of="$USB" bs=4M status=progress conv=fsync oflag=direct +sync +``` + +When it finishes (~1-3 min on USB 3.x), verify the write actually landed: + +```bash +sudo dd if="$USB" bs=4M count=$(( $(stat -c%s out/void-install.iso) / 4 / 1024 / 1024 )) \ + status=none | sha256sum +sha256sum out/void-install.iso +``` + +The two hashes should match (truncated read length matters; if they differ try +adding `iflag=fullblock`). + +> Alternative GUI tools that also work: GNOME Disks → "Restore Disk Image…", +> balenaEtcher, Ventoy. Avoid Rufus' "ISO mode" → it rebuilds the partition +> table and can break UEFI boot. + +Eject the stick cleanly: + +```bash +sudo eject "$USB" +``` + +--- + +## 5 · Boot the target machine from USB + +1. Plug the stick into the **target laptop** (not the host). +2. Power on while spamming the firmware boot-menu key: + - Dell XPS 17: **F12** + - ThinkPad: **F12** + - HP: **F9** + - Most others: **F8**, **F10**, **F11**, **Esc** +3. From the one-shot boot menu, select the entry that contains your USB stick's + model name **and** the prefix `UEFI:` (NOT the legacy/MBR entry). +4. The Void Linux GRUB menu appears — keep the default "Void Linux installer" + entry (or wait for the timeout). +5. After ~10-30 s you land on a console as `root` and the installer banner + appears. + +> **Secure Boot:** The ISO is **not** signed for Secure Boot. Disable Secure +> Boot in the firmware setup before the first boot, or shim signing will fail. +> You can re-enable it after the install completes (Void's GRUB will not +> validate, so leave it off unless you sign the kernel yourself). + +--- + +## 6 · Run the installer + +When you see the banner, follow the on-screen prompts. The installer is +**unattended-by-default** for the developer profile and will: + +1. Bring up networking (DHCP on Ethernet, or `nmtui` for Wi-Fi if no link). +2. Show the disk picker — select your **target btrfs partition** (e.g. + `/dev/nvme0n1p5`). The picker refuses to format the EFI partition or + anything labelled NTFS / Windows. +3. Confirm the summary by typing `YES` (case-sensitive). Last chance to abort. +4. Wipe the chosen partition, mkfs.btrfs, mount subvolumes. +5. Bootstrap base-system + ~140 packages from the public Void mirror. +6. Configure users, sudo, services, GRUB chained next to the existing Windows + loader. +7. Reboot. Remove the USB stick **before** GRUB starts. + +Total install time on a Gen-12 laptop with NVMe + 1 Gbps internet: **8-15 min**. + +--- + +## 7 · First-boot checks + +After the system reboots, log in as **moze** (password from your `secrets.env`). + +| Check | Command | Expected | +| --- | --- | --- | +| Cinnamon session | (lightdm shows it on login) | green Cinnamon wallpaper | +| Network | `nmcli c` | active connection | +| Audio | open `pavucontrol` | sink listed, no errors | +| Bluetooth | tray icon → "Devices…" | scan starts | +| Printer | Settings → Printers | CUPS server reachable | +| Trackpad gestures | three-finger swipe up | virtual-desktop overview | +| Screenshot | press **PrintScreen** | flameshot UI appears | +| Snapshots | `sudo timeshift --list` | (empty list, no errors) | +| Pre-upgrade snapshot | `sudo xbps-install -Sun \| tail` | wrapper prints `[snapshot]` line | +| GPU PRIME offload | `nvidia-smi` | NVIDIA GPU detected | +| Docker | `docker run --rm hello-world` | "Hello from Docker!" | +| VS Code | `code --version` | version printed | + +--- + +## 8 · Recovery (if something goes wrong) + +The installer **never overwrites your EFI partition** and **never touches +partitions other than the one you selected**. To roll back: + +- **Boot Windows again**: in firmware setup, move the Windows boot entry above + Void in the EFI boot order. Or hit the one-shot boot menu and pick Windows. +- **Wipe the Void partition**: boot back into the live USB, mount your old + filesystem (e.g. `mount /dev/nvme0n1p5 /mnt`), and either restore from your + backup or `mkfs. /dev/nvme0n1p5`. +- **Restore the GRUB menu only**: in the live USB, + `mount /dev/ /mnt/efi && grub-install --efi-directory=/mnt/efi --bootloader-id=Windows --removable` (after chrooting into your old install) or use Microsoft's "Startup Repair" from a Windows install media. +- **Restore a Timeshift snapshot** (post-install): boot into the live USB, + `xbps-install -Sy timeshift`, then `timeshift --restore --snapshot `. + +--- + +## 9 · Reporting bugs + +If the installer aborts: + +1. The serial / TUI shows the failing line and a log path. +2. Copy `/tmp/installer.log` and `/var/log/void-installer.log` off the live + USB (e.g. via `scp` over Ethernet) and attach them to the bug report. +3. Note: which step failed, exact partition layout (`lsblk -f`), firmware mode + (UEFI vs BIOS), and whether Secure Boot was on. diff --git a/installer/first-login.sh b/installer/first-login.sh new file mode 100644 index 0000000..e31e50f --- /dev/null +++ b/installer/first-login.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# First-login one-shot setup for the user. +# Installs Claude Code (official) + NVM + node LTS + vscode extensions. +# Idempotent: creates ~/.first-login-done marker on success. + +# NOTE: do NOT use `set -u` here — nvm.sh references unbound vars. +LOG="$HOME/.first-login.log" +exec > >(tee -a "$LOG") 2>&1 + +echo "==> [$(date)] first-login setup starting" + +# Need network. Wait up to 60s for default route + DNS. +for i in $(seq 1 30); do + getent hosts github.com >/dev/null 2>&1 && break + sleep 2 +done +if ! getent hosts github.com >/dev/null 2>&1; then + echo "!! no network; aborting first-login setup (will retry next login)" + exit 0 +fi + +# --- Claude Code (official native installer) — runs FIRST so failures in +# downstream NVM/node/etc. don't block claude installation. --- +mkdir -p "$HOME/.local/bin" +export PATH="$HOME/.local/bin:$PATH" +if ! command -v claude >/dev/null 2>&1 && [[ ! -x "$HOME/.local/bin/claude" ]]; then + echo "==> installing Claude Code via official installer" + curl -fsSL https://claude.ai/install.sh | bash || { + echo "!! claude install failed"; } +fi + +# --- NVM (best effort; nvm.sh has unbound vars so isolate it) --- +if [[ ! -s "$HOME/.nvm/nvm.sh" ]]; then + echo "==> installing NVM" + export NVM_DIR="$HOME/.nvm" + curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash || \ + echo "!! NVM install failed (continuing)" +fi + +if [[ -s "$HOME/.nvm/nvm.sh" ]]; then + export NVM_DIR="$HOME/.nvm" + # nvm.sh trips `set -u` on STABLE/PROVIDED_VERSION; isolate in subshell. + ( + set +u + # shellcheck disable=SC1091 + . "$NVM_DIR/nvm.sh" + if ! nvm ls --no-colors 2>/dev/null | grep -qE 'lts/'; then + echo "==> installing node LTS" + nvm install --lts || echo "!! node install failed" + fi + nvm use --lts >/dev/null 2>&1 || true + ) || true + + # Symlink the resulting node/npm into ~/.local/bin so they're on PATH + # for non-nvm shells. + NODE_BIN_DIR="$(ls -d "$HOME"/.nvm/versions/node/v*/bin 2>/dev/null | sort -V | tail -1)" + if [[ -n "$NODE_BIN_DIR" && -d "$NODE_BIN_DIR" ]]; then + for bin in node npm npx; do + [[ -x "$NODE_BIN_DIR/$bin" ]] && ln -sf "$NODE_BIN_DIR/$bin" "$HOME/.local/bin/$bin" + done + fi +fi + +# --- VS Code extensions --- +EXT_FILE=/etc/installer-vscode-extensions.txt +if [[ -r "$EXT_FILE" ]] && command -v code >/dev/null 2>&1; then + echo "==> installing VS Code extensions" + while read -r ext; do + [[ -z "$ext" || "$ext" =~ ^# ]] && continue + echo " -> $ext" + code --install-extension "$ext" --force >/dev/null 2>&1 || \ + echo " (failed: $ext)" + done < "$EXT_FILE" +fi + +touch "$HOME/.first-login-done" +echo "==> [$(date)] first-login setup done" diff --git a/installer/install.sh b/installer/install.sh new file mode 100755 index 0000000..00d6260 --- /dev/null +++ b/installer/install.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# Void Linux unattended-friendly installer for the XPS 17 (xps9700). +# Runs inside the Void live ISO. Reads /etc/installer-secrets.env for passwords +# and /usr/local/share/installer/install.conf for everything else. + +set -Eeuo pipefail + +# Resolve the real script path so this works whether invoked directly or via +# the /usr/local/sbin/install-void symlink. +INSTALLER_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +SHARE_DIR="${INSTALLER_SHARE_DIR:-/usr/local/share/installer}" + +# shellcheck source=lib/common.sh +source "$INSTALLER_DIR/lib/common.sh" + +# ---------- load config ---------- +CONFIG_FILE="${CONFIG_FILE:-$SHARE_DIR/install.conf}" +[[ -r "$CONFIG_FILE" ]] || die "config file $CONFIG_FILE missing" +# shellcheck disable=SC1090 +source "$CONFIG_FILE" +log "loaded config from $CONFIG_FILE" + +# Drop-in overrides (used by the QEMU test harness). +if [[ -d "${CONFIG_FILE}.d" ]]; then + for f in "${CONFIG_FILE}.d"/*.conf; do + [[ -r "$f" ]] || continue + # shellcheck disable=SC1090 + source "$f" + log "loaded override $f" + done +fi + +PKG_LIST_FILE="${PKG_LIST_FILE:-$SHARE_DIR/packages.list}" +export PKG_LIST_FILE +[[ -r "$PKG_LIST_FILE" ]] || die "packages.list $PKG_LIST_FILE missing" + +# ---------- profile ---------- +PROJECT_DIR="${PROJECT_DIR:-$SHARE_DIR}" +PROFILES_DIR="${PROFILES_DIR:-$SHARE_DIR/profiles}" +export PROJECT_DIR PROFILES_DIR +# shellcheck source=lib/profiles.sh +source "$INSTALLER_DIR/lib/profiles.sh" +load_profile || die "could not load profile '${PROFILE:-stable-cinnamon}'" +# Profile may override the package list. +[[ -r "$PROFILE_PACKAGES_FILE" ]] && PKG_LIST_FILE="$PROFILE_PACKAGES_FILE" +log "using packages list: $PKG_LIST_FILE" + +load_secrets + +# ---------- pre-flight ---------- +require_root + +# Default DEFAULT_ROOT_PART/EFI from config; will be confirmed/overridden by TUI. +ROOT_PART="${ROOT_PART:-$DEFAULT_ROOT_PART}" +EFI_PART="${EFI_PART:-$DEFAULT_EFI_PART}" +TARGET="${TARGET:-/mnt}" +export TARGET + +# ---------- modules ---------- +# shellcheck source=lib/tui.sh +source "$INSTALLER_DIR/lib/tui.sh" +# shellcheck source=lib/partition.sh +source "$INSTALLER_DIR/lib/partition.sh" +# shellcheck source=lib/bootstrap.sh +source "$INSTALLER_DIR/lib/bootstrap.sh" +# shellcheck source=lib/grub.sh +source "$INSTALLER_DIR/lib/grub.sh" +# shellcheck source=lib/postinstall.sh +source "$INSTALLER_DIR/lib/postinstall.sh" +# shellcheck source=lib/customizations.sh +source "$INSTALLER_DIR/lib/customizations.sh" + +# ---------- run ---------- +banner() { + cat <<'EOF' + + ╔══════════════════════════════════════════════════════════════╗ + ║ Void Linux Installer (xps9700) ║ + ║ target: btrfs on a single partition, dual-boot Windows ║ + ║ desktop: cinnamon | docker | vscode | nvidia PRIME ║ + ╚══════════════════════════════════════════════════════════════╝ +EOF +} + +main() { + banner + log "starting installer (UNATTENDED=${UNATTENDED:-0}, TEST_MODE=${TEST_MODE:-0})" + + tui_select_install_target + setup_filesystems + bootstrap_base + generate_fstab + mount_pseudo_fs + + configure_system + configure_users + configure_ssh_config + configure_nvidia_prime + configure_zram + configure_nix + install_vscode_real + install_customizations + enable_services + install_grub + reconfigure_all + + unmount_target + + ok "Installation complete." + log "Log file: $LOG_FILE" + if [[ "${TEST_MODE:-0}" != "1" && "${UNATTENDED:-0}" != "1" ]]; then + if confirm "Reboot now?"; then + systemctl reboot 2>/dev/null || reboot + fi + fi +} + +main "$@" diff --git a/installer/lib/bootstrap.sh b/installer/lib/bootstrap.sh new file mode 100755 index 0000000..294e792 --- /dev/null +++ b/installer/lib/bootstrap.sh @@ -0,0 +1,123 @@ +#!/bin/bash +# Bootstrap base system into $TARGET via xbps-install. + +# shellcheck source=common.sh +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +bootstrap_base() { + step "Bootstrapping base system into $TARGET (this takes a while)" + + local TARGET="${TARGET:-/mnt}" + local pkg_list=() + mapfile -t pkg_list < <(grep -vE '^\s*(#|$)' "${PKG_LIST_FILE:-/usr/local/share/installer/packages.list}") + + mkdir -p "$TARGET/var/db/xbps/keys" + cp -a /var/db/xbps/keys/* "$TARGET/var/db/xbps/keys/" 2>/dev/null || \ + warn "could not copy xbps keys (running outside Void live env?)" + + # Use the caching proxy during install if available; fall back to real repo. + # The proxy URL (if set) is only used during installation — the installed + # system's xbps.d always gets the real REPO_URL. + local _repo="${INSTALL_REPO_URL:-$REPO_URL}" + local _nonfree="${_repo%/current}/current/nonfree" + local _multilib="${_repo%/current}/current/multilib" + local _multilib_nonfree="${_repo%/current}/current/multilib/nonfree" + # Normalise: if _repo doesn't end in /current the above substitution + # leaves it unchanged; append nonfree/multilib paths directly. + [[ "$_repo" == */current ]] || { + _nonfree="${_repo}/nonfree" + _multilib="${_repo}/multilib" + _multilib_nonfree="${_repo}/multilib/nonfree" + } + log "install repo: $_repo (proxy=${INSTALL_REPO_URL:-none})" + + # Bootstrap base-system via proxy/repo. + XBPS_ARCH="$ARCH" xbps-install -y -S -r "$TARGET" -R "$_repo" base-system + ok "base-system installed" + + # Enable extra repos in target xbps.d — always write the REAL URLs so the + # installed system never depends on the proxy. + mkdir -p "$TARGET/etc/xbps.d" + cat > "$TARGET/etc/xbps.d/00-repository-main.conf" < "$TARGET/etc/xbps.d/00-repository-main.conf" </dev/null 2>&1; then + xgenfstab -U "$TARGET" > "$TARGET/etc/fstab" + else + # fallback minimal generator (uses BTRFS_SUBVOLS array from install.conf) + : > "$TARGET/etc/fstab" + local uuid entry sv mp pass + uuid=$(blkid -s UUID -o value "$ROOT_PART") + for entry in "${BTRFS_SUBVOLS[@]}"; do + sv="${entry%%:*}" + mp="${entry##*:}" + # root gets fsck pass 1, others 2 + pass=2; [[ "$mp" == "/" ]] && pass=1 + printf 'UUID=%s %-12s btrfs %s,subvol=%s 0 %d\n' \ + "$uuid" "$mp" "$BTRFS_MOUNT_OPTS" "$sv" "$pass" >> "$TARGET/etc/fstab" + done + local efi_uuid + efi_uuid=$(blkid -s UUID -o value "$EFI_PART") + printf 'UUID=%s /boot/efi vfat defaults,noatime,umask=0077 0 2\n' "$efi_uuid" >> "$TARGET/etc/fstab" + printf 'tmpfs /tmp tmpfs defaults,nosuid,nodev 0 0\n' >> "$TARGET/etc/fstab" + fi + ok "fstab generated" +} + +mount_pseudo_fs() { + local TARGET="${TARGET:-/mnt}" + step "Mounting pseudo-filesystems for chroot" + for d in dev proc sys run; do + mkdir -p "$TARGET/$d" + mount --rbind "/$d" "$TARGET/$d" + mount --make-rslave "$TARGET/$d" + done + # /dev/shm must be a real tmpfs for python multiprocessing (sem_open) + # used by xbps-reconfigure → compileall. Without it the byte-compile + # step fails with FileNotFoundError on every python package. + mkdir -p "$TARGET/dev/shm" + mountpoint -q "$TARGET/dev/shm" || mount -t tmpfs -o nosuid,nodev tmpfs "$TARGET/dev/shm" 2>/dev/null || true + if is_uefi; then + mkdir -p "$TARGET/sys/firmware/efi/efivars" + mount -t efivarfs efivarfs "$TARGET/sys/firmware/efi/efivars" 2>/dev/null || true + fi + # Make DNS work inside the chroot (needed for VS Code download, etc.). + if [[ -r /etc/resolv.conf ]]; then + install -Dm644 /etc/resolv.conf "$TARGET/etc/resolv.conf" 2>/dev/null \ + || cp /etc/resolv.conf "$TARGET/etc/resolv.conf" 2>/dev/null || true + fi +} + +unmount_target() { + local TARGET="${TARGET:-/mnt}" + step "Unmounting $TARGET" + sync + umount -R "$TARGET" 2>/dev/null || warn "lazy umount fallback" + umount -lR "$TARGET" 2>/dev/null || true +} diff --git a/installer/lib/common.sh b/installer/lib/common.sh new file mode 100755 index 0000000..dad25a8 --- /dev/null +++ b/installer/lib/common.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# Common helpers: logging, error handling, run-as-root checks. +# Sourced by all installer modules. + +set -Eeuo pipefail + +# --- colors --- +if [[ -t 1 ]] && command -v tput >/dev/null 2>&1; then + C_RED="$(tput setaf 1)"; C_GREEN="$(tput setaf 2)" + C_YEL="$(tput setaf 3)"; C_BLUE="$(tput setaf 4)" + C_BOLD="$(tput bold)"; C_RESET="$(tput sgr0)" +else + C_RED=""; C_GREEN=""; C_YEL=""; C_BLUE=""; C_BOLD=""; C_RESET="" +fi + +LOG_FILE="${LOG_FILE:-/var/log/void-installer.log}" +mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true +: > "$LOG_FILE" 2>/dev/null || LOG_FILE="/tmp/void-installer.log" + +log() { printf '%s [%s] %s\n' "$(date +%H:%M:%S)" "INFO" "$*" | tee -a "$LOG_FILE" >&2; } +warn() { printf '%s%s [%s] %s%s\n' "$C_YEL" "$(date +%H:%M:%S)" "WARN" "$*" "$C_RESET" | tee -a "$LOG_FILE" >&2; } +err() { printf '%s%s [%s] %s%s\n' "$C_RED" "$(date +%H:%M:%S)" "ERR " "$*" "$C_RESET" | tee -a "$LOG_FILE" >&2; } +ok() { printf '%s%s [%s] %s%s\n' "$C_GREEN" "$(date +%H:%M:%S)" " OK " "$*" "$C_RESET" | tee -a "$LOG_FILE" >&2; } +step() { printf '\n%s%s==> %s%s\n' "$C_BOLD" "$C_BLUE" "$*" "$C_RESET" | tee -a "$LOG_FILE" >&2; } + +die() { err "$*"; exit 1; } + +trap 'err "Installer aborted at line $LINENO (exit=$?). Log: $LOG_FILE"' ERR + +require_root() { + [[ "${EUID:-$(id -u)}" -eq 0 ]] || die "must run as root" +} + +confirm() { + # confirm "Question" -> returns 0 on yes + local prompt="${1:-Proceed?}" + if [[ "${UNATTENDED:-0}" == "1" ]]; then + log "[unattended] auto-yes: $prompt" + return 0 + fi + local ans + read -r -p "$prompt [y/N] " ans + [[ "$ans" =~ ^[Yy]$ ]] +} + +run_chroot() { + # run a command inside the target chroot + local target="${TARGET:-/mnt}" + chroot "$target" /usr/bin/env -i \ + HOME=/root TERM="${TERM:-linux}" \ + PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + LANG="${LANG:-en_US.UTF-8}" \ + /bin/bash -c "$*" +} + +is_uefi() { + [[ -d /sys/firmware/efi/efivars ]] +} + +# Set a password inside the chroot without exposing it to the shell argv +# or to ${} expansion in command strings (bash injection-safe). +# Uses openssl to pre-hash, then usermod -p, because chpasswd on Void can +# silently no-op for freshly-created (locked) accounts depending on the +# default crypt method in /etc/login.defs. +set_chroot_password() { + local user="$1" password="$2" + local target="${TARGET:-/mnt}" + # Generate a SHA-512 crypt hash on the host (openssl is in the live ISO). + local hash + hash="$(openssl passwd -6 "$password")" || { + warn "openssl passwd failed for $user; falling back to chpasswd" + chroot "$target" /usr/bin/env -i \ + HOME=/root TERM="${TERM:-linux}" \ + PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + chpasswd </dev/null 2>&1; then + run_profile_customizations + fi + + ok "customizations installed" +} + +_deploy_themes() { + local TARGET="$1" + local src="$OVERLAY_SRC/themes" + [[ -d "$src" ]] || { log "no themes overlay; skipping"; return 0; } + + install -d -m 0755 "$TARGET/usr/share/themes" + # Each subdir of overlay/themes is a complete theme dir; just rsync them. + cp -a "$src"/. "$TARGET/usr/share/themes/" 2>/dev/null || true + log "themes deployed -> /usr/share/themes/" +} + +_deploy_icons() { + local TARGET="$1" + local src="$OVERLAY_SRC/icons" + [[ -d "$src" ]] || { log "no icons overlay; skipping"; return 0; } + + install -d -m 0755 "$TARGET/usr/share/icons" + cp -a "$src"/. "$TARGET/usr/share/icons/" 2>/dev/null || true + # Refresh icon cache (best-effort). + for d in "$TARGET"/usr/share/icons/*/; do + [[ -d "$d" ]] || continue + run_chroot "gtk-update-icon-cache -f -t /usr/share/icons/$(basename "$d") 2>/dev/null || true" + done + log "icons deployed -> /usr/share/icons/" +} + +_deploy_wallpapers() { + local TARGET="$1" + local src="$OVERLAY_SRC/wallpapers" + [[ -d "$src" ]] || { log "no wallpapers overlay; skipping"; return 0; } + + local dst="$TARGET/usr/share/backgrounds/void-installer" + install -d -m 0755 "$dst" + cp -a "$src"/. "$dst/" + chmod 0644 "$dst"/* 2>/dev/null || true + log "wallpapers -> /usr/share/backgrounds/void-installer/" +} + +_deploy_user_dotfiles() { + local TARGET="$1" + local src="$OVERLAY_SRC/skel" + [[ -d "$src" ]] || { log "no skel overlay; skipping dotfiles"; return 0; } + + local home="$TARGET/home/$USERNAME" + install -d -m 0755 "$home" + + # Copy dotfiles, preserving structure. Don't clobber .ssh (already set). + ( + cd "$src" && find . -mindepth 1 -maxdepth 1 \ + ! -name '.ssh' -print0 + ) | while IFS= read -r -d '' rel; do + cp -a "$src/$rel" "$home/" 2>/dev/null || true + done + + run_chroot "chown -R $USERNAME:$USERNAME /home/$USERNAME" + log "dotfiles deployed -> /home/$USERNAME/" +} + +_deploy_vscode_config() { + local TARGET="$1" + local src="$OVERLAY_SRC/vscode-user" + [[ -d "$src" ]] || { log "no vscode-user overlay; skipping"; return 0; } + + local dst="$TARGET/home/$USERNAME/.config/Code/User" + install -d -m 0755 "$dst" + cp -a "$src"/. "$dst/" + run_chroot "chown -R $USERNAME:$USERNAME /home/$USERNAME/.config" + log "vscode user config -> ~/.config/Code/User/" +} + +_deploy_first_login() { + local TARGET="$1" + local src="$OVERLAY_SRC/first-login.sh" + local ext_list="$OVERLAY_SRC/vscode-extensions.txt" + + install -d -m 0755 "$TARGET/usr/local/libexec" + [[ -r "$src" ]] && { + install -m 0755 "$src" "$TARGET/usr/local/libexec/first-login.sh" + log "first-login script staged" + } + [[ -r "$ext_list" ]] && { + install -m 0644 "$ext_list" "$TARGET/etc/installer-vscode-extensions.txt" + } + + # Inject one-shot trigger into ~/.bash_profile (appended; idempotent guard + # in the script itself via /var/lib/first-login.done). + local home="$TARGET/home/$USERNAME" + install -d -m 0755 "$home" + if [[ -x "$TARGET/usr/local/libexec/first-login.sh" ]]; then + if ! grep -q "first-login.sh" "$home/.bash_profile" 2>/dev/null; then + cat >> "$home/.bash_profile" <<'EOF' + +# Auto-run user environment setup on first interactive login. +if [[ -z "$_FIRST_LOGIN_RAN" && -x /usr/local/libexec/first-login.sh \ + && ! -f "$HOME/.first-login-done" ]]; then + export _FIRST_LOGIN_RAN=1 + /usr/local/libexec/first-login.sh 2>&1 | tee -a "$HOME/.first-login.log" +fi +EOF + fi + run_chroot "chown $USERNAME:$USERNAME /home/$USERNAME/.bash_profile" + fi + + # Also autostart it from the desktop session so GUI-only users get it. + if [[ -x "$TARGET/usr/local/libexec/first-login.sh" ]]; then + local autostart="$TARGET/etc/xdg/autostart" + install -d -m 0755 "$autostart" + cat > "$autostart/void-installer-first-login.desktop" <<'EOF' +[Desktop Entry] +Type=Application +Name=Void Installer First-Login Setup +Exec=/usr/local/libexec/first-login.sh +NoDisplay=true +X-GNOME-Autostart-enabled=true +OnlyShowIn=X-Cinnamon;GNOME;XFCE;KDE; +EOF + fi + + # Ensure ~/.local/bin is on PATH for every shell (login + non-login). + install -d -m 0755 "$TARGET/etc/profile.d" + cat > "$TARGET/etc/profile.d/local-bin.sh" <<'EOF' +# Prepend the per-user bin dir so first-login symlinks (claude, node, npm) +# are visible to every interactive shell. +case ":$PATH:" in + *":$HOME/.local/bin:"*) ;; + *) export PATH="$HOME/.local/bin:$PATH" ;; +esac +EOF + + # Claude Code config (auth tokens) bundled from host overlay. + local claude_src="$OVERLAY_SRC/claude" + if [[ -d "$claude_src" ]]; then + local cdst="$TARGET/home/$USERNAME/.claude" + install -d -m 0700 "$cdst" + cp -a "$claude_src"/. "$cdst/" + log "claude config -> ~/.claude/" + fi + if [[ -r "$OVERLAY_SRC/claude.json" ]]; then + install -m 0600 "$OVERLAY_SRC/claude.json" "$TARGET/home/$USERNAME/.claude.json" + fi + run_chroot "chown -R $USERNAME:$USERNAME /home/$USERNAME/.claude /home/$USERNAME/.claude.json 2>/dev/null || true" +} + +_write_dconf_defaults() { + local TARGET="$1" + install -d -m 0755 "$TARGET/etc/dconf/db/local.d" \ + "$TARGET/etc/dconf/profile" + + cat > "$TARGET/etc/dconf/profile/user" <<'EOF' +user-db:user +system-db:local +EOF + + local wallpaper="${INITIAL_WALLPAPER:-pxfuel.jpg}" + cat > "$TARGET/etc/dconf/db/local.d/00-cinnamon" <t'] + +[org/cinnamon/desktop/keybindings/custom-keybindings/custom1] +name='Flameshot' +command='flameshot gui' +binding=['Print'] + +[org/cinnamon/desktop/keybindings/media-keys] +screenshot=[''] +area-screenshot=[''] +window-screenshot=[''] + +[org/cinnamon] +enabled-applets=['panel1:left:0:menu@cinnamon.org', 'panel1:left:1:show-desktop@cinnamon.org', 'panel1:left:2:grouped-window-list@cinnamon.org', 'panel1:right:0:systray@cinnamon.org', 'panel1:right:1:xapp-status@cinnamon.org', 'panel1:right:2:notifications@cinnamon.org', 'panel1:right:3:printers@cinnamon.org', 'panel1:right:4:removable-drives@cinnamon.org', 'panel1:right:5:keyboard@cinnamon.org', 'panel1:right:6:favorites@cinnamon.org', 'panel1:right:7:network@cinnamon.org', 'panel1:right:8:sound@cinnamon.org', 'panel1:right:9:power@cinnamon.org', 'panel1:right:10:calendar@cinnamon.org', 'panel1:right:11:user@cinnamon.org'] + +[org/cinnamon/desktop/default-applications/terminal] +exec='${DEFAULT_TERMINAL:-alacritty}' +exec-arg='-e' +EOF + + # Compile dconf db inside the chroot. + run_chroot "dconf update 2>/dev/null || true" + log "dconf cinnamon defaults written" +} + +_set_default_terminal() { + local TARGET="$1" + local term="${DEFAULT_TERMINAL:-alacritty}" + + # x-terminal-emulator-style alternative — Void doesn't ship update-alternatives + # for terminals, so just symlink in /usr/local/bin. + if [[ -x "$TARGET/usr/bin/$term" ]]; then + ln -sf "/usr/bin/$term" "$TARGET/usr/local/bin/x-terminal-emulator" + log "default terminal set to $term" + else + warn "$term not found in target; skipping default-terminal symlink" + fi + + # Persistent X keymap (works for non-cinnamon login too). + install -d -m 0755 "$TARGET/etc/X11/xorg.conf.d" + cat > "$TARGET/etc/X11/xorg.conf.d/00-keyboard.conf" < "$TARGET/etc/skel/.config/libinput-gestures.conf" <<'EOF' +# void-installer defaults +gesture swipe up 3 wmctrl -k on +gesture swipe down 3 wmctrl -k off +gesture swipe left 3 xdotool key super+Right +gesture swipe right 3 xdotool key super+Left +gesture swipe left 4 xdotool key ctrl+alt+Right +gesture swipe right 4 xdotool key ctrl+alt+Left +gesture swipe up 4 xdotool key super +gesture swipe down 4 xdotool key super+d +gesture pinch in xdotool key ctrl+minus +gesture pinch out xdotool key ctrl+plus +EOF + # Ensure user is in 'input' group (libinput-gestures needs /dev/input/event*). + run_chroot "groupadd -f input; usermod -aG input $USERNAME" || true + + # Autostart for cinnamon session. + install -d -m 0755 "$TARGET/etc/skel/.config/autostart" + cat > "$TARGET/etc/skel/.config/autostart/libinput-gestures.desktop" <<'EOF' +[Desktop Entry] +Type=Application +Name=Libinput Gestures +Exec=libinput-gestures-setup start +X-GNOME-Autostart-enabled=true +NoDisplay=false +EOF + + # Mirror into existing user's home (skel only applies to NEW users). + if id "$USERNAME" >/dev/null 2>&1 || [[ -d "$TARGET/home/$USERNAME" ]]; then + install -d -m 0755 "$TARGET/home/$USERNAME/.config/autostart" + cp -a "$TARGET/etc/skel/.config/libinput-gestures.conf" \ + "$TARGET/home/$USERNAME/.config/libinput-gestures.conf" 2>/dev/null || true + cp -a "$TARGET/etc/skel/.config/autostart/libinput-gestures.desktop" \ + "$TARGET/home/$USERNAME/.config/autostart/" 2>/dev/null || true + run_chroot "chown -R $USERNAME:$USERNAME /home/$USERNAME/.config" || true + fi + log "trackpad gestures configured (libinput-gestures)" +} + +_install_snapshot_hook() { + local TARGET="$1" + # Pre-upgrade btrfs snapshot via xbps-install hook. + install -d -m 0755 "$TARGET/etc/xbps.d" + install -d -m 0755 "$TARGET/usr/local/sbin" + + cat > "$TARGET/usr/local/sbin/xbps-pre-upgrade-snapshot.sh" <<'EOF' +#!/bin/sh +# Take a read-only btrfs snapshot of @ before an xbps upgrade/install. +# Snapshots live in /.snapshots/auto-YYYYmmdd-HHMMSS so timeshift can manage them. +set -e +ROOT_SUBVOL=/run/btrfs-root/@ +SNAP_DIR=/.snapshots +[ -d "$SNAP_DIR" ] || mkdir -p "$SNAP_DIR" +TS=$(date +%Y%m%d-%H%M%S) +NAME="auto-pre-xbps-$TS" +# Mount the toplevel of the btrfs FS once so we can snapshot @. +ROOT_DEV=$(findmnt -no SOURCE /) +mkdir -p /run/btrfs-root +if ! mountpoint -q /run/btrfs-root; then + mount -o subvolid=5 "$ROOT_DEV" /run/btrfs-root 2>/dev/null || exit 0 +fi +btrfs subvolume snapshot -r /run/btrfs-root/@ "/run/btrfs-root/.snapshots/$NAME" 2>/dev/null \ + || mkdir -p /run/btrfs-root/.snapshots && \ + btrfs subvolume snapshot -r /run/btrfs-root/@ "/run/btrfs-root/.snapshots/$NAME" +echo "[snapshot] created $NAME" +EOF + chmod 0755 "$TARGET/usr/local/sbin/xbps-pre-upgrade-snapshot.sh" + + # xbps doesn't have native pre-hooks; wrap xbps-install via /usr/local/bin shim. + cat > "$TARGET/usr/local/bin/xbps-install" <<'EOF' +#!/bin/sh +# Wrapper that snapshots @ before any state-changing xbps-install run. +case " $* " in + *" -S "*|*" --sync "*|*" -u "*|*" --update "*) /usr/local/sbin/xbps-pre-upgrade-snapshot.sh || true ;; + *) [ -n "$1" ] && /usr/local/sbin/xbps-pre-upgrade-snapshot.sh || true ;; +esac +exec /usr/bin/xbps-install "$@" +EOF + chmod 0755 "$TARGET/usr/local/bin/xbps-install" + log "btrfs pre-upgrade snapshot hook installed" +} + +_install_upgrade_applet() { + local TARGET="$1" + install -d -m 0755 "$TARGET/usr/local/bin" \ + "$TARGET/usr/share/applications" \ + "$TARGET/etc/skel/.config/autostart" + + # Tiny GUI wrapper: uses zenity if available, else xterm. + cat > "$TARGET/usr/local/bin/void-upgrade-gui" <<'EOF' +#!/bin/sh +# Check for upgrades, prompt user, run xbps-install -Su (with snapshot via wrapper). +set -e +PENDING=$(xbps-install -Sun 2>/dev/null | wc -l) +if [ "$PENDING" -eq 0 ]; then + notify-send "Void Upgrade" "System is up to date." 2>/dev/null || true + exit 0 +fi +MSG="There are $PENDING package updates available.\nRun system upgrade now?\n(A btrfs snapshot will be taken automatically.)" +if command -v zenity >/dev/null 2>&1; then + zenity --question --title="Void Upgrade" --text="$MSG" || exit 0 +fi +pkexec xbps-install -Suy 2>&1 | tee /tmp/void-upgrade.log +notify-send "Void Upgrade" "Upgrade finished. See /tmp/void-upgrade.log" 2>/dev/null || true +EOF + chmod 0755 "$TARGET/usr/local/bin/void-upgrade-gui" + + cat > "$TARGET/usr/share/applications/void-upgrade.desktop" <<'EOF' +[Desktop Entry] +Type=Application +Name=Void Upgrade +Comment=Check and apply system upgrades (with btrfs snapshot) +Exec=void-upgrade-gui +Icon=system-software-update +Categories=System;PackageManager; +Terminal=false +EOF + + # Daily check on login (autostart, runs once per day). + cat > "$TARGET/etc/skel/.config/autostart/void-upgrade-check.desktop" <<'EOF' +[Desktop Entry] +Type=Application +Name=Void Upgrade Check +Exec=sh -c '[ "$(date +%F)" != "$(cat ~/.cache/void-upgrade.last 2>/dev/null)" ] && void-upgrade-gui && date +%F > ~/.cache/void-upgrade.last' +X-GNOME-Autostart-enabled=true +NoDisplay=true +EOF + cp -a "$TARGET/etc/skel/.config/autostart/void-upgrade-check.desktop" \ + "$TARGET/home/$USERNAME/.config/autostart/" 2>/dev/null || true + run_chroot "chown -R $USERNAME:$USERNAME /home/$USERNAME/.config" || true + log "void-upgrade GUI applet installed" +} + +_install_nemo_actions() { + local TARGET="$1" + install -d -m 0755 "$TARGET/usr/share/nemo/actions" + cat > "$TARGET/usr/share/nemo/actions/open-vscode.nemo_action" <<'EOF' +[Nemo Action] +Active=true +Name=Open with VS Code +Comment=Open the selected file or folder in Visual Studio Code +Exec=code %F +Icon-Name=com.visualstudio.code +Selection=Any +Extensions=any; +EOF + log "Nemo 'Open with VS Code' action installed" +} diff --git a/installer/lib/grub.sh b/installer/lib/grub.sh new file mode 100755 index 0000000..701239f --- /dev/null +++ b/installer/lib/grub.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# GRUB UEFI install with dual-boot (Windows on /dev/nvme0n1p3 via os-prober). + +# shellcheck source=common.sh +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +install_grub() { + step "Installing GRUB (UEFI, bootloader-id=$BOOTLOADER_ID)" + + local TARGET="${TARGET:-/mnt}" + + if ! is_uefi; then + die "non-UEFI boot detected; this installer only supports UEFI" + fi + + # Configure /etc/default/grub + cat > "$TARGET/etc/default/grub" <<'GRUBEOF' +GRUB_DEFAULT=0 +GRUB_TIMEOUT=5 +GRUB_DISTRIBUTOR="Void" +GRUB_CMDLINE_LINUX_DEFAULT="loglevel=4 nvidia-drm.modeset=1" +GRUB_CMDLINE_LINUX="" +GRUB_DISABLE_OS_PROBER=false +GRUB_TERMINAL_OUTPUT="gfxterm" +GRUB_GFXMODE=auto +GRUBEOF + + # Make sure os-prober can see the NTFS partitions to enumerate Windows. + run_chroot "modprobe efivarfs 2>/dev/null || true" + run_chroot "xbps-install -y os-prober ntfs-3g >/dev/null 2>&1 || true" + + run_chroot "grub-install \ + --target=x86_64-efi \ + --efi-directory=/boot/efi \ + --bootloader-id='$BOOTLOADER_ID' \ + --recheck" + + # Ensure os-prober actually runs (some hosts skip it without this). + mkdir -p "$TARGET/etc/grub.d" + + # Generate config + run_chroot "grub-mkconfig -o /boot/grub/grub.cfg" + + # Verify Windows entry was found (best-effort, non-fatal in test mode) + if grep -q -i 'windows\|microsoft' "$TARGET/boot/grub/grub.cfg"; then + ok "Windows boot entry detected in grub.cfg" + else + if [[ "${TEST_MODE:-0}" == "1" ]]; then + log "no Windows entry (expected in test mode)" + else + warn "no Windows entry in grub.cfg — os-prober may have failed; you can re-run grub-mkconfig later" + fi + fi + + ok "GRUB installed" +} diff --git a/installer/lib/partition.sh b/installer/lib/partition.sh new file mode 100755 index 0000000..08c8168 --- /dev/null +++ b/installer/lib/partition.sh @@ -0,0 +1,130 @@ +#!/bin/bash +# Partition / filesystem setup. +# - Reformats ROOT_PART as btrfs with subvolumes +# - Mounts everything under TARGET (default /mnt) +# - Mounts existing EFI partition read-write at $TARGET/boot/efi (NEVER reformatted) + +# shellcheck source=common.sh +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +setup_filesystems() { + step "Filesystem setup on $ROOT_PART (btrfs) + EFI share $EFI_PART" + + [[ -b "$ROOT_PART" ]] || die "ROOT_PART $ROOT_PART not a block device" + [[ -b "$EFI_PART" ]] || die "EFI_PART $EFI_PART not a block device" + + # Sanity: EFI must already be vfat. We never format it. + local efi_fs + efi_fs=$(lsblk -no FSTYPE "$EFI_PART") + [[ "$efi_fs" == "vfat" ]] \ + || die "EFI partition $EFI_PART is '$efi_fs', expected vfat — refusing" + + # Force unmount anything currently using ROOT_PART (live ISO, prior run). + local mp + while read -r mp; do + [[ -n "$mp" ]] && umount -R "$mp" 2>/dev/null || true + done < <(lsblk -nro MOUNTPOINTS "$ROOT_PART" 2>/dev/null | grep -v '^$' || true) + umount -R "$ROOT_PART" 2>/dev/null || true + swapoff -a 2>/dev/null || true + + # If the live ISO's initramfs already auto-discovered a btrfs on this + # partition (mklive runs `btrfs device scan` early), the device is + # registered with the in-kernel btrfs module and any later mkfs sees EBUSY. + # Forget the registration BEFORE wiping so it doesn't get re-claimed. + btrfs device scan --forget 2>/dev/null || true + + log "wiping filesystem signatures on $ROOT_PART" + wipefs -af "$ROOT_PART" 2>/dev/null || true + # Zero the first 64 MiB and last 4 MiB to obliterate btrfs primary AND + # backup superblocks. Without this, a half-written btrfs from an + # interrupted prior run can be auto-mounted between commands. + dd if=/dev/zero of="$ROOT_PART" bs=1M count=64 conv=fsync 2>/dev/null || true + local part_size_b + part_size_b=$(blockdev --getsize64 "$ROOT_PART" 2>/dev/null || echo 0) + if [[ "$part_size_b" -gt $((8 * 1024 * 1024)) ]]; then + local seek_mb=$(( part_size_b / 1024 / 1024 - 4 )) + dd if=/dev/zero of="$ROOT_PART" bs=1M count=4 seek="$seek_mb" \ + conv=fsync 2>/dev/null || true + fi + sync + udevadm settle 2>/dev/null || true + btrfs device scan --forget 2>/dev/null || true + + log "lsblk view of $ROOT_PART:" + lsblk -fno NAME,FSTYPE,LABEL,MOUNTPOINTS "$ROOT_PART" 2>&1 \ + | while read -r l; do log " $l"; done + + log "creating btrfs on $ROOT_PART" + # Stop udev from auto-claiming the device mid-format (race that causes + # mkfs.btrfs to fail its final O_EXCL reopen with EBUSY even though the + # superblock was written successfully). + udevadm control --stop-exec-queue 2>/dev/null || true + local mkfs_rc=0 + mkfs.btrfs -f -L void "$ROOT_PART" || mkfs_rc=$? + sync + udevadm control --start-exec-queue 2>/dev/null || true + udevadm settle 2>/dev/null || true + if [[ $mkfs_rc -ne 0 ]]; then + # Tolerate nonzero exit if a valid btrfs was in fact written + # (EBUSY-on-close race observed with btrfs-progs 6.11). + local actual_fs + actual_fs=$(blkid -o value -s TYPE "$ROOT_PART" 2>/dev/null || echo "") + if [[ "$actual_fs" != "btrfs" ]]; then + die "mkfs.btrfs failed on $ROOT_PART (rc=$mkfs_rc, fs='$actual_fs')" + fi + log "mkfs.btrfs exited rc=$mkfs_rc but blkid reports btrfs — continuing" + fi + + local TARGET="${TARGET:-/mnt}" + mkdir -p "$TARGET" + mount -o "${BTRFS_MOUNT_OPTS}" "$ROOT_PART" "$TARGET" + + log "creating subvolumes: ${BTRFS_SUBVOLS[*]%%:*}" + local entry sv + for entry in "${BTRFS_SUBVOLS[@]}"; do + sv="${entry%%:*}" + # Idempotent: skip if a stale subvolume already exists from a + # previous interrupted run (mkfs -f recreates the FS but on a fresh + # mount the directory listing should be empty; this is defensive). + if [[ -e "$TARGET/$sv" ]]; then + log " subvolume $sv already present — skipping" + continue + fi + btrfs subvolume create "$TARGET/$sv" + done + umount "$TARGET" + + log "remounting subvolumes" + # First mount the root subvolume (must be the first entry, conventionally @). + local root_entry="${BTRFS_SUBVOLS[0]}" + local root_sv="${root_entry%%:*}" + mount -o "${BTRFS_MOUNT_OPTS},subvol=${root_sv}" "$ROOT_PART" "$TARGET" + + # Pre-create mountpoints + EFI dir. + mkdir -p "$TARGET/boot/efi" + for entry in "${BTRFS_SUBVOLS[@]:1}"; do + local mp="${entry##*:}" + mkdir -p "$TARGET$mp" + done + + for entry in "${BTRFS_SUBVOLS[@]:1}"; do + sv="${entry%%:*}" + local mp="${entry##*:}" + mount -o "${BTRFS_MOUNT_OPTS},subvol=${sv}" "$ROOT_PART" "$TARGET$mp" + done + + # Mount shared EFI WITHOUT formatting. + mount "$EFI_PART" "$TARGET/boot/efi" + + # Make sure we're not about to clobber Windows' EFI loader. + if [[ -d "$TARGET/boot/efi/EFI/Microsoft" ]]; then + log "Windows EFI files detected — will preserve EFI/Microsoft/* untouched" + else + warn "no EFI/Microsoft dir found on $EFI_PART — proceeding anyway" + fi + + ok "filesystems mounted under $TARGET" + findmnt -R "$TARGET" >> "$LOG_FILE" + + export TARGET +} diff --git a/installer/lib/postinstall.sh b/installer/lib/postinstall.sh new file mode 100755 index 0000000..158faa1 --- /dev/null +++ b/installer/lib/postinstall.sh @@ -0,0 +1,351 @@ +#!/bin/bash +# Post-install configuration: +# - hostname / locale / keyboard / timezone / hwclock +# - users (root + moze) + sudo +# - services (NetworkManager, lightdm, dbus, polkitd, docker, bluetoothd, +# acpid, tlp, sshd[disabled], dhcpcd[disabled in favor of NM]) +# - zram swap (zramen) +# - NVIDIA PRIME render-offload setup +# - SSH config copy +# - Nix bootstrap (nix-daemon service + first-boot user package install) + +# shellcheck source=common.sh +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +configure_system() { + step "System configuration" + local TARGET="${TARGET:-/mnt}" + + # ----- hostname ----- + echo "$HOSTNAME" > "$TARGET/etc/hostname" + cat > "$TARGET/etc/hosts" < "$TARGET/etc/rc.conf" < "$TARGET/etc/locale.conf" < "$TARGET/etc/vconsole.conf" </dev/null 2>&1"; then + run_chroot "useradd -m -u $USER_UID -G $USER_GROUPS -s $DEFAULT_SHELL -c '$USER_FULLNAME' $USERNAME" + else + log "user $USERNAME already exists — skipping useradd" + run_chroot "usermod -G $USER_GROUPS -s $DEFAULT_SHELL $USERNAME" + fi + set_chroot_password "$USERNAME" "$USER_PASSWORD" + + # ----- sudoers: wheel group ----- + mkdir -p "$TARGET/etc/sudoers.d" + if [[ "${TEST_MODE:-0}" == "1" ]]; then + # Test harness needs passwordless sudo to run smoke checks via SSH. + cat > "$TARGET/etc/sudoers.d/10-wheel" <<'EOF' +%wheel ALL=(ALL:ALL) NOPASSWD: ALL +Defaults env_keep += "EDITOR" +EOF + else + cat > "$TARGET/etc/sudoers.d/10-wheel" <<'EOF' +%wheel ALL=(ALL:ALL) ALL +Defaults env_keep += "EDITOR" +EOF + fi + chmod 440 "$TARGET/etc/sudoers.d/10-wheel" + + ok "user '$USERNAME' created and added to: $USER_GROUPS" +} + +configure_ssh_config() { + step "Installing SSH config for $USERNAME" + local TARGET="${TARGET:-/mnt}" + local src="$SSH_SOURCE_DIR" + local dst="$TARGET/home/$USERNAME/$SSH_TARGET_DIR_REL" + + if [[ ! -d "$src" ]]; then + warn "no SSH source dir at $src — skipping" + return 0 + fi + + install -d -m 0700 "$dst" + cp -a "$src"/. "$dst/" + # Tighten perms. + find "$dst" -type d -exec chmod 700 {} + + find "$dst" -type f -exec chmod 600 {} + + find "$dst" -type f -name '*.pub' -exec chmod 644 {} + + [[ -f "$dst/known_hosts" ]] && chmod 644 "$dst/known_hosts" + [[ -f "$dst/known_hosts.old" ]] && chmod 644 "$dst/known_hosts.old" + [[ -f "$dst/config" ]] && chmod 600 "$dst/config" + + run_chroot "chown -R $USERNAME:$USERNAME /home/$USERNAME/$SSH_TARGET_DIR_REL" + ok "SSH config installed at /home/$USERNAME/$SSH_TARGET_DIR_REL" +} + +configure_nvidia_prime() { + step "Configuring NVIDIA PRIME render-offload" + local TARGET="${TARGET:-/mnt}" + + # 1) Xorg config: Intel as primary, NVIDIA as PRIME provider. + install -d -m 0755 "$TARGET/etc/X11/xorg.conf.d" + cat > "$TARGET/etc/X11/xorg.conf.d/10-intel.conf" <<'EOF' +Section "OutputClass" + Identifier "intel" + MatchDriver "i915" + Driver "modesetting" +EndSection +EOF + cat > "$TARGET/etc/X11/xorg.conf.d/20-nvidia.conf" <<'EOF' +Section "OutputClass" + Identifier "nvidia" + MatchDriver "nvidia-drm" + Driver "nvidia" + Option "AllowEmptyInitialConfiguration" + Option "PrimaryGPU" "no" + ModulePath "/usr/lib/nvidia/xorg" + ModulePath "/usr/lib/xorg/modules" +EndSection +EOF + + # 2) Modules to load early for KMS. + install -d -m 0755 "$TARGET/etc/modules-load.d" + cat > "$TARGET/etc/modules-load.d/nvidia.conf" <<'EOF' +nvidia +nvidia_modeset +nvidia_uvm +nvidia_drm +EOF + + # 3) Wrapper script + desktop file: run any app on NVIDIA via `prime-run`. + install -d -m 0755 "$TARGET/usr/local/bin" + cat > "$TARGET/usr/local/bin/prime-run" <<'EOF' +#!/bin/sh +# Run a program on the NVIDIA dGPU via PRIME render offload. +exec env __NV_PRIME_RENDER_OFFLOAD=1 \ + __VK_LAYER_NV_optimus=NVIDIA_only \ + __GLX_VENDOR_LIBRARY_NAME=nvidia \ + "$@" +EOF + chmod 0755 "$TARGET/usr/local/bin/prime-run" + + # 4) Make sure dracut picks up nvidia modules. + install -d -m 0755 "$TARGET/etc/dracut.conf.d" + cat > "$TARGET/etc/dracut.conf.d/10-nvidia.conf" <<'EOF' +add_drivers+=" nvidia nvidia_modeset nvidia_uvm nvidia_drm " +EOF + + ok "NVIDIA PRIME offload configured (use 'prime-run ')" +} + +configure_zram() { + [[ "${ZRAM_ENABLE:-yes}" == "yes" ]] || return 0 + step "Configuring zram (zramen)" + local TARGET="${TARGET:-/mnt}" + install -d -m 0755 "$TARGET/etc/default" + cat > "$TARGET/etc/default/zramen" < "$TARGET/etc/nix/nix.conf" <<'EOF' +experimental-features = nix-command flakes +build-users-group = nixbld +auto-optimise-store = true +sandbox = true +EOF + + # First-boot script: as $USERNAME, install user packages. + install -d -m 0755 "$TARGET/usr/local/libexec" + cat > "$TARGET/usr/local/libexec/first-boot-nix.sh" <&2 + exit 0 +fi + +su - $USERNAME -c ' + set -e + . /etc/profile.d/nix.sh 2>/dev/null || true + # google-chrome / spotify / discord are unfree -> need allow-unfree + --impure. + export NIXPKGS_ALLOW_UNFREE=1 + nix profile install --impure ${NIX_USER_PACKAGES[*]} || true +' + +mkdir -p "\$(dirname "\$mark")" +touch "\$mark" +EOF + chmod 0755 "$TARGET/usr/local/libexec/first-boot-nix.sh" + + # runit one-shot service. + install -d -m 0755 "$TARGET/etc/sv/first-boot-nix" + cat > "$TARGET/etc/sv/first-boot-nix/run" <<'EOF' +#!/bin/sh +exec 2>&1 +/usr/local/libexec/first-boot-nix.sh +exec chpst -b first-boot-nix pause +EOF + chmod 0755 "$TARGET/etc/sv/first-boot-nix/run" + cat > "$TARGET/etc/sv/first-boot-nix/finish" <<'EOF' +#!/bin/sh +sv down first-boot-nix +EOF + chmod 0755 "$TARGET/etc/sv/first-boot-nix/finish" + + ok "Nix configured; user packages will install on first boot" +} + +install_vscode_real() { + # Install official Microsoft VS Code (the real proprietary build), NOT the + # `vscode` xbps package which is actually code-oss and ships `code-oss`. + step "Installing official Microsoft VS Code" + local TARGET="${TARGET:-/mnt}" + local url="https://update.code.visualstudio.com/latest/linux-x64/stable" + local tmp="$TARGET/tmp/vscode.tar.gz" + + install -d -m 0755 "$TARGET/opt" "$TARGET/usr/local/bin" + + if ! run_chroot "curl -fsSL --retry 3 -o /tmp/vscode.tar.gz '$url'"; then + warn "failed to download VS Code; skipping (install manually later)" + rm -f "$tmp" + return 0 + fi + + # Tarball extracts to VSCode-linux-x64/. Move it to /opt/vscode. + rm -rf "$TARGET/opt/vscode" + if ! run_chroot "tar -xzf /tmp/vscode.tar.gz -C /opt && mv /opt/VSCode-linux-x64 /opt/vscode"; then + warn "failed to extract VS Code tarball; skipping" + rm -f "$tmp" + return 0 + fi + rm -f "$tmp" + + # `code` shim on PATH. + ln -sf /opt/vscode/bin/code "$TARGET/usr/local/bin/code" + + # Desktop entry so it shows up in the Cinnamon menu. + install -d -m 0755 "$TARGET/usr/local/share/applications" + cat > "$TARGET/usr/local/share/applications/code.desktop" <<'EOF' +[Desktop Entry] +Name=Visual Studio Code +Comment=Code Editing. Redefined. +GenericName=Text Editor +Exec=/opt/vscode/bin/code %F +Icon=/opt/vscode/resources/app/resources/linux/code.png +Type=Application +StartupNotify=false +StartupWMClass=Code +Categories=TextEditor;Development;IDE; +MimeType=text/plain;inode/directory;application/x-code-workspace; +Actions=new-empty-window; +Keywords=vscode; + +[Desktop Action new-empty-window] +Name=New Empty Window +Exec=/opt/vscode/bin/code --new-window %F +Icon=/opt/vscode/resources/app/resources/linux/code.png +EOF + + ok "VS Code installed at /opt/vscode (use 'code' on PATH)" +} + +enable_services() { + step "Enabling services (runit)" + local TARGET="${TARGET:-/mnt}" + local svdir="$TARGET/etc/runit/runsvdir/default" + install -d -m 0755 "$svdir" + + local svc + local enabled=( + dbus + NetworkManager + lightdm + polkitd + docker + bluetoothd + acpid + tlp + elogind + chronyd + nix-daemon + first-boot-nix + zramen + cupsd + cups-browsed + ) + [[ "${SSHD_ENABLE:-no}" == "yes" ]] && enabled+=(sshd) + + for svc in "${enabled[@]}"; do + if [[ -d "$TARGET/etc/sv/$svc" ]]; then + ln -sf "/etc/sv/$svc" "$svdir/$svc" + log "enabled $svc" + else + warn "no service dir /etc/sv/$svc — skipping" + fi + done + + # Disable dhcpcd if it's there (NetworkManager handles it). + rm -f "$svdir/dhcpcd" 2>/dev/null || true + + ok "services enabled" +} + +reconfigure_all() { + step "Reconfiguring all packages (initramfs + grub artifacts)" + run_chroot "xbps-reconfigure -fa" + ok "reconfigure complete" +} diff --git a/installer/lib/profiles.sh b/installer/lib/profiles.sh new file mode 100644 index 0000000..218ecfb --- /dev/null +++ b/installer/lib/profiles.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Profile loading. PROFILE env var (default: stable-cinnamon) selects which +# config/profiles//profile.conf is sourced and which packages.list is +# used. Every variable defined in profile.conf overrides install.conf. + +PROFILES_DIR="${PROFILES_DIR:-${PROJECT_DIR:-/usr/local/share/installer}/profiles}" + +load_profile() { + local profile="${PROFILE:-stable-cinnamon}" + local pdir="$PROFILES_DIR/$profile" + + if [[ ! -d "$pdir" ]]; then + echo "[ERR] profile '$profile' not found at $pdir" >&2 + echo "Available profiles:" >&2 + ls -1 "$PROFILES_DIR" 2>/dev/null | sed 's/^/ - /' >&2 + return 1 + fi + + if [[ -r "$pdir/profile.conf" ]]; then + # shellcheck disable=SC1090 + source "$pdir/profile.conf" + fi + + export PROFILE="$profile" + export PROFILE_DIR="$pdir" + # Resolve packages list path (profile.conf may set it relative or absolute). + if [[ -n "$PROFILE_PACKAGES_FILE" && ! -r "$PROFILE_PACKAGES_FILE" ]]; then + # Try resolving relative to profile dir, then project root. + if [[ -r "$pdir/$(basename "$PROFILE_PACKAGES_FILE")" ]]; then + PROFILE_PACKAGES_FILE="$pdir/$(basename "$PROFILE_PACKAGES_FILE")" + elif [[ -r "${PROJECT_DIR:-.}/$PROFILE_PACKAGES_FILE" ]]; then + PROFILE_PACKAGES_FILE="${PROJECT_DIR:-.}/$PROFILE_PACKAGES_FILE" + fi + fi + [[ -z "$PROFILE_PACKAGES_FILE" ]] && PROFILE_PACKAGES_FILE="$pdir/packages.list" + export PROFILE_PACKAGES_FILE + + echo "[INFO] profile loaded: $PROFILE ($PROFILE_DESC)" + return 0 +} + +run_profile_customizations() { + # Source every *.sh under /customizations/ in name order. + local cdir="$PROFILE_DIR/customizations" + [[ -d "$cdir" ]] || { echo "[INFO] no profile customizations dir at $cdir"; return 0; } + local hook + for hook in "$cdir"/*.sh; do + [[ -r "$hook" ]] || continue + echo "[INFO] running profile hook: $(basename "$hook")" + # shellcheck disable=SC1090 + source "$hook" + done +} diff --git a/installer/lib/tui.sh b/installer/lib/tui.sh new file mode 100755 index 0000000..3a9ff7b --- /dev/null +++ b/installer/lib/tui.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# TUI disk selection. Uses `dialog` to show detected disks/partitions +# and require explicit user confirmation before any destructive action. +# Sets globals: TARGET_DISK, ROOT_PART, EFI_PART + +# shellcheck source=common.sh +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +tui_select_install_target() { + step "Disk selection" + + local default_root="${DEFAULT_ROOT_PART:-}" + local default_efi="${DEFAULT_EFI_PART:-}" + + # Build a human menu of partitions (skip loop/ram/zram, only TYPE=part). + local menu_items=() + local dev type fstype size label + while read -r dev type fstype size label; do + [[ "$type" == "part" ]] || continue + [[ "$dev" =~ ^/dev/(sd|nvme|vd|mmcblk|hd|xvd) ]] || continue + local marker="" + [[ "$dev" == "$default_root" ]] && marker=" (DEFAULT ROOT)" + [[ "$fstype" == "vfat" ]] && marker+=" [EFI?]" + [[ "$fstype" == "ntfs" ]] && marker+=" [WINDOWS - DO NOT TOUCH]" + menu_items+=("$dev" "${fstype:-?} ${size} '${label:-}'${marker}") + done < <(lsblk -lnpo NAME,TYPE,FSTYPE,SIZE,LABEL 2>/dev/null || true) + + if [[ ${#menu_items[@]} -eq 0 ]]; then + log "lsblk output for diagnosis:" + lsblk -lnpo NAME,TYPE,FSTYPE,SIZE,LABEL 2>&1 | while read -r l; do log " $l"; done + die "no candidate partitions found" + fi + + local choice + if [[ "${UNATTENDED:-0}" == "1" ]]; then + choice="$default_root" + log "[unattended] target root partition = $choice" + else + choice=$(dialog --stdout --title "Void Installer — SELECT ROOT PARTITION" \ + --backtitle "WARNING: the chosen partition will be WIPED. Windows partitions show [WINDOWS]." \ + --default-item "$default_root" \ + --menu "Choose the partition to install Void Linux onto.\nDefault highlights $default_root (current Linux Mint).\nAbsolutely DO NOT pick a partition labelled [WINDOWS]." \ + 25 90 14 "${menu_items[@]}") \ + || die "user cancelled disk selection" + fi + + [[ -b "$choice" ]] || die "selected device $choice is not a block device" + local fstype + fstype=$(lsblk -no FSTYPE "$choice" 2>/dev/null | head -1) + if [[ "$fstype" == "ntfs" ]]; then + die "REFUSING to wipe NTFS partition $choice (looks like Windows)" + fi + + ROOT_PART="$choice" + EFI_PART="${default_efi}" + # Resolve the parent block device robustly (works for nvme, mmcblk, sd*, vd*). + TARGET_DISK="/dev/$(lsblk -no PKNAME "$choice" 2>/dev/null | head -1)" + [[ "$TARGET_DISK" == "/dev/" ]] && TARGET_DISK="$DEFAULT_DISK" + + # Confirmation step: must type the partition device name verbatim. + if [[ "${UNATTENDED:-0}" != "1" ]]; then + local typed + typed=$(dialog --stdout --title "FINAL CONFIRMATION" \ + --backtitle "Type the device name to confirm WIPE" \ + --inputbox "About to:\n - WIPE : $ROOT_PART (will become btrfs)\n - SHARE : $EFI_PART (kept intact, only adds /EFI/Void)\n - LEAVE : everything else (Windows, recovery)\n\nType the FULL device path of the partition to wipe to continue:" \ + 18 75) \ + || die "user cancelled confirmation" + [[ "$typed" == "$ROOT_PART" ]] \ + || die "confirmation mismatch (typed '$typed' != '$ROOT_PART')" + fi + + ok "target root : $ROOT_PART" + ok "shared EFI : $EFI_PART" + ok "parent disk : $TARGET_DISK" + + export TARGET_DISK ROOT_PART EFI_PART +} diff --git a/iso/Dockerfile b/iso/Dockerfile new file mode 100644 index 0000000..a370d35 --- /dev/null +++ b/iso/Dockerfile @@ -0,0 +1,19 @@ +# Container used to run void-mklive with real root. +# This avoids the user-namespace CAP_MKNOD wall (dracut needs mknod /dev/null +# inside the initramfs staging dir) and lets losetup/mount/chroot work +# unconditionally. Host stays clean — no sudo, no host package installs. + +FROM debian:stable-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash git curl ca-certificates xz-utils tar patch python3 \ + mtools xorriso squashfs-tools dosfstools e2fsprogs \ + kmod \ + && rm -rf /var/lib/apt/lists/* + +# xbps-static is downloaded into /cache by the host script and added to PATH +# at runtime by /work/iso/_inner-build.sh — no Void packages baked here. + +WORKDIR /work diff --git a/iso/_inner-build.sh b/iso/_inner-build.sh new file mode 100755 index 0000000..f4cc9c2 --- /dev/null +++ b/iso/_inner-build.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# Runs INSIDE the docker container (as root). Invoked by iso/build-iso.sh. +# Expects the project bind-mounted at /work and the cache at /cache. +# +# Required env (set by build-iso.sh): +# ARCH, REPO_URL, KEYMAP, LOCALE, ISO_PKGS, ISO_TITLE, OUT_ISO_REL + +set -Eeuo pipefail + +: "${ARCH:?}"; : "${REPO_URL:?}"; : "${KEYMAP:?}"; : "${LOCALE:?}" +: "${ISO_PKGS:?}"; : "${ISO_TITLE:?}"; : "${OUT_ISO_REL:?}" + +CACHE_DIR=/cache +PROJECT_DIR=/work +MKLIVE_DIR="$CACHE_DIR/void-mklive" +INCLUDE_DIR="$PROJECT_DIR/build/includes" +OUT_ISO="$PROJECT_DIR/$OUT_ISO_REL" + +# xbps-static was downloaded by the host script; put it on PATH so mklive +# uses it instead of expecting a system xbps. +export PATH="$CACHE_DIR/xbps-static/usr/bin:$PATH" + +# Sanity checks. +[[ -d "$MKLIVE_DIR" ]] || { echo "ERROR: $MKLIVE_DIR missing"; exit 1; } +[[ -d "$INCLUDE_DIR" ]] || { echo "ERROR: $INCLUDE_DIR missing"; exit 1; } +command -v xbps-install.static >/dev/null \ + || { echo "ERROR: xbps-install.static not on PATH"; exit 1; } + +mkdir -p "$(dirname "$OUT_ISO")" + +cd "$MKLIVE_DIR" + +# Cleanup trap: lazy-unmount any leftover pseudo-fs from a previous abort. +_cleanup_mklive_builds() { + local d sub + for d in "$MKLIVE_DIR"/mklive-build.*/; do + [[ -d "$d" ]] || continue + for sub in tmp-rootfs/sys tmp-rootfs/proc tmp-rootfs/dev tmp-rootfs/run \ + image/rootfs/sys image/rootfs/proc image/rootfs/dev image/rootfs/run; do + [[ -d "$d$sub" ]] && umount -R --lazy "$d$sub" 2>/dev/null || true + done + rm -rf "$d" 2>/dev/null || true + done +} +trap _cleanup_mklive_builds EXIT + +./mklive.sh \ + -a "$ARCH" \ + -r "$REPO_URL" \ + -c "$CACHE_DIR/xbps-live-pkgs" \ + -H "$CACHE_DIR/xbps-host-pkgs" \ + -k "$KEYMAP" \ + -l "$LOCALE" \ + -T "$ISO_TITLE" \ + -p "$ISO_PKGS" \ + -I "$INCLUDE_DIR" \ + -C "${BOOT_CMDLINE:-}" \ + -o "$OUT_ISO" + +# Ensure the resulting file is writable by the host user. +chown "$(stat -c '%u:%g' "$PROJECT_DIR")" "$OUT_ISO" "${OUT_ISO}".* 2>/dev/null || true diff --git a/iso/build-iso.sh b/iso/build-iso.sh new file mode 100755 index 0000000..4ba70dc --- /dev/null +++ b/iso/build-iso.sh @@ -0,0 +1,296 @@ +#!/bin/bash +# Build a custom Void Linux live ISO with the installer baked in. +# +# Strategy: +# 1. Stage everything possible AS THE HOST USER (overlay, mklive clone, patches, xbps-static). +# 2. Run void-mklive's mklive.sh AS ROOT INSIDE A DOCKER CONTAINER. +# Avoids the user-namespace CAP_MKNOD/CAP_SYS_ADMIN walls. +# +# Requires (host): bash, git, curl, docker. + +set -Eeuo pipefail + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CACHE_DIR="${CACHE_DIR:-$PROJECT_DIR/cache}" +OUT_DIR="${OUT_DIR:-$PROJECT_DIR/out}" +BUILD_DIR="${BUILD_DIR:-$PROJECT_DIR/build}" +INCLUDE_DIR="$BUILD_DIR/includes" +MKLIVE_DIR="$CACHE_DIR/void-mklive" +MKLIVE_REPO="${MKLIVE_REPO:-https://github.com/void-linux/void-mklive.git}" +MKLIVE_REF="${MKLIVE_REF:-master}" +PATCH_DIR="$PROJECT_DIR/iso/patches" +DOCKER_IMAGE="${DOCKER_IMAGE:-void-installer-builder:latest}" +DOCKER="${DOCKER:-docker}" + +# shellcheck disable=SC1091 +source "$PROJECT_DIR/config/install.conf" + +SECRETS_FILE="${SECRETS_FILE:-$PROJECT_DIR/secrets.env}" +[[ -r "$SECRETS_FILE" ]] || { echo "missing $SECRETS_FILE"; exit 1; } +# shellcheck disable=SC1090 +source "$SECRETS_FILE" +: "${USER_PASSWORD:?}"; : "${ROOT_PASSWORD:?}" + +SSH_SRC_DIR="${SSH_SRC_DIR:-$HOME/.ssh}" +[[ -d "$SSH_SRC_DIR" ]] || { echo "no SSH dir at $SSH_SRC_DIR"; exit 1; } + +command -v "$DOCKER" >/dev/null \ + || { echo "ERROR: '$DOCKER' not in PATH"; exit 1; } +"$DOCKER" info >/dev/null 2>&1 \ + || { echo "ERROR: '$DOCKER' daemon unreachable"; exit 1; } + +mkdir -p "$CACHE_DIR" "$OUT_DIR" "$BUILD_DIR" + +# 1) clone+patch mklive +if [[ ! -d "$MKLIVE_DIR/.git" ]]; then + echo ">>> cloning void-mklive" + git clone --depth=1 --branch "$MKLIVE_REF" "$MKLIVE_REPO" "$MKLIVE_DIR" +fi +if compgen -G "$PATCH_DIR/*.patch" >/dev/null; then + echo ">>> resetting + applying iso/patches/" + ( cd "$MKLIVE_DIR" && git checkout -- . ) + for p in "$PATCH_DIR"/*.patch; do + echo " $(basename "$p")" + ( cd "$MKLIVE_DIR" && patch -p1 --silent < "$p" ) + done +fi + +# 2) xbps-static +XBPS_STATIC_DIR="$CACHE_DIR/xbps-static" +if [[ ! -x "$XBPS_STATIC_DIR/usr/bin/xbps-install.static" ]]; then + echo ">>> downloading xbps-static" + mkdir -p "$XBPS_STATIC_DIR" + curl -fsSL "https://repo-default.voidlinux.org/static/xbps-static-latest.x86_64-musl.tar.xz" \ + | tar xJf - -C "$XBPS_STATIC_DIR" +fi + +# 3) overlay +echo ">>> staging includes overlay at $INCLUDE_DIR" +rm -rf "$INCLUDE_DIR" +mkdir -p "$INCLUDE_DIR" + +install -d -m 0755 "$INCLUDE_DIR/usr/local/share/installer/lib" +install -m 0644 "$PROJECT_DIR/config/install.conf" "$INCLUDE_DIR/usr/local/share/installer/install.conf" +# Default package list = stable-cinnamon profile (kept in /share for back-compat). +install -m 0644 "$PROJECT_DIR/config/profiles/stable-cinnamon/packages.list" \ + "$INCLUDE_DIR/usr/local/share/installer/packages.list" +install -m 0755 "$PROJECT_DIR/installer/install.sh" "$INCLUDE_DIR/usr/local/share/installer/install.sh" +for f in "$PROJECT_DIR"/installer/lib/*.sh; do + install -m 0755 "$f" "$INCLUDE_DIR/usr/local/share/installer/lib/$(basename "$f")" +done + +# Ship every profile so the live ISO can install any of them via PROFILE=. +install -d -m 0755 "$INCLUDE_DIR/usr/local/share/installer/profiles" +cp -a "$PROJECT_DIR/config/profiles/." "$INCLUDE_DIR/usr/local/share/installer/profiles/" + +install -d -m 0755 "$INCLUDE_DIR/usr/local/sbin" +ln -sf /usr/local/share/installer/install.sh "$INCLUDE_DIR/usr/local/sbin/install-void" + +install -d -m 0700 "$INCLUDE_DIR/etc" +{ + printf "USER_PASSWORD=%q\n" "$USER_PASSWORD" + printf "ROOT_PASSWORD=%q\n" "$ROOT_PASSWORD" +} > "$INCLUDE_DIR/etc/installer-secrets.env" +chmod 0600 "$INCLUDE_DIR/etc/installer-secrets.env" + +install -d -m 0700 "$INCLUDE_DIR/etc/installer-ssh" +cp -a "$SSH_SRC_DIR"/. "$INCLUDE_DIR/etc/installer-ssh/" +find "$INCLUDE_DIR/etc/installer-ssh" -type f -exec chmod 0600 {} + +find "$INCLUDE_DIR/etc/installer-ssh" -type d -exec chmod 0700 {} + + +install -d -m 0755 "$INCLUDE_DIR/etc/sv/installer" +cat > "$INCLUDE_DIR/etc/sv/installer/run" <<'SV_EOF' +#!/bin/sh +exec /sbin/agetty --autologin root --noclear tty1 linux +SV_EOF +chmod 0755 "$INCLUDE_DIR/etc/sv/installer/run" + +install -d -m 0755 "$INCLUDE_DIR/etc/sv/agetty-tty1" +: > "$INCLUDE_DIR/etc/sv/agetty-tty1/down" + +install -d -m 0755 "$INCLUDE_DIR/etc/runit/runsvdir/default" +ln -sf /etc/sv/installer "$INCLUDE_DIR/etc/runit/runsvdir/default/installer" + +install -d -m 0700 "$INCLUDE_DIR/root" +cat > "$INCLUDE_DIR/root/.bash_profile" <<'PROFILE_EOF' +case "$(tty)" in + /dev/tty1) + if [ ! -f /tmp/.installer-done ]; then + touch /tmp/.installer-done + clear + echo + echo " Void Linux Installer (xps9700)" + echo " Press ENTER to start, or Ctrl-C within 5s for a shell." + sleep 5 || true + /usr/local/sbin/install-void || { + echo "Installer exited with $?. Dropping to shell." + exec /bin/bash + } + echo "Install complete. Type 'reboot' or 'poweroff'." + exec /bin/bash + fi + ;; +esac +PROFILE_EOF +chmod 0644 "$INCLUDE_DIR/root/.bash_profile" + +cat > "$INCLUDE_DIR/etc/motd" <>> merging EXTRA_INCLUDE_DIR=$EXTRA_INCLUDE_DIR" + cp -a "$EXTRA_INCLUDE_DIR/." "$INCLUDE_DIR/" +fi + +# 3b) Customizations overlay (themes / icons / wallpapers / dotfiles / vscode) +echo ">>> staging user customizations overlay" +OVERLAY="$INCLUDE_DIR/etc/installer-overlay" +install -d -m 0755 "$OVERLAY" "$OVERLAY/wallpapers" \ + "$OVERLAY/themes" "$OVERLAY/icons" \ + "$OVERLAY/skel" "$OVERLAY/vscode-user" + +# Wallpapers from ~/Scaricati/pxfuel*.jpg (literal parens in filenames) +WP_SRC="${WALLPAPERS_SRC:-$HOME/Scaricati}" +shopt -s nullglob +for f in "$WP_SRC"/pxfuel*.jpg; do + install -m 0644 "$f" "$OVERLAY/wallpapers/$(basename "$f")" +done +shopt -u nullglob +echo " wallpapers: $(ls "$OVERLAY/wallpapers" | wc -l) file(s)" + +# Theme: clone Gruvbox-GTK-Theme and run install.sh into a staging dir. +THEME_CACHE="$CACHE_DIR/gruvbox-gtk-theme" +if [[ ! -d "$THEME_CACHE/.git" ]]; then + git clone --depth=1 https://github.com/Fausto-Korpsvart/Gruvbox-GTK-Theme.git "$THEME_CACHE" || \ + echo " (warning: could not clone theme repo)" +fi +THEME_BUILD="$CACHE_DIR/gruvbox-gtk-built" +if [[ -x "$THEME_CACHE/themes/install.sh" && ! -d "$THEME_BUILD" ]]; then + echo " building gruvbox themes -> $THEME_BUILD (via docker)" + install -d -m 0755 "$THEME_BUILD" + "$DOCKER" run --rm \ + -v "$THEME_CACHE":/src \ + -v "$THEME_BUILD":/out \ + debian:stable-slim sh -c ' + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq >/dev/null + apt-get install -y --no-install-recommends sassc bash >/dev/null + cd /src/themes && bash install.sh -d /out -t default -c dark -s standard + ' || echo " (warning: theme build failed; continuing without)" +fi +if [[ -d "$THEME_BUILD" ]]; then + for d in "$THEME_BUILD"/Gruvbox-Dark*; do + [[ -d "$d" ]] && cp -a "$d" "$OVERLAY/themes/$(basename "$d")" + done + echo " themes: $(ls "$OVERLAY/themes" 2>/dev/null | wc -l) variant(s)" +fi + +# Icons: clone gruvbox-plus-icon-pack +ICON_CACHE="$CACHE_DIR/gruvbox-plus-icons" +if [[ ! -d "$ICON_CACHE/.git" ]]; then + git clone --depth=1 https://github.com/SylEleuth/gruvbox-plus-icon-pack.git "$ICON_CACHE" || \ + echo " (warning: could not clone icon repo)" +fi +if [[ -d "$ICON_CACHE/Gruvbox-Plus-Dark" ]]; then + cp -a "$ICON_CACHE/Gruvbox-Plus-Dark" "$OVERLAY/icons/Gruvbox-Plus-Dark" + echo " icons: Gruvbox-Plus-Dark deployed" +fi + +# Bibata cursor — copied from host (no Void package). +BIBATA_SRC="${BIBATA_SRC:-/usr/share/icons/Bibata-Modern-Ice}" +if [[ -d "$BIBATA_SRC" ]]; then + cp -a "$BIBATA_SRC" "$OVERLAY/icons/Bibata-Modern-Ice" + echo " cursor: Bibata-Modern-Ice deployed" +else + echo " (warning: $BIBATA_SRC missing on host; cursor theme will be skipped)" +fi + +# Dotfiles (skel) +DOTFILES_SRC="${DOTFILES_SRC:-$HOME}" +for f in .bashrc .bash_aliases .gitconfig; do + [[ -r "$DOTFILES_SRC/$f" ]] && install -m 0644 "$DOTFILES_SRC/$f" "$OVERLAY/skel/$f" +done +echo " dotfiles: $(ls -A "$OVERLAY/skel" 2>/dev/null | wc -l) file(s)" + +# VS Code user config +VSCODE_SRC="${VSCODE_USER_SRC:-$HOME/.config/Code/User}" +if [[ -d "$VSCODE_SRC" ]]; then + for f in settings.json keybindings.json mcp.json tasks.json; do + [[ -r "$VSCODE_SRC/$f" ]] && install -m 0644 "$VSCODE_SRC/$f" "$OVERLAY/vscode-user/$f" + done + [[ -d "$VSCODE_SRC/snippets" ]] && cp -a "$VSCODE_SRC/snippets" "$OVERLAY/vscode-user/snippets" + # Copy the whole globalStorage so Copilot/extensions keep their state + # (note: GitHub OAuth tokens live in libsecret, NOT here — user re-signs once). + [[ -d "$VSCODE_SRC/globalStorage" ]] && cp -a "$VSCODE_SRC/globalStorage" "$OVERLAY/vscode-user/globalStorage" + echo " vscode-user: $(ls -A "$OVERLAY/vscode-user" 2>/dev/null | wc -l) item(s)" +fi + +# VS Code extensions list (so first-login can re-install them). +if command -v code >/dev/null 2>&1; then + code --list-extensions > "$OVERLAY/vscode-extensions.txt" 2>/dev/null || true + echo " vscode extensions: $(wc -l < "$OVERLAY/vscode-extensions.txt" 2>/dev/null) listed" +fi + +# Claude Code config + auth (~/.claude lives in $HOME on Linux). +CLAUDE_SRC="${CLAUDE_SRC:-$HOME/.claude}" +if [[ -d "$CLAUDE_SRC" ]]; then + cp -a "$CLAUDE_SRC" "$OVERLAY/claude" + echo " claude: ~/.claude bundled" +fi +CLAUDE_JSON="${CLAUDE_JSON:-$HOME/.claude.json}" +if [[ -r "$CLAUDE_JSON" ]]; then + install -m 0600 "$CLAUDE_JSON" "$OVERLAY/claude.json" +fi + +# VS Code extensions list (host) +if command -v code >/dev/null 2>&1; then + code --list-extensions > "$OVERLAY/vscode-extensions.txt" 2>/dev/null || true + echo " vscode extensions: $(wc -l < "$OVERLAY/vscode-extensions.txt") to install" +fi + +# First-login one-shot +[[ -r "$PROJECT_DIR/installer/first-login.sh" ]] && \ + install -m 0755 "$PROJECT_DIR/installer/first-login.sh" "$OVERLAY/first-login.sh" + + +# 4) packages, output filename +ISO_PKGS=$(grep -vE '^\s*(#|$)' "$PROJECT_DIR/config/packages.live.list" | tr '\n' ' ') +TS="$(date -u +%Y%m%d)" +OUT_ISO="${OUTPUT_ISO:-$OUT_DIR/void-install-${HOSTNAME}-${TS}.iso}" + +# 5) docker +echo ">>> building docker image $DOCKER_IMAGE (cached)" +# Force the legacy builder if buildx is missing (Docker 29 removed it from +# the default `docker build` path; users without docker-buildx-plugin +# detected on PATH will otherwise see "unknown command: docker buildx"). +if "$DOCKER" buildx version >/dev/null 2>&1; then + "$DOCKER" build -t "$DOCKER_IMAGE" "$PROJECT_DIR/iso" +else + DOCKER_BUILDKIT=0 "$DOCKER" build -t "$DOCKER_IMAGE" "$PROJECT_DIR/iso" +fi + +echo ">>> running mklive.sh inside docker — output: $OUT_ISO" +"$DOCKER" run --rm --privileged \ + -v "$PROJECT_DIR:/work:rw" \ + -v "$CACHE_DIR:/cache:rw" \ + -e ARCH="$ARCH" \ + -e REPO_URL="$REPO_URL" \ + -e KEYMAP="$KEYMAP" \ + -e LOCALE="$LOCALE" \ + -e ISO_PKGS="$ISO_PKGS" \ + -e ISO_TITLE="Void Installer ($HOSTNAME)" \ + -e OUT_ISO_REL="${OUT_ISO#$PROJECT_DIR/}" \ + -e BOOT_CMDLINE="${BOOT_CMDLINE:-}" \ + -e HOST_UID="$(id -u)" \ + -e HOST_GID="$(id -g)" \ + "$DOCKER_IMAGE" \ + bash /work/iso/_inner-build.sh + +echo +echo ">>> ISO built: $OUT_ISO" +sha256sum "$OUT_ISO" | tee "${OUT_ISO}.sha256" diff --git a/iso/patches/0001-cgroupv2-lazy-umount.patch b/iso/patches/0001-cgroupv2-lazy-umount.patch new file mode 100644 index 0000000..804304f --- /dev/null +++ b/iso/patches/0001-cgroupv2-lazy-umount.patch @@ -0,0 +1,38 @@ +--- a/lib.sh ++++ b/lib.sh +@@ -103,12 +103,16 @@ umount_pseudofs() { + # deletable instead throwing the error "Device or Resource Busy". + # The '-f' option is passed to umount to account for the + # contingency where the psuedofs mounts are not present. ++ # On cgroupv2 hosts (e.g. Ubuntu/Mint) some submounts stay busy; ++ # fall back to lazy unmount so the build continues cleanly. + if [ -d "${ROOTFS}" ]; then + for f in dev proc sys; do +- umount -R -f "$ROOTFS/$f" >/dev/null 2>&1 ++ umount -R -f "$ROOTFS/$f" >/dev/null 2>&1 || \ ++ umount -R -l "$ROOTFS/$f" >/dev/null 2>&1 || true + done + fi +- umount -f "$ROOTFS/tmp" >/dev/null 2>&1 ++ umount -f "$ROOTFS/tmp" >/dev/null 2>&1 || \ ++ umount -l "$ROOTFS/tmp" >/dev/null 2>&1 || true + } + + run_cmd_target() { +--- a/mklive.sh ++++ b/mklive.sh +@@ -55,10 +55,11 @@ mount_pseudofs() { + } + + umount_pseudofs() { ++ # lazy unmount fallback for cgroupv2 hosts (Ubuntu/Mint) + for f in sys dev proc; do +- if [ -d "$ROOTFS/$f" ] && ! umount -R -f "$ROOTFS/$f"; then +- info_msg "ERROR: failed to unmount $ROOTFS/$f/" +- return 1 ++ if [ -d "$ROOTFS/$f" ]; then ++ umount -R -f "$ROOTFS/$f" >/dev/null 2>&1 || \ ++ umount -R -l "$ROOTFS/$f" >/dev/null 2>&1 || true + fi + done + } diff --git a/iso/patches/0002-mtools-efi-and-onefs-cp.patch b/iso/patches/0002-mtools-efi-and-onefs-cp.patch new file mode 100644 index 0000000..6c40a3e --- /dev/null +++ b/iso/patches/0002-mtools-efi-and-onefs-cp.patch @@ -0,0 +1,62 @@ +--- a/mklive.sh ++++ b/mklive.sh +@@ -386,13 +387,16 @@ EOF + + modprobe -q loop || : + +- # Create EFI vfat image. +- truncate -s 32M "$GRUB_DIR"/efiboot.img >/dev/null 2>&1 +- mkfs.vfat -F12 -S 512 -n "grub_uefi" "$GRUB_DIR/efiboot.img" >/dev/null 2>&1 ++ # Create EFI vfat image — use mtools so the build does not depend on ++ # losetup (avoids CAP_SYS_ADMIN on init userns / works inside containers). ++ truncate -s 64M "$GRUB_DIR"/efiboot.img >/dev/null 2>&1 ++ mformat -i "$GRUB_DIR/efiboot.img" -F -v "grub_uefi" :: + + GRUB_EFI_TMPDIR="$(mktemp --tmpdir="$BUILDDIR" -dt grub-efi.XXXXX)" +- LOOP_DEVICE="$(losetup --show --find "${GRUB_DIR}"/efiboot.img)" +- mount -o rw,flush -t vfat "${LOOP_DEVICE}" "${GRUB_EFI_TMPDIR}" >/dev/null 2>&1 ++ LOOP_DEVICE="$(losetup --show --find "${GRUB_DIR}"/efiboot.img 2>/dev/null)" || LOOP_DEVICE="" ++ if [ -n "$LOOP_DEVICE" ]; then ++ mount -o rw,flush -t vfat "${LOOP_DEVICE}" "${GRUB_EFI_TMPDIR}" >/dev/null 2>&1 ++ fi + + build_grub_image() { + local GRUB_ARCH="$1" EFI_ARCH="$2" +@@ -402,8 +406,7 @@ EOF + --output="/tmp/boot${EFI_ARCH,,}.efi" \ + "boot/grub/grub.cfg" + if [ $? -ne 0 ]; then +- umount "$GRUB_EFI_TMPDIR" +- losetup --detach "${LOOP_DEVICE}" ++ [ -n "$LOOP_DEVICE" ] && { umount "$GRUB_EFI_TMPDIR"; losetup --detach "${LOOP_DEVICE}"; } + die "Failed to generate EFI loader" + fi + mkdir -p "${GRUB_EFI_TMPDIR}"/EFI/BOOT +@@ -426,8 +429,17 @@ EOF + build_grub_image arm64 aa64 + ;; + esac +- umount "$GRUB_EFI_TMPDIR" +- losetup --detach "${LOOP_DEVICE}" ++ if [ -n "$LOOP_DEVICE" ]; then ++ umount "$GRUB_EFI_TMPDIR" ++ losetup --detach "${LOOP_DEVICE}" ++ else ++ (cd "$GRUB_EFI_TMPDIR" && find . -type d | while read -r d; do ++ mmd -i "$GRUB_DIR/efiboot.img" "::${d#.}" 2>/dev/null || true ++ done) ++ (cd "$GRUB_EFI_TMPDIR" && find . -type f | while read -r f; do ++ mcopy -i "$GRUB_DIR/efiboot.img" "$f" "::${f#.}" ++ done) ++ fi + rm -rf "$GRUB_EFI_TMPDIR" + } + +@@ -442,7 +454,7 @@ generate_squashfs() { + mkdir -p "$BUILDDIR/tmp-rootfs" + mkfs.ext3 -F -m1 "$BUILDDIR/tmp/LiveOS/ext3fs.img" >/dev/null 2>&1 + mount -o loop "$BUILDDIR/tmp/LiveOS/ext3fs.img" "$BUILDDIR/tmp-rootfs" +- cp -a "$ROOTFS"/* "$BUILDDIR"/tmp-rootfs/ ++ cp -a --one-file-system "$ROOTFS"/* "$BUILDDIR"/tmp-rootfs/ + umount -f "$BUILDDIR/tmp-rootfs" + mkdir -p "$IMAGEDIR/LiveOS" diff --git a/tests/boot-niri-interactive.sh b/tests/boot-niri-interactive.sh new file mode 100755 index 0000000..ebbdb8a --- /dev/null +++ b/tests/boot-niri-interactive.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Boot the installed niri disk in an interactive QEMU GUI window. +set -Eeuo pipefail +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT="$PROJECT_DIR/out/niri" +DISK="$OUT/niri-disk.img" +VARS="$OUT/OVMF_VARS.installed.fd" + +[[ -r "$DISK" ]] || { echo "no $DISK — run tests/run-niri-install.sh first"; exit 1; } +cp "$OUT/OVMF_VARS.fd" "$VARS" + +DISPLAY="${DISPLAY:-:0}" exec qemu-system-x86_64 \ + -name void-niri-installed \ + -machine q35,accel=kvm:tcg \ + -cpu max -m 4096 -smp 4 \ + -drive if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE.fd \ + -drive "if=pflash,format=raw,file=$VARS" \ + -drive "if=virtio,file=$DISK,format=raw,cache=none" \ + -netdev user,id=n0 -device virtio-net-pci,netdev=n0 \ + -vga virtio -display gtk diff --git a/tests/interactive-qemu.sh b/tests/interactive-qemu.sh new file mode 100755 index 0000000..1855563 --- /dev/null +++ b/tests/interactive-qemu.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Launch QEMU with the latest installer ISO for INTERACTIVE testing. +# Use this when you want to drive the TUI yourself. +set -Eeuo pipefail +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT_DIR="$PROJECT_DIR/out" +ISO="${ISO:-$(ls -t "$OUT_DIR"/void-install-*.iso 2>/dev/null | grep -v TEST | head -1 || true)}" +DISK="${DISK:-$OUT_DIR/test-disk.img}" +RAM_MB="${RAM_MB:-4096}" +SMP="${SMP:-4}" + +OVMF_CODE="${OVMF_CODE:-/usr/share/OVMF/OVMF_CODE.fd}" +OVMF_VARS_TPL="${OVMF_VARS_TPL:-/usr/share/OVMF/OVMF_VARS.fd}" +[[ -r "$OVMF_CODE" ]] || { echo "no OVMF — install ovmf"; exit 1; } +[[ -r "$ISO" ]] || { echo "no ISO at $ISO — run 'make iso' first"; exit 1; } + +if [[ ! -f "$DISK" ]]; then + "$PROJECT_DIR/tests/make-test-disk.sh" "$DISK" +fi +VARS="$OUT_DIR/OVMF_VARS.interactive.fd" +[[ -f "$VARS" ]] || cp "$OVMF_VARS_TPL" "$VARS" + +exec qemu-system-x86_64 \ + -name void-installer-interactive \ + -machine q35,accel=kvm:tcg -cpu max \ + -m "$RAM_MB" -smp "$SMP" \ + -drive "if=pflash,format=raw,readonly=on,file=$OVMF_CODE" \ + -drive "if=pflash,format=raw,file=$VARS" \ + -drive "if=virtio,file=$DISK,format=raw,cache=none" \ + -cdrom "$ISO" \ + -boot menu=on \ + -netdev user,id=n0,hostfwd=tcp:127.0.0.1:2222-:22 \ + -device virtio-net-pci,netdev=n0 \ + -vga virtio -display gtk diff --git a/tests/lib/make-test-overlay.sh b/tests/lib/make-test-overlay.sh new file mode 100755 index 0000000..9a01797 --- /dev/null +++ b/tests/lib/make-test-overlay.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# Side-channel overlay used ONLY by the QEMU smoke-test harness. +# Sourced into the live ISO at /usr/local/share/installer/install.conf.d/ +# and /etc/profile.d/. Forces unattended + test-mode behaviour and enables +# sshd in the installed system so the harness can ssh in. + +set -Eeuo pipefail + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DEST="${1:?usage: $0 }" + +mkdir -p "$DEST/etc/profile.d" +cat > "$DEST/etc/profile.d/99-void-installer-test.sh" < "$DEST/usr/local/share/installer/install.conf.d/99-test.conf" <<'EOF' +# Test overrides (qemu smoke test) +SSHD_ENABLE="yes" +DEFAULT_DISK="/dev/vda" +DEFAULT_ROOT_PART="/dev/vda5" +DEFAULT_EFI_PART="/dev/vda1" +# Use the local xbps proxy on the host (10.0.2.2 inside QEMU's user net). +INSTALL_REPO_URL="http://10.0.2.2:3142/current" +# Quieter zram in tiny VM +ZRAM_SIZE_PCT="25" +EOF +chmod 0644 "$DEST/usr/local/share/installer/install.conf.d/99-test.conf" + +# Drop a public key the harness can use to ssh in as moze without a password. +# Generated below (or supplied via TEST_PUBKEY env) and added to authorized_keys +# under /etc/installer-ssh/ so postinstall.sh copies it into ~/.ssh/. +mkdir -p "$DEST/etc/installer-ssh" +if [[ -n "${TEST_PUBKEY:-}" ]]; then + echo "$TEST_PUBKEY" > "$DEST/etc/installer-ssh/authorized_keys" + chmod 0600 "$DEST/etc/installer-ssh/authorized_keys" +fi + +# Enable an autostart `sshd` already inside the live env so the harness +# can ssh into the live ISO too if needed (debug). +mkdir -p "$DEST/etc/runit/runsvdir/default" +ln -sf /etc/sv/sshd "$DEST/etc/runit/runsvdir/default/sshd" 2>/dev/null || true + +# Autologin root on ttyS0 so the installer's .bash_profile fires under +# QEMU's `-display none -serial file:...` (no tty1 attached). +mkdir -p "$DEST/etc/sv/agetty-ttyS0-autologin" +cat > "$DEST/etc/sv/agetty-ttyS0-autologin/run" <<'EOF' +#!/bin/sh +exec /sbin/agetty --autologin root --noclear -L 115200 ttyS0 vt100 +EOF +chmod 0755 "$DEST/etc/sv/agetty-ttyS0-autologin/run" +ln -sf /etc/sv/agetty-ttyS0-autologin "$DEST/etc/runit/runsvdir/default/agetty-ttyS0-autologin" + +# Make .bash_profile fire on ttyS0 AND tty1 (interactive GTK QEMU uses tty1). +mkdir -p "$DEST/root" +cat > "$DEST/root/.bash_profile" <<'EOF' +case "$(tty)" in + /dev/ttyS0|/dev/tty1) + # Atomic single-instance lock. mkdir is atomic; only ONE tty wins. + # Previously the check-then-touch race let both tty1 and ttyS0 enter, + # running install-void twice in parallel and corrupting /mnt. + if mkdir /tmp/.installer-lock 2>/dev/null; then + echo "Void Linux Installer (xps9700) — TEST MODE" + # Bring up networking via dhcpcd (live ISO doesn't autostart it). + echo " bringing up network..." + dhcpcd -b 2>/dev/null || true + for i in $(seq 1 20); do + if curl -sf --max-time 1 http://10.0.2.2:3142/ >/dev/null 2>&1; then + echo " network up after ${i}s" + break + fi + sleep 1 + done + sleep 1 + set -x + /usr/local/sbin/install-void 2>&1 | tee /tmp/installer.log + rc=${PIPESTATUS[0]} + set +x + if [ "$rc" -ne 0 ]; then + echo "===== INSTALLER FAILED rc=$rc =====" + echo "--- ip addr ---" + ip addr 2>&1 | head -20 + echo "--- ip route ---" + ip route 2>&1 + echo "--- /etc/resolv.conf ---" + cat /etc/resolv.conf 2>/dev/null + echo "--- curl proxy test ---" + curl -sv --max-time 3 http://10.0.2.2:3142/ 2>&1 | head -10 + echo "--- /tmp/installer.log (tail) ---" + tail -100 /tmp/installer.log 2>/dev/null + echo "===== END FAIL DUMP =====" + exec /bin/bash + fi + echo "Install complete. Powering off." + poweroff + fi + ;; +esac +EOF +chmod 0644 "$DEST/root/.bash_profile" + +echo ">>> test overlay staged at $DEST" diff --git a/tests/make-test-disk.sh b/tests/make-test-disk.sh new file mode 100755 index 0000000..2304a5a --- /dev/null +++ b/tests/make-test-disk.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Create a fresh test disk image that mimics the XPS 17 layout: +# p1: 150 MiB EFI vfat (with a fake EFI/Microsoft/* tree) +# p2: 128 MiB MSR +# p3: 4 GiB NTFS placeholder ("Windows", must NOT be touched) +# p5: rest btrfs "Mint" (will be wiped by installer) +# +# No root required: uses sgdisk on the raw file + mtools for FAT32. +# The installer reformats p5; p3 carries only a partition-type flag (0700). +# +# Output: a sparse raw image (QEMU format=raw). + +set -Eeuo pipefail + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT="${1:-$PROJECT_DIR/out/test-disk.img}" +SIZE="${SIZE:-32G}" + +mkdir -p "$(dirname "$OUT")" + +command -v sgdisk >/dev/null || { echo "sgdisk missing (gptfdisk)"; exit 1; } +command -v mformat >/dev/null || { echo "mformat missing (mtools)"; exit 1; } +command -v mmd >/dev/null || { echo "mmd missing (mtools)"; exit 1; } +command -v mcopy >/dev/null || { echo "mcopy missing (mtools)"; exit 1; } + +echo ">>> creating sparse raw disk ($SIZE) at $OUT" +rm -f "$OUT" +truncate -s "$SIZE" "$OUT" + +echo ">>> partitioning" +sgdisk -Z "$OUT" >/dev/null 2>&1 || true +sgdisk \ + -n 1:2048:+150M -t 1:ef00 -c 1:"EFI system partition" \ + -n 2:0:+128M -t 2:0c01 -c 2:"Microsoft reserved partition" \ + -n 3:0:+4G -t 3:0700 -c 3:"Basic data partition" \ + -n 5:0:0 -t 5:8300 -c 5:"Mint" \ + "$OUT" >/dev/null + +# Derive partition-1 byte offset + sector count from GPT metadata. +P1_START=$(sgdisk -i 1 "$OUT" | awk '/First sector:/ {print $3}') +P1_LAST=$(sgdisk -i 1 "$OUT" | awk '/Last sector:/ {print $3}') +P1_SECTORS=$(( P1_LAST - P1_START + 1 )) +P1_OFFSET=$(( P1_START * 512 )) + +echo ">>> formatting EFI partition 1 (FAT32) at byte offset $P1_OFFSET" +mformat -i "$OUT@@$P1_OFFSET" -F -T "$P1_SECTORS" -v ESP :: + +echo ">>> faking Windows EFI loader on p1" +mmd -i "$OUT@@$P1_OFFSET" ::/EFI ::/EFI/Microsoft ::/EFI/Microsoft/Boot +printf 'FAKE WINDOWS BOOTMGR\n' \ + | mcopy -i "$OUT@@$P1_OFFSET" - ::/EFI/Microsoft/Boot/bootmgfw.efi + +echo ">>> done: $OUT" diff --git a/tests/run-niri-install.sh b/tests/run-niri-install.sh new file mode 100755 index 0000000..ff1050f --- /dev/null +++ b/tests/run-niri-install.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# Build the mainline-niri ISO and install it headlessly into a separate +# disk image, isolated from the stable-cinnamon test harness. +# +# Outputs (under out/niri/): +# void-install-niri.iso test ISO with PROFILE=mainline-niri baked in +# niri-disk.img 32G raw disk receiving the install +# OVMF_VARS.fd per-VM EFI nvram +# install.serial.log full installer log +# +# After a successful install, run: +# tests/boot-niri-interactive.sh +# to launch the installed niri VM in a GUI window. + +set -Eeuo pipefail + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT="$PROJECT_DIR/out/niri" +mkdir -p "$OUT" + +QEMU="${QEMU:-qemu-system-x86_64}" +OVMF_CODE="${OVMF_CODE:-/usr/share/OVMF/OVMF_CODE.fd}" +OVMF_VARS_TPL="${OVMF_VARS_TPL:-/usr/share/OVMF/OVMF_VARS.fd}" +RAM_MB="${RAM_MB:-4096}" +SMP="${SMP:-4}" +TIMEOUT_INSTALL="${TIMEOUT_INSTALL:-3600}" + +blue() { printf '\033[34m==> %s\033[0m\n' "$*"; } + +# 1) ensure xbps proxy is up (shared with main harness) +"$PROJECT_DIR/tools/start-xbps-proxy.sh" +trap '"$PROJECT_DIR/tools/stop-xbps-proxy.sh" 2>/dev/null || true; \ + pkill -f qemu-system.*void-niri-install 2>/dev/null || true' EXIT + +# 2) build TEST overlay with PROFILE=mainline-niri +TEST_OVERLAY="$OUT/test-overlay" +TEST_PROFILE=mainline-niri \ + "$PROJECT_DIR/tests/lib/make-test-overlay.sh" "$TEST_OVERLAY" + +# 3) build ISO +ISO="$OUT/void-install-niri.iso" +if [[ ! -f "$ISO" || -n "${REBUILD_ISO:-}" ]]; then + blue "building niri ISO -> $ISO" + EXTRA_INCLUDE_DIR="$TEST_OVERLAY" \ + OUTPUT_ISO="$ISO" \ + INSTALL_REPO_URL="http://10.0.2.2:3142/current" \ + BOOT_CMDLINE="console=tty0 console=ttyS0,115200" \ + "$PROJECT_DIR/iso/build-iso.sh" +fi + +# 4) fresh disk +DISK="$OUT/niri-disk.img" +blue "creating fresh disk -> $DISK" +"$PROJECT_DIR/tests/make-test-disk.sh" "$DISK" + +# 5) per-VM EFI nvram +VARS="$OUT/OVMF_VARS.fd" +cp "$OVMF_VARS_TPL" "$VARS" + +# 6) run installer headless +SERIAL_LOG="$OUT/install.serial.log" +: > "$SERIAL_LOG" +blue "boot ISO + run installer (timeout ${TIMEOUT_INSTALL}s)" +set +e +timeout "$TIMEOUT_INSTALL" "$QEMU" \ + -name void-niri-install \ + -machine q35,accel=kvm:tcg \ + -cpu max -m "$RAM_MB" -smp "$SMP" \ + -display none -monitor none \ + -serial "file:$SERIAL_LOG" \ + -drive "if=pflash,format=raw,readonly=on,file=$OVMF_CODE" \ + -drive "if=pflash,format=raw,file=$VARS" \ + -drive "if=virtio,file=$DISK,format=raw,cache=none" \ + -cdrom "$ISO" \ + -boot order=d,menu=off \ + -netdev user,id=n0 -device virtio-net-pci,netdev=n0 \ + -no-reboot +rc=$? +set -e + +if [[ $rc -ne 0 ]]; then + echo "installer QEMU exit $rc — see $SERIAL_LOG" + tail -40 "$SERIAL_LOG" + exit "$rc" +fi + +if grep -q "INSTALLATION COMPLETE\|installation complete\|powering off" "$SERIAL_LOG"; then + blue "niri install completed; disk -> $DISK" + blue "run: tests/boot-niri-interactive.sh to boot it in a GUI window" +else + echo "WARNING: no completion marker in $SERIAL_LOG" + tail -30 "$SERIAL_LOG" + exit 1 +fi diff --git a/tests/run-qemu-test.sh b/tests/run-qemu-test.sh new file mode 100755 index 0000000..ba6918d --- /dev/null +++ b/tests/run-qemu-test.sh @@ -0,0 +1,242 @@ +#!/bin/bash +# Automated headless QEMU smoke test for the Void installer. +# +# Workflow: +# 1. Build (or reuse) a TEST variant of the ISO with UNATTENDED=1 + +# TEST_MODE=1 and our test pubkey baked in for moze@. +# 2. Build a fresh test disk image mimicking the XPS 17 layout. +# 3. Boot the ISO under QEMU/OVMF; installer runs unattended; VM powers off. +# 4. Boot the installed disk; ssh in as moze; run smoke checks. + +set -Eeuo pipefail + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT_DIR="$PROJECT_DIR/out" +LOG_DIR="$OUT_DIR/qemu-logs" +mkdir -p "$OUT_DIR" "$LOG_DIR" + +QEMU="${QEMU:-qemu-system-x86_64}" +OVMF_CODE="${OVMF_CODE:-}" +OVMF_VARS_TPL="${OVMF_VARS_TPL:-}" +RAM_MB="${RAM_MB:-4096}" +SMP="${SMP:-4}" +SSH_PORT="${SSH_PORT:-2222}" +TIMEOUT_INSTALL="${TIMEOUT_INSTALL:-3600}" +TIMEOUT_BOOT="${TIMEOUT_BOOT:-300}" +ACCEL="${ACCEL:-kvm:tcg}" + +# Auto-detect OVMF paths. +for c in /usr/share/OVMF/OVMF_CODE.fd /usr/share/edk2-ovmf/x64/OVMF_CODE.fd \ + /usr/share/edk2/x64/OVMF_CODE.fd /usr/share/qemu/OVMF_CODE.fd; do + [[ -z "$OVMF_CODE" && -r "$c" ]] && OVMF_CODE="$c" +done +for v in /usr/share/OVMF/OVMF_VARS.fd /usr/share/edk2-ovmf/x64/OVMF_VARS.fd \ + /usr/share/edk2/x64/OVMF_VARS.fd /usr/share/qemu/OVMF_VARS.fd; do + [[ -z "$OVMF_VARS_TPL" && -r "$v" ]] && OVMF_VARS_TPL="$v" +done +[[ -r "$OVMF_CODE" ]] || { echo "OVMF_CODE not found"; exit 1; } +[[ -r "$OVMF_VARS_TPL" ]] || { echo "OVMF_VARS not found"; exit 1; } + +red() { printf '\033[31m%s\033[0m\n' "$*" >&2; } +green() { printf '\033[32m%s\033[0m\n' "$*"; } +blue() { printf '\033[34m==> %s\033[0m\n' "$*"; } + +# ---------- 0) start xbps caching proxy ---------- +blue "starting XBPS caching proxy" +"$PROJECT_DIR/tools/start-xbps-proxy.sh" +PIDFILE="$OUT_DIR/qemu.pid" +cleanup_all() { + # Kill any QEMU started by this run (pidfile + name fallback). + [[ -f "$PIDFILE" ]] && kill "$(cat "$PIDFILE")" 2>/dev/null || true + rm -f "$PIDFILE" + # Defensive: kill any lingering qemu by our unique -name tags. + pkill -f 'qemu-system.*void-install(er-test|ed-test)' 2>/dev/null || true + "$PROJECT_DIR/tools/stop-xbps-proxy.sh" 2>/dev/null || true +} +trap cleanup_all EXIT INT TERM + +# ---------- 1) test ssh keypair ---------- +TEST_KEY="$OUT_DIR/id_test" +if [[ ! -f "$TEST_KEY" ]]; then + blue "generating test ssh keypair" + ssh-keygen -t ed25519 -N '' -C 'qemu-test' -f "$TEST_KEY" >/dev/null +fi +TEST_PUBKEY=$(cat "${TEST_KEY}.pub") +export TEST_PUBKEY + +# ---------- 2) build TEST ISO ---------- +TEST_ISO="$OUT_DIR/void-install-TEST.iso" +TEST_OVERLAY_DIR="$OUT_DIR/test-overlay" +"$PROJECT_DIR/tests/lib/make-test-overlay.sh" "$TEST_OVERLAY_DIR" + +if [[ ! -f "$TEST_ISO" || -n "${REBUILD_ISO:-}" ]]; then + blue "building test ISO -> $TEST_ISO" + EXTRA_INCLUDE_DIR="$TEST_OVERLAY_DIR" \ + OUTPUT_ISO="$TEST_ISO" \ + INSTALL_REPO_URL="http://10.0.2.2:3142/current" \ + BOOT_CMDLINE="console=tty0 console=ttyS0,115200" \ + "$PROJECT_DIR/iso/build-iso.sh" +else + blue "reusing cached test ISO $TEST_ISO (set REBUILD_ISO=1 to rebuild)" +fi + +# ---------- 3) test disk ---------- +DISK="$OUT_DIR/test-disk.img" +blue "creating fresh test disk -> $DISK" +"$PROJECT_DIR/tests/make-test-disk.sh" "$DISK" + +# ---------- 4) OVMF VARS copy ---------- +VARS="$OUT_DIR/OVMF_VARS.fd" +cp "$OVMF_VARS_TPL" "$VARS" + +# ---------- 5) install phase ---------- +blue "boot ISO + run installer (timeout ${TIMEOUT_INSTALL}s)" +SERIAL_LOG="$LOG_DIR/install.serial.log" +: > "$SERIAL_LOG" + +set +e +timeout "$TIMEOUT_INSTALL" "$QEMU" \ + -name void-installer-test \ + -machine q35,accel="$ACCEL" \ + -cpu max \ + -m "$RAM_MB" \ + -smp "$SMP" \ + -display none \ + -monitor none \ + -serial "file:$SERIAL_LOG" \ + -drive "if=pflash,format=raw,readonly=on,file=$OVMF_CODE" \ + -drive "if=pflash,format=raw,file=$VARS" \ + -drive "if=virtio,file=$DISK,format=raw,cache=none" \ + -cdrom "$TEST_ISO" \ + -boot order=d,menu=off \ + -netdev user,id=n0 -device virtio-net-pci,netdev=n0 \ + -no-reboot +RC=$? +set -e +blue "install QEMU exited rc=$RC" + +if ! grep -q 'Installation complete' "$SERIAL_LOG"; then + echo "------ tail of $SERIAL_LOG ------" >&2 + tail -200 "$SERIAL_LOG" >&2 || true + red "installer did not report 'Installation complete'"; exit 1 +fi +green "installer reported success" + +# ---------- 6) boot installed system ---------- +blue "boot installed system, ssh on 127.0.0.1:$SSH_PORT" +BOOT_LOG="$LOG_DIR/boot.serial.log" +: > "$BOOT_LOG" + +"$QEMU" \ + -name void-installed-test \ + -machine q35,accel="$ACCEL" \ + -cpu max \ + -m "$RAM_MB" \ + -smp "$SMP" \ + -display none \ + -serial "file:$BOOT_LOG" \ + -drive "if=pflash,format=raw,readonly=on,file=$OVMF_CODE" \ + -drive "if=pflash,format=raw,file=$VARS" \ + -drive "if=virtio,file=$DISK,format=raw,cache=none" \ + -netdev "user,id=n0,hostfwd=tcp:127.0.0.1:$SSH_PORT-:22" \ + -device virtio-net-pci,netdev=n0 \ + -daemonize \ + -pidfile "$PIDFILE" + +blue "waiting for sshd" +SSH_READY=0 +for i in $(seq 1 "$TIMEOUT_BOOT"); do + if ssh -i "$TEST_KEY" -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=2 -o BatchMode=yes \ + -p "$SSH_PORT" moze@127.0.0.1 true 2>/dev/null; then + SSH_READY=1 + green "ssh reachable after ${i}s" + break + fi + sleep 1 +done +if [[ $SSH_READY -ne 1 ]]; then + tail -200 "$BOOT_LOG" >&2 || true + red "sshd never came up" + exit 1 +fi + +# ---------- 7) smoke checks ---------- +blue "running smoke tests" +SSHCMD=(ssh -i "$TEST_KEY" -o StrictHostKeyChecking=no + -o UserKnownHostsFile=/dev/null -p "$SSH_PORT" moze@127.0.0.1) + +FAILED=0 +assert() { + local desc="$1"; shift + if "${SSHCMD[@]}" "$@" >/dev/null 2>&1; then + green " ok $desc" + else + red " FAIL $desc" + FAILED=$((FAILED+1)) + fi +} + +assert "/ on btrfs subvol=@" "findmnt -no FSTYPE,OPTIONS / | grep -q 'subvol=/@'" +assert "user moze in wheel" "id -nG moze | tr ' ' '\\n' | grep -qx wheel" +assert "user moze in docker" "id -nG moze | tr ' ' '\\n' | grep -qx docker" +assert "sudo via wheel" "sudo -n -l | grep -q 'ALL'" +assert "cinnamon-session present" "command -v cinnamon-session" +assert "lightdm enabled" "test -L /var/service/lightdm" +assert "docker enabled" "test -L /var/service/docker" +assert "vscode (code) installed" "command -v code" +assert "nvidia kmod metadata" "modinfo nvidia" +assert ".ssh dir mode 700" "test \$(stat -c '%a' /home/moze/.ssh) = 700" +assert "id_github private key" "test -f /home/moze/.ssh/id_github" +assert "ssh config copied" "test -f /home/moze/.ssh/config" +assert "EFI vfat mounted" "findmnt -no FSTYPE /boot/efi | grep -q vfat" +assert "EFI/Microsoft preserved" "test -f /boot/efi/EFI/Microsoft/Boot/bootmgfw.efi" +assert "GRUB has Void entry" "sudo -n grep -q -i 'Void' /boot/grub/grub.cfg" +assert "nix-daemon running" "pgrep -x nix-daemon" +assert "zram swap active" "swapon --show=NAME --noheadings | grep -q '^/dev/zram'" +assert "hostname is xps9700" "test \$(hostname) = xps9700" +assert "timezone is Europe/Zurich" "readlink /etc/localtime | grep -q 'Europe/Zurich'" +assert "keymap ch-fr_nodeadkeys" "grep -q 'ch-fr_nodeadkeys' /etc/rc.conf" +assert "alacritty installed" "command -v alacritty" +assert "no firefox installed" "! command -v firefox" +assert "gruvbox theme present" "ls -d /usr/share/themes/Gruvbox* >/dev/null 2>&1" +assert "gruvbox-dark exact" "test -d /usr/share/themes/Gruvbox-Dark" +assert "gruvbox icons present" "ls -d /usr/share/icons/Gruvbox-Plus-Dark >/dev/null 2>&1" +assert "bibata cursor present" "test -d /usr/share/icons/Bibata-Modern-Ice" +assert "dconf cursor=bibata" "grep -q \"cursor-theme='Bibata-Modern-Ice'\" /etc/dconf/db/local.d/00-cinnamon" +assert "dconf gtk=Gruvbox-Dark" "grep -q \"gtk-theme='Gruvbox-Dark'\" /etc/dconf/db/local.d/00-cinnamon" +assert "wallpapers deployed" "test -f /usr/share/backgrounds/void-installer/pxfuel.jpg" +assert "user .bashrc deployed" "grep -q 'NVM_DIR' /home/moze/.bashrc" +assert "vscode settings deployed" "test -f /home/moze/.config/Code/User/settings.json" +assert "X11 keymap pinned ch(fr)" "grep -q 'XkbLayout.*ch' /etc/X11/xorg.conf.d/00-keyboard.conf" +assert "dconf cinnamon defaults" "test -f /etc/dconf/db/local.d/00-cinnamon" +assert "first-login script staged" "test -x /usr/local/libexec/first-login.sh" +assert "first-login autostart" "test -f /etc/xdg/autostart/void-installer-first-login.desktop" +assert "local-bin profile.d" "test -f /etc/profile.d/local-bin.sh" +assert "claude installer used" "grep -q 'claude.ai/install.sh' /usr/local/libexec/first-login.sh" +assert "nix unfree allowed" "grep -q 'NIXPKGS_ALLOW_UNFREE' /usr/local/libexec/first-boot-nix.sh" +assert "vscode-extensions list" "test -f /etc/installer-vscode-extensions.txt" +assert "cups installed" "command -v cupsd" +assert "cups service enabled" "test -L /etc/runit/runsvdir/default/cupsd" +assert "timeshift installed" "command -v timeshift" +assert "flameshot installed" "command -v flameshot" +assert "libinput-gestures present" "command -v libinput-gestures" +assert "gestures default config" "test -f /etc/skel/.config/libinput-gestures.conf" +assert "btrfs snapshot hook" "test -x /usr/local/sbin/xbps-pre-upgrade-snapshot.sh" +assert "xbps-install wrapper" "test -x /usr/local/bin/xbps-install" +assert "void-upgrade GUI" "test -x /usr/local/bin/void-upgrade-gui" +assert "void-upgrade .desktop" "test -f /usr/share/applications/void-upgrade.desktop" +assert "Print Screen -> flameshot" "grep -q 'Print' /etc/dconf/db/local.d/00-cinnamon" +assert "tray applets configured" "grep -q 'systray@cinnamon.org' /etc/dconf/db/local.d/00-cinnamon" +assert "Nemo VS Code action" "test -f /usr/share/nemo/actions/open-vscode.nemo_action" + +cleanup_all +echo +if [[ $FAILED -eq 0 ]]; then + green "ALL SMOKE TESTS PASSED" + exit 0 +else + red "$FAILED smoke test(s) failed" + exit 1 +fi diff --git a/tools/start-xbps-proxy.sh b/tools/start-xbps-proxy.sh new file mode 100755 index 0000000..a0db85f --- /dev/null +++ b/tools/start-xbps-proxy.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Start the XBPS caching proxy in the background. +# Idempotent: does nothing if already running. +set -Eeuo pipefail + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CACHE_DIR="${XBPS_CACHE_DIR:-$PROJECT_DIR/cache/xbps-pkgs}" +PORT="${XBPS_PROXY_PORT:-3142}" +PIDFILE="${XBPS_PROXY_PIDFILE:-$PROJECT_DIR/out/xbps-proxy.pid}" +LOGFILE="${XBPS_PROXY_LOG:-$PROJECT_DIR/out/xbps-proxy.log}" + +mkdir -p "$(dirname "$PIDFILE")" "$CACHE_DIR" + +# Already running? +if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then + echo ">>> XBPS proxy already running (pid $(cat "$PIDFILE"), port $PORT)" + exit 0 +fi + +XBPS_CACHE_DIR="$CACHE_DIR" \ +XBPS_PROXY_PORT="$PORT" \ + python3 "$PROJECT_DIR/tools/xbps-proxy.py" > "$LOGFILE" 2>&1 & +echo $! > "$PIDFILE" + +# Wait for the socket to open (up to 5 s). +for i in $(seq 1 20); do + if curl -sf --max-time 1 "http://127.0.0.1:$PORT/" >/dev/null 2>&1; then + break + fi + sleep 0.25 +done + +echo ">>> XBPS proxy started (pid $(cat "$PIDFILE"), port $PORT)" +echo " cache : $CACHE_DIR" +echo " log : $LOGFILE" diff --git a/tools/stop-xbps-proxy.sh b/tools/stop-xbps-proxy.sh new file mode 100755 index 0000000..1937feb --- /dev/null +++ b/tools/stop-xbps-proxy.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Stop the XBPS caching proxy. +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PIDFILE="${XBPS_PROXY_PIDFILE:-$PROJECT_DIR/out/xbps-proxy.pid}" + +if [[ -f "$PIDFILE" ]]; then + PID="$(cat "$PIDFILE")" + if kill -0 "$PID" 2>/dev/null; then + kill "$PID" + echo ">>> XBPS proxy stopped (pid $PID)" + else + echo ">>> XBPS proxy not running (stale pidfile)" + fi + rm -f "$PIDFILE" +else + echo ">>> no XBPS proxy pidfile found" +fi diff --git a/tools/xbps-proxy.py b/tools/xbps-proxy.py new file mode 100644 index 0000000..87eb192 --- /dev/null +++ b/tools/xbps-proxy.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +""" +XBPS caching HTTP reverse proxy. +Cache-first, web fallback. Caches .xbps packages forever; never caches +-repodata files (must stay fresh for xbps to see new package versions). + +Usage: + XBPS_CACHE_DIR=./cache/xbps-pkgs \\ + XBPS_UPSTREAM=https://repo-default.voidlinux.org \\ + XBPS_PROXY_PORT=3142 \\ + python3 tools/xbps-proxy.py +""" +import http.server +import socket +import time +import urllib.request +import urllib.error +import os +import sys +from pathlib import Path + +# Force IPv4 only — many hosts have no working IPv6 route to voidlinux mirrors, +# which surfaces as `[Errno 101] Network is unreachable` mid-download. Pin +# socket.getaddrinfo to AF_INET so urllib never tries an AAAA record. +_orig_getaddrinfo = socket.getaddrinfo + + +def _ipv4_only(host, port, family=0, type=0, proto=0, flags=0): # noqa: A002 + return _orig_getaddrinfo(host, port, socket.AF_INET, type, proto, flags) + + +socket.getaddrinfo = _ipv4_only + +UPSTREAM = os.environ.get("XBPS_UPSTREAM", "https://repo-default.voidlinux.org") +CACHE_DIR = Path(os.environ.get("XBPS_CACHE_DIR", "./cache/xbps-pkgs")).resolve() +PORT = int(os.environ.get("XBPS_PROXY_PORT", "3142")) +BIND = os.environ.get("XBPS_PROXY_BIND", "0.0.0.0") + +# Suffixes that must always be fetched fresh (small metadata files). +ALWAYS_FRESH = ("-repodata",) + +CACHE_DIR.mkdir(parents=True, exist_ok=True) + + +def should_cache(path: str) -> bool: + for suffix in ALWAYS_FRESH: + if path.endswith(suffix): + return False + return True + + +class Handler(http.server.BaseHTTPRequestHandler): + + def do_GET(self): + self._handle(head_only=False) + + def do_HEAD(self): + self._handle(head_only=True) + + def _handle(self, head_only: bool): + path = self.path.lstrip("/") + if not path: + self.send_error(404, "empty path") + return + fpath = CACHE_DIR / path + hit = should_cache(path) and fpath.is_file() + + if hit: + self._serve_cached(fpath, head_only) + print(f"[HIT ] {path}", flush=True) + return + + upstream_url = f"{UPSTREAM}/{path}" + print(f"[MISS] {path} <- {upstream_url}", flush=True) + last_exc = None + for attempt in range(3): + try: + self._fetch_upstream(path, fpath, upstream_url, head_only) + return + except urllib.error.HTTPError as exc: + print(f"[ERR ] {path} HTTP {exc.code}", flush=True) + try: + self.send_error(exc.code, exc.reason) + except Exception: # noqa: BLE001 + pass + return + except (urllib.error.URLError, OSError) as exc: + last_exc = exc + if attempt < 2: + print(f"[WARN] {path} attempt {attempt + 1} failed: {exc}; retrying", flush=True) + time.sleep(1 + attempt) + print(f"[ERR ] {path} {last_exc}", flush=True) + try: + self.send_error(503, str(last_exc)) + except Exception: # noqa: BLE001 + pass + + def _fetch_upstream(self, path, fpath, upstream_url, head_only): + req = urllib.request.Request(upstream_url) + req.get_method = lambda: "HEAD" if head_only else "GET" + with urllib.request.urlopen(req, timeout=120) as resp: + ctype = resp.headers.get("Content-Type", "application/octet-stream") + clen = resp.headers.get("Content-Length") + self.send_response(200) + self.send_header("Content-Type", ctype) + if clen: + self.send_header("Content-Length", clen) + self.end_headers() + if head_only: + return + + cache_this = should_cache(path) + tmp = None + tmp_fh = None + if cache_this: + fpath.parent.mkdir(parents=True, exist_ok=True) + tmp = fpath.with_suffix(fpath.suffix + ".tmp") + tmp_fh = open(tmp, "wb") + try: + while True: + chunk = resp.read(64 * 1024) + if not chunk: + break + self.wfile.write(chunk) + if tmp_fh is not None: + tmp_fh.write(chunk) + finally: + if tmp_fh is not None: + tmp_fh.close() + if cache_this and tmp is not None and tmp.exists() and not fpath.is_dir(): + tmp.rename(fpath) + + def _serve_cached(self, fpath: Path, head_only: bool): + size = fpath.stat().st_size + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Length", str(size)) + self.end_headers() + if head_only: + return + with open(fpath, "rb") as fh: + while True: + chunk = fh.read(64 * 1024) + if not chunk: + break + self.wfile.write(chunk) + + # Silence default access log — we do our own. + def log_message(self, *_): + pass + + +if __name__ == "__main__": + server = http.server.ThreadingHTTPServer((BIND, PORT), Handler) + print(f"XBPS proxy : {BIND}:{PORT} -> {UPSTREAM}", flush=True) + print(f"Cache dir : {CACHE_DIR}", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nProxy stopped.", flush=True)