feat: initial commit — void-installer multi-profile (stable-cinnamon + mainline-niri)

This commit is contained in:
mozempk
2026-04-22 23:53:16 +02:00
commit a16ac37d20
35 changed files with 3902 additions and 0 deletions

33
.gitignore vendored Normal file
View File

@@ -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

58
Makefile Normal file
View File

@@ -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

195
README.md Normal file
View File

@@ -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 (~515 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 <app>`.
---
## 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.

91
config/install.conf Normal file
View File

@@ -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: "<subvol-name>:<mountpoint>"
# ("@" 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}"

19
config/packages.live.list Normal file
View File

@@ -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

View File

@@ -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" <<EOF
[terminal]
vt = 1
[default_session]
command = "tuigreet --time --remember --cmd niri-session"
user = "_greeter"
EOF
log "greetd configured for niri-session"
}
_niri_install_noctalia() {
local TARGET="$1"
# Third-party Void repo that ships noctalia-shell + noctalia-qs.
# Source: https://docs.noctalia.dev/getting-started/installation/#void
install -d -m 0755 "$TARGET/etc/xbps.d"
cat > "$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"

View File

@@ -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.

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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.<oldfs> /dev/nvme0n1p5`.
- **Restore the GRUB menu only**: in the live USB,
`mount /dev/<EFI> /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 <name>`.
---
## 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.

77
installer/first-login.sh Normal file
View File

@@ -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"

118
installer/install.sh Executable file
View File

@@ -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 "$@"

123
installer/lib/bootstrap.sh Executable file
View File

@@ -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" <<EOF
repository=$REPO_URL
EOF
log "enabling extra repos: ${EXTRA_REPOS[*]}"
XBPS_ARCH="$ARCH" xbps-install -y -S -r "$TARGET" \
-R "$_repo" -R "$_nonfree" -R "$_multilib" -R "$_multilib_nonfree" \
void-repo-nonfree void-repo-multilib void-repo-multilib-nonfree
# Sync from all newly enabled repos.
XBPS_ARCH="$ARCH" xbps-install -y -S -r "$TARGET" \
-R "$_repo" -R "$_nonfree" -R "$_multilib" -R "$_multilib_nonfree"
log "installing extra packages from $PKG_LIST_FILE"
XBPS_ARCH="$ARCH" xbps-install -y -r "$TARGET" \
-R "$_repo" -R "$_nonfree" -R "$_multilib" -R "$_multilib_nonfree" \
"${pkg_list[@]}"
# Ensure the installed system's xbps config points only at real repos.
cat > "$TARGET/etc/xbps.d/00-repository-main.conf" <<EOF
repository=$REPO_URL
EOF
ok "all xbps packages installed (proxy removed from target xbps.d)"
}
generate_fstab() {
step "Generating /etc/fstab"
local TARGET="${TARGET:-/mnt}"
if command -v xgenfstab >/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
}

98
installer/lib/common.sh Executable file
View File

@@ -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 <<EOF
$user:$password
EOF
return
}
# usermod -p writes the hash directly into /etc/shadow and unlocks.
chroot "$target" /usr/bin/env -i \
HOME=/root TERM="${TERM:-linux}" \
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
usermod -p "$hash" "$user"
}
load_secrets() {
local f="${1:-/etc/installer-secrets.env}"
if [[ -r "$f" ]]; then
# shellcheck disable=SC1090
source "$f"
log "loaded secrets from $f"
else
warn "no secrets file at $f; passwords must be set in env"
fi
: "${USER_PASSWORD:?USER_PASSWORD missing}"
: "${ROOT_PASSWORD:?ROOT_PASSWORD missing}"
}

View File

@@ -0,0 +1,432 @@
#!/bin/bash
# User-environment customizations: themes, icons, wallpapers, dotfiles,
# vscode config, cinnamon dconf defaults, default terminal, keymap.
# Reads pre-baked overlay from /etc/installer-overlay/ on the live ISO.
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
OVERLAY_SRC="${OVERLAY_SRC:-/etc/installer-overlay}"
install_customizations() {
step "Installing customizations (themes / icons / wallpapers / dotfiles)"
local TARGET="${TARGET:-/mnt}"
if [[ ! -d "$OVERLAY_SRC" ]]; then
warn "no overlay at $OVERLAY_SRC; skipping customizations"
return 0
fi
_deploy_themes "$TARGET"
_deploy_icons "$TARGET"
_deploy_wallpapers "$TARGET"
_deploy_user_dotfiles "$TARGET"
_deploy_vscode_config "$TARGET"
_deploy_first_login "$TARGET"
_set_default_terminal "$TARGET"
_install_gestures "$TARGET"
_install_snapshot_hook "$TARGET"
_install_upgrade_applet "$TARGET"
# Cinnamon/X11-specific helpers — only run on the cinnamon profile.
if [[ "${DESKTOP:-cinnamon}" == "cinnamon" ]]; then
_write_dconf_defaults "$TARGET"
_install_nemo_actions "$TARGET"
fi
# Profile-specific hooks (e.g. niri KDL config, waybar, greetd).
if declare -F run_profile_customizations >/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" <<EOF
# Cinnamon system-wide defaults — generated by void-installer.
[org/cinnamon/desktop/interface]
gtk-theme='${GTK_THEME}'
icon-theme='${ICON_THEME}'
cursor-theme='${CURSOR_THEME}'
[org/cinnamon/desktop/wm/preferences]
theme='${GTK_THEME}'
[org/cinnamon/theme]
name='${GTK_THEME}'
[org/cinnamon/desktop/background]
picture-uri='file:///usr/share/backgrounds/void-installer/${wallpaper}'
picture-options='zoom'
[org/gnome/desktop/background]
picture-uri='file:///usr/share/backgrounds/void-installer/${wallpaper}'
picture-options='zoom'
[org/gnome/desktop/input-sources]
xkb-options=['']
sources=[('xkb', 'ch+fr')]
[org/cinnamon/desktop/keybindings]
custom-list=['custom0', 'custom1']
[org/cinnamon/desktop/keybindings/custom-keybindings/custom0]
name='Open Terminal'
command='${DEFAULT_TERMINAL:-alacritty}'
binding=['<Primary><Alt>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" <<EOF
Section "InputClass"
Identifier "system-keyboard"
MatchIsKeyboard "on"
Option "XkbLayout" "ch"
Option "XkbVariant" "fr"
EndSection
EOF
log "X11 keymap pinned: ch(fr)"
}
_install_gestures() {
local TARGET="$1"
# System-wide default libinput-gestures config (workspace switching, overview, etc.)
install -d -m 0755 "$TARGET/etc/skel/.config"
cat > "$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"
}

56
installer/lib/grub.sh Executable file
View File

@@ -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"
}

130
installer/lib/partition.sh Executable file
View File

@@ -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
}

351
installer/lib/postinstall.sh Executable file
View File

@@ -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" <<EOF
127.0.0.1 localhost
::1 localhost
127.0.1.1 $HOSTNAME.localdomain $HOSTNAME
EOF
# ----- rc.conf -----
cat > "$TARGET/etc/rc.conf" <<EOF
KEYMAP="$KEYMAP"
HARDWARECLOCK="$HARDWARECLOCK"
TIMEZONE="$TIMEZONE"
EOF
# ----- timezone (also create symlink for systems checking it) -----
run_chroot "ln -sf /usr/share/zoneinfo/$TIMEZONE /etc/localtime"
# ----- locales (glibc) -----
if [[ -f "$TARGET/etc/default/libc-locales" ]]; then
sed -i "s/^#\($LOCALE.*\)/\1/" "$TARGET/etc/default/libc-locales"
run_chroot "xbps-reconfigure -f glibc-locales"
fi
cat > "$TARGET/etc/locale.conf" <<EOF
LANG=$LANG
LC_ALL=$LANG
EOF
# ----- vconsole (for early TTY keymap) -----
cat > "$TARGET/etc/vconsole.conf" <<EOF
KEYMAP=$KEYMAP
EOF
ok "locale / keymap / tz configured"
}
configure_users() {
step "Creating users"
local TARGET="${TARGET:-/mnt}"
# ----- root password (chpasswd via stdin — no shell expansion of $) -----
set_chroot_password root "$ROOT_PASSWORD"
# ----- ensure groups exist -----
run_chroot "groupadd -f docker"
run_chroot "groupadd -f plugdev"
# ----- create user (idempotent: tolerate 'already exists', fail on real errors) -----
if ! run_chroot "id -u $USERNAME >/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 <app>')"
}
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" <<EOF
ALGO=zstd
PERCENT=$ZRAM_SIZE_PCT
PRIORITY=100
EOF
ok "zram configured at $ZRAM_SIZE_PCT% RAM"
}
configure_nix() {
[[ "${ENABLE_NIX:-yes}" == "yes" ]] || return 0
step "Configuring nix multi-user (daemon mode)"
local TARGET="${TARGET:-/mnt}"
# Void's `nix` xbps package installs to /usr and ships a runit service.
install -d -m 0755 "$TARGET/etc/nix"
cat > "$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" <<EOF
#!/bin/bash
set -e
mark=/var/lib/first-boot-nix.done
[[ -f "\$mark" ]] && exit 0
# Wait for nix-daemon to be available.
# The Void package puts the socket at /var/nix/daemon-socket/nix-daemon.sock
# (NOT /nix/var/nix/...).
for _ in \$(seq 1 30); do
[[ -S /var/nix/daemon-socket/nix-daemon.sock ]] && break
sleep 1
done
if [[ ! -S /var/nix/daemon-socket/nix-daemon.sock ]]; then
echo "nix-daemon not available; aborting first-boot nix install" >&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"
}

53
installer/lib/profiles.sh Normal file
View File

@@ -0,0 +1,53 @@
#!/bin/bash
# Profile loading. PROFILE env var (default: stable-cinnamon) selects which
# config/profiles/<name>/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 <profile>/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
}

77
installer/lib/tui.sh Executable file
View File

@@ -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
}

19
iso/Dockerfile Normal file
View File

@@ -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

61
iso/_inner-build.sh Executable file
View File

@@ -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

296
iso/build-iso.sh Executable file
View File

@@ -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=<name>.
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" <<MOTD_EOF
Void Linux installer for $HOSTNAME (XPS 17 9700)
Run: install-void
Logs: /var/log/void-installer.log
MOTD_EOF
if [[ -n "${EXTRA_INCLUDE_DIR:-}" && -d "$EXTRA_INCLUDE_DIR" ]]; then
echo ">>> 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"

View File

@@ -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
}

View File

@@ -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"

20
tests/boot-niri-interactive.sh Executable file
View File

@@ -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

34
tests/interactive-qemu.sh Executable file
View File

@@ -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

107
tests/lib/make-test-overlay.sh Executable file
View File

@@ -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 <out-dir>}"
mkdir -p "$DEST/etc/profile.d"
cat > "$DEST/etc/profile.d/99-void-installer-test.sh" <<EOF
# Auto-run installer non-interactively when test ISO boots.
export UNATTENDED=1
export TEST_MODE=1
export PROFILE="${TEST_PROFILE:-stable-cinnamon}"
EOF
chmod 0644 "$DEST/etc/profile.d/99-void-installer-test.sh"
mkdir -p "$DEST/usr/local/share/installer/install.conf.d"
cat > "$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"

53
tests/make-test-disk.sh Executable file
View File

@@ -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"

94
tests/run-niri-install.sh Executable file
View File

@@ -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

242
tests/run-qemu-test.sh Executable file
View File

@@ -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

35
tools/start-xbps-proxy.sh Executable file
View File

@@ -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"

19
tools/stop-xbps-proxy.sh Executable file
View File

@@ -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

160
tools/xbps-proxy.py Normal file
View File

@@ -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)