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

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
}