NoSignal-OS/nosignal.sh
28allday 19fd794b6b NoSignal — fully-offline Arch → Hyprland desktop installer
Single-script builder (nosignal.sh) that turns a stock Arch Linux ISO into a
fully-offline installer for a themed Hyprland + caelestia (Quickshell) desktop:
matching SDDM greeter, Btrfs/Limine bootable snapshots, chwd-style GPU
detection, and a curated "os updates" layer (keybind cheatsheet, settings
panels, system polish, on-box management skill). See README.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:03:10 +01:00

2366 lines
117 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
#
# nosignal.sh — one script: turn a stock Arch ISO into a graphical Arch desktop
# installer ISO. The installed system boots into SDDM (themed to match the
# desktop — layer changes 16+19; was greetd+tuigreet) and runs Hyprland,
# themed with the caelestia dotfiles
# (https://github.com/caelestia-dots/caelestia).
#
# Architecture (Phase 3 — FULLY OFFLINE): the ISO bundles EVERYTHING needed
# for the install. At build time, this script:
# * builds the caelestia AUR packages (shell/CLI/meta + quickshell-git and
# friends) in a devtools clean chroot on the build host,
# * downloads the complete pacstrap dependency tree (including every GPU
# driver variant and the prebuilt Limine snapshot tools from the omarchy
# repo) into a local pacman repo,
# * vendors the caelestia dotfiles + quickshell-overview as pinned tarballs,
# and places all of it on the ISO9660 (outside the squashfs, visible to the
# live environment at /run/archiso/bootmnt/nosignal). The installer pacstraps
# entirely from that file:// repo and lays down the whole desktop — dotfiles,
# theming, workspace overview, Btrfs+Limine snapshots — at INSTALL TIME in the
# chroot. No network is needed at install or on first boot: the machine boots
# straight into the themed SDDM greeter and a fully themed desktop. (The old
# first-boot build stage — paru bootstrap, nmtui gate, tty1 autologin — is gone.)
#
# Network IS needed on the BUILD host (AUR builds + repo download), and the
# Wi-Fi prompt remains in the installer as an OPTIONAL step purely so the
# credentials can be persisted into the installed system.
#
# NOTE: this is the *ISO builder*. It bundles and installs the upstream
# caelestia dotfiles; it is not affiliated with that project.
#
# Usage:
# 1. Drop a stock Arch ISO (https://archlinux.org/download/, file starts
# with 'archlinux-') into the same folder as this script.
# 2. ./nosignal.sh
# 3. Output: nosignal-arch-YYYYMMDD.iso (Ventoy-compatible, dd-bootable).
#
# The offline payload is cached in ./offline/ — subsequent builds reuse the
# built AUR packages and previously downloaded repo packages. Delete that
# folder (or individual .built-* stamps in offline/aur/) to force a rebuild.
#
# Dependencies (build host): xorriso, squashfs-tools, git, sha512sum, sudo,
# devtools (mkarchroot/makechrootpkg), pacman-contrib (paccache).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
WORK="$SCRIPT_DIR/work"
OFFLINE="$SCRIPT_DIR/offline"
INVOKING_USER="${SUDO_USER:-$(id -un)}"
INVOKING_GROUP="$(id -gn "$INVOKING_USER")"
OUT_ISO="$SCRIPT_DIR/nosignal-arch-$(date +%Y%m%d).iso"
# ---------------------------------------------------------------- pins ------
# The validated combination (VM-certified): caelestia dotfiles + the AUR
# release PKGBUILDs current at that commit, quickshell pinned to the commit
# the shell was validated against, and the overview sidecar pin.
CAELESTIA_DOTS_REPO="https://github.com/caelestia-dots/caelestia.git"
CAELESTIA_DOTS_COMMIT="757b7f26b8e637754ddd498671360dd6ba7e89ef"
OVERVIEW_REPO="https://github.com/Shanu-Kumawat/quickshell-overview.git"
OVERVIEW_COMMIT="74918ba66e0714017a6a5bfb9bf2affa66dabbc1"
QUICKSHELL_GIT_COMMIT="b66495f" # short hash is fine for makepkg #commit=
# NoSignal's fork of the caelestia shell — we BUILD this instead of the upstream
# caelestia-shell so the interface/layout is under our control and never gets
# overwritten by an upstream update (the package provides/conflicts caelestia-shell
# and is pinned here, so pacman/yay never replaces it). Updates are deliberate:
# advance the fork's `nosignal` branch, bump the pin, rebuild. The fork's
# packaging/PKGBUILD builds the shell from this exact commit.
NOSIGNAL_SHELL_REPO="https://github.com/28allday/nosignal-shell.git"
NOSIGNAL_SHELL_COMMIT="0ae859311310661555dae089c91f2c0bfaaec10b"
# OS theme wallpapers — the user's own Moebius-style generated art (11 pieces,
# 2944x1648, repo-local in assets/wallpapers/; replaced the third-party Last
# Horizon set 2026-06-11, which also clears the redistribution-rights concern).
# The desktop palette is generated FROM the default wallpaper by caelestia's
# dynamic Material scheme at install time.
WALLPAPERS_DIR="$SCRIPT_DIR/assets/wallpapers"
# foam-sea since change 18 (was wanderer.png). Build-time default only — no
# migration, so existing installs keep whatever the user picked.
DEFAULT_WALLPAPER="foam-sea.png"
# omarchy-send release binary (LocalSend-compatible LAN file transfer TUI).
OMARCHY_SEND_REPO="28allday/omarchy-send"
OMARCHY_SEND_VERSION="v0.1.11"
# NoSignal layer — the "os updates" round developed and live-tested on real
# hardware (keybind cheatsheet, bash default shell, nosignal-update, yay+Shelly
# store, Omarchy keybindings, omadots polish, hyprmoncfg, settings Updates
# page, system polish). The tree is vendored verbatim onto the ISO and baked
# into ~/.local/share/nosignal at install time. Dev-only docs (changelog,
# builder/test notes) are kept unshipped in the repo's dev-docs/ folder.
NOSIGNAL_LAYER_DIR="$SCRIPT_DIR/os updates"
# LazyVim starter (omadots' nvim setup, change 6) — pinned, .git stripped.
LAZYVIM_REPO="https://github.com/LazyVim/starter"
LAZYVIM_COMMIT="803bc181d7c0d6d5eeba9274d9be49b287294d99"
# Flathub remote definition (change 4). The .flatpakrepo file embeds the GPG
# key, so `flatpak remote-add` from it works OFFLINE in the install chroot.
FLATHUB_REPO_URL="https://dl.flathub.org/repo/flathub.flatpakrepo"
# ----------------------------------------------------- package manifests ----
# BASE_PKGS is the single source of truth for the non-GPU pacstrap set: it is
# written to the ISO as /nosignal/base-packages.list and read back by the
# installer (so the download closure and the installed set can never drift).
BASE_PKGS=(
base linux linux-firmware intel-ucode amd-ucode
networkmanager openssh sudo git curl wget vim less base-devel
limine efibootmgr zram-generator
btrfs-progs snapper snap-pac
pipewire pipewire-pulse pipewire-alsa pipewire-jack wireplumber
polkit hyprpolkitagent qt5-wayland qt6-wayland xdg-user-dirs xdg-utils
hyprland uwsm xdg-desktop-portal-hyprland xdg-desktop-portal-gtk
hyprpicker hyprlock hypridle hyprsunset wl-clipboard cliphist inotify-tools
grim slurp swappy brightnessctl playerctl pavucontrol
# Display manager: SDDM (layer change 16 — DeckShift's desktop<->gaming
# session switching rewrites SDDM config; greetd/tuigreet couldn't do this).
# xorg-server is for SDDM's X11 GREETER only (DisplayServer=x11, the
# reliable path) — the Hyprland session itself stays Wayland.
sddm xorg-server plymouth
# NB: fish must STAY even though bash is the default shell (NoSignal layer
# change 2) — caelestia-meta hard-depends on it. foot stays as a meta dep
# too (its menu entries are hidden by the layer's system-polish change).
foot fish fastfetch starship btop jq eza trash-cli
adw-gtk-theme papirus-icon-theme ttf-jetbrains-mono-nerd
ttf-material-symbols-variable ttf-cascadia-code-nerd
noto-fonts noto-fonts-emoji
kitty-terminfo foot-terminfo
ufw
caelestia-meta
# default terminal (foot stays as caelestia-themed fallback)
kitty
# everyday apps
chromium nautilus gvfs gvfs-mtp
mpv imv papers gnome-calculator gnome-text-editor
libreoffice-fresh
# default music player (layer change 24): CLIAmp, prebuilt in [omarchy]
# (no AUR build); pulls ffmpeg + yt-dlp into the closure
cliamp
# monitor layout/profiles TUI + hotplug daemon (writes the same
# ~/.config/hypr/monitors.conf the base config sources; superseded
# nwg-displays — keep a SINGLE writer of that file). AUR-built.
hyprmoncfg
# software installation story: yay (AUR helper, also used by
# nosignal-update) + Shelly GUI store (both AUR-built) + flatpak backend
flatpak archlinux-appstream-data
yay-bin shelly-bin
# NoSignal layer tooling: checkupdates (updates panel), desktop notify,
# lspci for the Additions page's GPU-detected OBS encoders (change 25 —
# present on the test boxes only as an incidental dep; make it explicit)
pacman-contrib libnotify pciutils
# omadots developer polish (starship/btop/eza already above)
tmux neovim fzf bat zoxide
# base default dev tooling (layer change 33): GitHub CLI (`gh`)
github-cli
# bluetooth
bluez bluez-utils blueman
# laptop: audio firmware + power profiles
sof-firmware power-profiles-daemon
# printing + network discovery
cups system-config-printer avahi nss-mdns
# GUI Wi-Fi fallback (non-CLI escape hatch beside the bar's Wi-Fi panel) +
# GL/Vulkan info tools so `prime-run glxinfo`/`vulkaninfo` work for verifying
# hybrid-GPU offload (hardware-test gap, 2026-06-20)
nm-connection-editor mesa-utils vulkan-tools
)
# Every GPU driver variant goes into the offline repo; the installer detects
# the hardware and installs only the matching subset. KEEP IN SYNC with the
# GPU detection block inside write_installer below.
GPU_ALL_PKGS=(
mesa vulkan-icd-loader
nvidia-open-dkms nvidia-utils egl-wayland libva-nvidia-driver
nvidia-settings linux-headers
nvidia-prime # prime-run offload (hybrid laptops)
vulkan-radeon
vulkan-intel intel-media-driver
qemu-guest-agent spice-vdagent
vulkan-swrast
)
# Prebuilt binaries from the omarchy repo (skips their GraalVM AUR build).
LIMINE_TOOLS=(limine-snapper-sync limine-mkinitcpio-hook)
# AUR packages built in the clean chroot, in dependency order (each build can
# resolve the previously built ones through the local bootstrap repo).
# caelestia-meta is NOT here — it builds from the dotfiles clone on the host.
AUR_BUILD_ORDER=(
app2unit libcava python-materialyoucolor ttf-rubik-vf qtengine
quickshell-git caelestia-cli nosignal-shell
# NoSignal layer round — independent of the caelestia chain (yay-bin and
# shelly-bin repackage release binaries; hyprmoncfg ships a Go release)
yay-bin shelly-bin hyprmoncfg
)
# Clean up the work dir on any exit (success, error, or Ctrl-C). 2>/dev/null
# is in case $WORK never got created (e.g. we exited before mkdir). The
# offline payload cache in $OFFLINE is deliberately NOT cleaned.
trap 'sudo rm -rf "$WORK" 2>/dev/null || true' EXIT
# ===========================================================================
# write_installer — emit the live-ISO installer to the given path.
# This function holds the script that runs on the *target* machine inside
# the live ISO (auto-launched on tty1). It is intentionally embedded here so
# this file is the only artifact needed to rebuild the installer ISO.
# ===========================================================================
write_installer() {
cat > "$1" <<'__INSTALLER_PAYLOAD__'
#!/usr/bin/env bash
set -euo pipefail
MARKER="/tmp/.installer-ran"
if [ -f "$MARKER" ]; then
echo
echo " Installer already ran this session."
echo " To retry: rm $MARKER && bash $0"
echo
exec /bin/bash
fi
touch "$MARKER"
# Mirror stdout/stderr to /tmp/installer.log. If the script aborts, the user
# can switch to tty2, log in as root, and `cat /tmp/installer.log` to see the
# last "==> ..." marker reached and any error printed by the failing command.
exec > >(tee /tmp/installer.log) 2>&1
if [ ! -d /sys/firmware/efi ]; then
echo "ERROR: not booted in UEFI mode. UEFI-only installer." >&2
exec /bin/bash
fi
clear
# --- styled UI helpers (pure bash, no extra packages). They draw to /dev/tty
# directly so the arrow-key redraws never spam the tee'd installer.log. The UI
# is centre-aligned to the console width. --------------------------------------
NSI_G=$'\033[38;5;150m' # NoSignal green
NSI_GB=$'\033[1;38;5;150m' # bold green
NSI_DIM=$'\033[0;90m' # dim/grey
NSI_B=$'\033[1m'
NSI_R=$'\033[0m'
# Console width (from the real console — stdout is tee'd to a pipe). Default 80.
nsi_cols() {
local c; c=$(stty size </dev/tty 2>/dev/null | awk '{print $2}')
case "$c" in ''|*[!0-9]*) c=80 ;; esac
[ "$c" -ge 20 ] 2>/dev/null || c=80
printf '%s' "$c"
}
# Left-pad string for centring a block of visible width $1.
nsi_pad() {
local cols p; cols=$(nsi_cols); p=$(( (cols - $1) / 2 )); [ "$p" -lt 0 ] && p=0
printf '%*s' "$p" ''
}
# Centred line of PLAIN text. $2=colour code (optional), $3=reset (optional).
cecho() { printf '%s%s%s%s\n' "$(nsi_pad "${#1}")" "${2:-}" "$1" "${3:-}"; }
# Centred read into VAR: cread VAR "prompt". csecret = silent (passwords).
cread() { printf '%s' "$(nsi_pad "${#2}")" >/dev/tty; read -rp "$2" "$1" </dev/tty; }
csecret() { printf '%s' "$(nsi_pad "${#2}")" >/dev/tty; read -rsp "$2" "$1" </dev/tty; echo; }
# tui_menu "Title" "opt1" "opt2" ... -> sets MENU_CHOICE (0-based index) + MENU_VALUE.
# Navigate with ↑/↓ (or j/k), Enter selects. Centre-aligned as a block.
tui_menu() {
local title="$1"; shift
local -a opts=("$@")
local n=${#opts[@]} sel=0 first=1 i key rest o w
local lines=$(( n + 3 )) # title + blank + n options + hint
local hint='↑/↓ move · Enter select'
# Block width = widest of title, " "+option, hint — then centre that block.
local maxw=${#title}
for o in "${opts[@]}"; do w=$(( ${#o} + 2 )); [ "$w" -gt "$maxw" ] && maxw=$w; done
[ "${#hint}" -gt "$maxw" ] && maxw=${#hint}
local pad; pad=$(nsi_pad "$maxw")
printf '\033[?25l' >/dev/tty
while true; do
if [ "$first" -eq 1 ]; then first=0; else printf '\033[%dA' "$lines" >/dev/tty; fi
{
printf '\r\033[2K%s%s%s%s\n\r\033[2K\n' "$pad" "$NSI_GB" "$title" "$NSI_R"
for i in "${!opts[@]}"; do
if [ "$i" -eq "$sel" ]; then
printf '\r\033[2K%s%s%s %s%s%s\n' "$pad" "$NSI_GB" "$NSI_R" "$NSI_B" "${opts[$i]}" "$NSI_R"
else
printf '\r\033[2K%s %s%s%s\n' "$pad" "$NSI_DIM" "${opts[$i]}" "$NSI_R"
fi
done
printf '\r\033[2K%s%s%s%s\n' "$pad" "$NSI_DIM" "$hint" "$NSI_R"
} >/dev/tty
IFS= read -rsn1 key </dev/tty || true
case "$key" in
$'\033')
IFS= read -rsn2 -t 0.01 rest </dev/tty || true
case "$rest" in
'[A'|'OA') sel=$(( (sel - 1 + n) % n )) ;;
'[B'|'OB') sel=$(( (sel + 1) % n )) ;;
esac ;;
k) sel=$(( (sel - 1 + n) % n )) ;;
j) sel=$(( (sel + 1) % n )) ;;
'') break ;;
esac
done
printf '\033[?25h' >/dev/tty
MENU_CHOICE=$sel
MENU_VALUE="${opts[$sel]}"
}
# Install-progress UI. During the heavy install phase ALL command output is
# redirected to /tmp/installer.log (off-screen) and the user sees only a single
# CENTRED spinner + the current phase name on /dev/tty — calm and consistent
# with the centred menus, no scrolling formatting/build-hook spam. Full detail
# stays in the log (Ctrl+Alt+F2 -> cat /tmp/installer.log).
NSI_PHASE_FILE=/tmp/nsi-phase
NSI_SPIN_PID=""
# Centred line drawn straight to the real console (stdout is the log mid-install).
tcecho() { printf '%s%s%s%s\n' "$(nsi_pad "${#1}")" "${2:-}" "$1" "${3:-}" >/dev/tty; }
# Set the phase label the spinner shows.
nsi_phase() { printf '%s' "$1" > "$NSI_PHASE_FILE" 2>/dev/null || true; }
nsi_spin_start() {
printf '\033[?25l' >/dev/tty # hide cursor
(
set +e
local frames='/-\|' i=0 msg vis pad
while true; do
msg=$(cat "$NSI_PHASE_FILE" 2>/dev/null); [ -n "$msg" ] || msg="Working"
vis=$(( ${#msg} + 5 )) # frame + 2 spaces + msg + ellipsis
pad=$(nsi_pad "$vis")
printf '\r\033[2K%s%s%s%s %s%s…%s' \
"$pad" "$NSI_GB" "${frames:i%4:1}" "$NSI_R" "$NSI_B" "$msg" "$NSI_R" >/dev/tty
i=$((i+1)); sleep 0.15
done
) &
NSI_SPIN_PID=$!
}
nsi_spin_stop() {
[ -n "$NSI_SPIN_PID" ] && { kill "$NSI_SPIN_PID" 2>/dev/null || true; wait "$NSI_SPIN_PID" 2>/dev/null || true; NSI_SPIN_PID=""; }
printf '\r\033[2K\033[?25h' >/dev/tty # clear the spinner line, show cursor
}
# ERR trap during install: stop the spinner, restore on-screen output, and show
# which phase failed + the tail of the log (full detail on tty2).
nsi_fail() {
trap - ERR
nsi_spin_stop
exec >/dev/tty 2>&1
echo
cecho "Install FAILED at: $(cat "$NSI_PHASE_FILE" 2>/dev/null)" "$NSI_GB" "$NSI_R"
cecho "See the log on tty2: Ctrl+Alt+F2 → cat /tmp/installer.log" "$NSI_DIM" "$NSI_R"
echo
tail -n 25 /tmp/installer.log 2>/dev/null
}
# Banner — centred as a block. ASCII + box-drawing + Latin-1 middot only: the
# Linux console font has no CJK glyphs, so the kana rendered as blocks and (being
# double-width) pushed the right border out. The 入力信号なし wordmark lives on
# the Plymouth splash (a PNG) instead. Borders are 46 cells; interior is 44.
BPAD=$(nsi_pad 46)
printf '%s%s%s\n' "$BPAD" "$NSI_GB" "┌────────────────────────────────────────────┐"
printf '%s%s%s\n' "$BPAD" "$NSI_GB" "│ NoSignal · NO SIGNAL │"
printf '%s%s%s\n' "$BPAD" "$NSI_GB" "└────────────────────────────────────────────┘"
printf '%s' "$NSI_R"
cecho "Arch · Hyprland · Caelestia · SDDM — fully offline installer" "$NSI_DIM" "$NSI_R"
echo
# ----------------------------------------------------- offline payload ------
# Everything installs from the package repo bundled on the install media.
# archiso's copytoram defaults to AUTO and fires on every non-optical boot
# (USB stick + enough RAM): the initramfs copies airootfs.sfs to RAM, then
# UNMOUNTS and rmdir's /run/archiso/bootmnt — taking /nosignal with it. CD
# boots are exempt, which is why VM -cdrom certification never caught it.
# Remount the install media by the UUID archiso itself booted from.
NOSIGNAL_PAYLOAD=/run/archiso/bootmnt/nosignal
if [ ! -f "$NOSIGNAL_PAYLOAD/repo/nosignal.db" ]; then
cecho "Boot media unmounted by copytoram — remounting the offline repo..." "$NSI_DIM" "$NSI_R"
iso_uuid=$(sed -n 's/.*archisosearchuuid=\([^ ]*\).*/\1/p' /proc/cmdline)
iso_label=$(sed -n 's/.*archisolabel=\([^ ]*\).*/\1/p' /proc/cmdline)
iso_dev=""
for _ in $(seq 1 15); do
if [ -n "$iso_uuid" ] && [ -e "/dev/disk/by-uuid/$iso_uuid" ]; then
iso_dev="/dev/disk/by-uuid/$iso_uuid"; break
fi
if [ -n "$iso_label" ] && [ -e "/dev/disk/by-label/$iso_label" ]; then
iso_dev="/dev/disk/by-label/$iso_label"; break
fi
sleep 1
done
if [ -n "$iso_dev" ]; then
mkdir -p /run/archiso/bootmnt
mount -o ro "$iso_dev" /run/archiso/bootmnt || true
fi
fi
if [ ! -f "$NOSIGNAL_PAYLOAD/repo/nosignal.db" ] || [ ! -f "$NOSIGNAL_PAYLOAD/base-packages.list" ]; then
echo "ERROR: offline package repo not found on the install media" >&2
echo " (expected $NOSIGNAL_PAYLOAD/repo). Bad burn or wrong ISO?" >&2
exec /bin/bash
fi
# Strip comments/blank lines so the list file can be annotated.
mapfile -t BASE_PKGS < <(grep -vE '^\s*(#|$)' "$NOSIGNAL_PAYLOAD/base-packages.list")
cecho "Offline repo found (${#BASE_PKGS[@]} base packages)." "$NSI_DIM" "$NSI_R"
echo
# pacman config used for every install-time operation: ONLY the bundled repo.
# The installed system gets the normal online mirrors instead (written later).
# SigLevel Never: the bundled packages were verified when the ISO was BUILT
# (official sigs checked on download; AUR pkgs built locally) — the ISO is the
# trust boundary. Anything weaker fails: e.g. 'Optional TrustAll' still tries
# to VERIFY any signature embedded in the repo db and dies on the missing key.
cat > /tmp/nosignal-pacman.conf <<'OFFLINECONF'
[options]
Architecture = x86_64
SigLevel = Never
[nosignal]
Server = file:///run/archiso/bootmnt/nosignal/repo
OFFLINECONF
# ---------------------------------------------------------------- network ----
# The install is fully offline — no network is needed here or on first boot.
# Wired boxes DHCP automatically via NetworkManager; Wi-Fi users connect from
# the desktop's Wi-Fi panel (click-to-connect) or nm-connection-editor on first
# boot. So the installer asks for nothing here.
# ---------------------------------------------------------------- prompts ----
while true; do
cread HOSTNAME "Hostname [nosignal]: "
HOSTNAME="${HOSTNAME:-nosignal}"
[[ "$HOSTNAME" =~ ^[a-zA-Z0-9][a-zA-Z0-9-]{0,62}$ ]] && break
cecho "Invalid (RFC 1123: letters/digits/hyphen, no leading hyphen, max 63 chars)."
done
while true; do
cread USERNAME "Username: "
[[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]{0,30}$ ]] && break
cecho "Invalid (lowercase, starts a-z or _, max 31 chars)."
done
while true; do
csecret PW1 "Password for $USERNAME (also used for root): "
csecret PW2 "Confirm password: "
if [ -z "$PW1" ]; then cecho "Empty — try again."; continue; fi
if [ "$PW1" != "$PW2" ]; then cecho "Mismatch — try again."; continue; fi
break
done
USER_PW="$PW1"
echo
# Localisation — pick a region from a curated list; it sets the timezone, locale,
# console keymap and Hyprland xkb layout together (no more three cryptic prompts).
# "Other" drops to the search-based prompts for anything not listed. The curated
# values are all standard tzdata/locale.gen/kbd names present on the ISO.
tui_menu "Where are you? (sets time zone, language & keyboard)" \
"United Kingdom" \
"Ireland" \
"United States" \
"Canada" \
"Australia" \
"Germany" \
"France" \
"Spain" \
"Italy" \
"Netherlands" \
"Other (advanced — type & search)"
case "$MENU_VALUE" in
"United Kingdom") TIMEZONE=Europe/London; LOCALE=en_GB.UTF-8; KEYMAP=uk; XKB_LAYOUT=gb ;;
"Ireland") TIMEZONE=Europe/Dublin; LOCALE=en_IE.UTF-8; KEYMAP=uk; XKB_LAYOUT=gb ;;
"United States") TIMEZONE=America/New_York; LOCALE=en_US.UTF-8; KEYMAP=us; XKB_LAYOUT=us ;;
"Canada") TIMEZONE=America/Toronto; LOCALE=en_CA.UTF-8; KEYMAP=us; XKB_LAYOUT=us ;;
"Australia") TIMEZONE=Australia/Sydney; LOCALE=en_AU.UTF-8; KEYMAP=us; XKB_LAYOUT=us ;;
"Germany") TIMEZONE=Europe/Berlin; LOCALE=de_DE.UTF-8; KEYMAP=de-latin1; XKB_LAYOUT=de ;;
"France") TIMEZONE=Europe/Paris; LOCALE=fr_FR.UTF-8; KEYMAP=fr-latin1; XKB_LAYOUT=fr ;;
"Spain") TIMEZONE=Europe/Madrid; LOCALE=es_ES.UTF-8; KEYMAP=es; XKB_LAYOUT=es ;;
"Italy") TIMEZONE=Europe/Rome; LOCALE=it_IT.UTF-8; KEYMAP=it; XKB_LAYOUT=it ;;
"Netherlands") TIMEZONE=Europe/Amsterdam; LOCALE=nl_NL.UTF-8; KEYMAP=us; XKB_LAYOUT=us ;;
*)
# Other (advanced): the previous search-based prompts. Enter '?text' to search.
while true; do
cread TIMEZONE "Timezone [Europe/London] ('?text' searches): "
TIMEZONE="${TIMEZONE:-Europe/London}"
if [[ "$TIMEZONE" == \?* ]]; then
timedatectl list-timezones 2>/dev/null | grep -i -- "${TIMEZONE#\?}" | head -25 | sed 's/^/ /'
continue
fi
[ -f "/usr/share/zoneinfo/$TIMEZONE" ] && break
cecho "Not found (e.g. Europe/London, America/New_York, Australia/Sydney)."
done
while true; do
cread LOCALE "Locale [en_GB.UTF-8] ('?text' searches): "
LOCALE="${LOCALE:-en_GB.UTF-8}"
if [[ "$LOCALE" == \?* ]]; then
grep -oE '^#?[^ ]+ ' /etc/locale.gen | tr -d '# ' | grep -i -- "${LOCALE#\?}" | head -25 | sed 's/^/ /'
continue
fi
grep -oE '^#?[^ ]+ ' /etc/locale.gen | tr -d '# ' | grep -qxF "$LOCALE" && break
cecho "Not found (e.g. en_GB.UTF-8, en_US.UTF-8, de_DE.UTF-8)."
done
while true; do
cread KEYMAP "Console keymap [uk] ('?text' searches): "
KEYMAP="${KEYMAP:-uk}"
if [[ "$KEYMAP" == \?* ]]; then
localectl list-keymaps 2>/dev/null | grep -i -- "${KEYMAP#\?}" | head -25 | sed 's/^/ /'
continue
fi
localectl list-keymaps 2>/dev/null | grep -qxF "$KEYMAP" && break
cecho "Not found (e.g. uk, us, de-latin1, fr-latin1)."
done
# Map the console keymap to an XKB layout for Hyprland's kb_layout. The names
# usually agree (us, de, it, es, pl...); this table covers the common
# exceptions, and the fallback strips variant suffixes (cz-qwertz -> cz).
case "$KEYMAP" in
uk) XKB_LAYOUT="gb" ;;
fr-latin1|fr-latin9) XKB_LAYOUT="fr" ;;
pt-latin1|pt-latin9) XKB_LAYOUT="pt" ;;
br-abnt2) XKB_LAYOUT="br" ;;
la-latin1) XKB_LAYOUT="latam" ;;
sv-latin1) XKB_LAYOUT="se" ;;
sg|sg-latin1) XKB_LAYOUT="ch" ;;
dvorak*) XKB_LAYOUT="us" ;;
*) XKB_LAYOUT="${KEYMAP%%[-.]*}" ;;
esac
;;
esac
cecho "Region set: ${TIMEZONE} · ${LOCALE} · keymap ${KEYMAP}" "$NSI_DIM" "$NSI_R"
# Disk — arrow-key pick from the real disks, then a plain Yes/No confirm
# (defaults to No). No more typing the device path.
echo
mapfile -t DISKS < <(lsblk -d -n -p -o NAME,SIZE,MODEL | grep -Ev '/dev/(loop|sr|zram|fd)')
if [ "${#DISKS[@]}" -eq 0 ]; then
cecho "No disks found. Dropping to shell."
exec /bin/bash
fi
while true; do
DISK_LABELS=()
for d in "${DISKS[@]}"; do
DISK_LABELS+=("$(awk '{name=$1; size=$2; $1=""; $2=""; sub(/^[[:space:]]+/,""); printf "%-16s %-9s %s", name, size, $0}' <<<"$d")")
done
tui_menu "Install to which disk? (everything on it is erased)" "${DISK_LABELS[@]}"
DISK=$(awk '{print $1}' <<<"${DISKS[$MENU_CHOICE]}")
tui_menu "Erase $DISK and install NoSignal?" \
"No — choose a different disk" \
"Yes — ERASE $DISK and install"
[ "$MENU_CHOICE" -eq 1 ] && break
done
if [[ "$DISK" =~ nvme|mmcblk ]]; then
EFI_PART="${DISK}p1"; ROOT_PART="${DISK}p2"
else
EFI_PART="${DISK}1"; ROOT_PART="${DISK}2"
fi
# ---------------------------------------------------------------- install ----
# Go quiet + centred for the heavy phase: the prompts are done, so hide every
# command's output to the log and show a single centred spinner with the current
# phase name (consistent with the centred menus, no formatting/build-hook spam).
clear
# Silence kernel printk on the console — disk format/mount triggers harmless
# btrfs/ext4 probe messages ("VFS: Can't find ext4 filesystem") that write
# straight to the VT and would splatter over the clean spinner. (dmesg keeps
# them; this only stops console printing. Not restored — a reboot follows.)
dmesg -D 2>/dev/null || echo 1 > /proc/sys/kernel/printk 2>/dev/null || true
tcecho "Installing NoSignal" "$NSI_GB" "$NSI_R"
tcecho "this takes a few minutes — sit tight" "$NSI_DIM" "$NSI_R"
printf '\n' >/dev/tty
nsi_phase "Preparing the disk"
exec >>/tmp/installer.log 2>&1
trap nsi_fail ERR
nsi_spin_start
echo "==> Wiping $DISK..."
swapoff -a || true
umount -R /mnt 2>/dev/null || true
wipefs -af "$DISK"
sgdisk --zap-all "$DISK"
echo "==> Partitioning (1G EFI + rest btrfs)..."
# 1 GiB ESP (was 512M): UKIs are self-contained kernel+initramfs+microcode, and
# the NVIDIA UKI is ~139 MB (driver + GSP firmware). With ENABLE_LIMINE_FALLBACK
# (a 2nd UKI) plus multiple kernel versions, a 512 MB ESP can fill and UKI writes
# then fail (finding F4 secondary risk). 1 GiB is safe headroom on every GPU.
sgdisk -n1:0:+1G -t1:ef00 -c1:EFI "$DISK"
sgdisk -n2:0:0 -t2:8300 -c2:ROOT "$DISK"
partprobe "$DISK"
udevadm settle
echo "==> Formatting (btrfs root for Limine snapshots)..."
mkfs.fat -F32 -n EFI "$EFI_PART"
mkfs.btrfs -f -L ROOT "$ROOT_PART"
# Btrfs subvolume layout for snapshot/rollback (root-only snapshots):
# @ -> / (snapshotted + rolled back)
# @home -> /home (separate, so a rollback never reverts user data)
# @snapshots -> /.snapshots (separate, so snapshots survive a rollback of @)
# @log -> /var/log (separate, so logs survive a rollback — useful)
echo "==> Creating btrfs subvolumes..."
mount "$ROOT_PART" /mnt
btrfs subvolume create /mnt/@
btrfs subvolume create /mnt/@home
btrfs subvolume create /mnt/@snapshots
btrfs subvolume create /mnt/@log
umount /mnt
echo "==> Mounting subvolumes..."
BTRFS_OPTS="noatime,compress=zstd,space_cache=v2"
mount -o "$BTRFS_OPTS,subvol=@" "$ROOT_PART" /mnt
mkdir -p /mnt/boot /mnt/home /mnt/.snapshots /mnt/var/log
mount -o "$BTRFS_OPTS,subvol=@home" "$ROOT_PART" /mnt/home
mount -o "$BTRFS_OPTS,subvol=@snapshots" "$ROOT_PART" /mnt/.snapshots
mount -o "$BTRFS_OPTS,subvol=@log" "$ROOT_PART" /mnt/var/log
mount "$EFI_PART" /mnt/boot
# ------------------------------------------------------------ GPU drivers ----
# Hardware-detect the GPU(s) and pick driver packages, borrowing CachyOS chwd's
# method: scan PCI display-class controllers (class 0300/0302/0380) and map the
# vendor id (10de NVIDIA / 1002 AMD / 8086 Intel) to a driver set — plus the VM
# vendors so Hyprland comes up in a guest. Done natively (no chwd binary, no
# CachyOS repos / lib32 / cachyos-kernel deps) so the ISO stays vanilla Arch.
# Multiple GPUs union their package sets (e.g. an Intel/AMD iGPU + NVIDIA dGPU).
# ALL variants below are present in the offline repo (see GPU_ALL_PKGS in the
# builder — keep the two in sync).
echo "==> Detecting GPU(s)..."
GPU_NVIDIA=0; GPU_AMD=0; GPU_INTEL=0; GPU_VM=0
IGPU_PCI_ID="" # vendor:device of the iGPU (for hybrid Vulkan device steering)
while IFS= read -r gpuline; do
case "$gpuline" in
*'[10de:'*) GPU_NVIDIA=1 ;;
*'[1002:'*) GPU_AMD=1; [ -z "$IGPU_PCI_ID" ] && IGPU_PCI_ID=$(printf '%s\n' "$gpuline" | grep -oE '1002:[0-9a-fA-F]{4}' | head -1) ;;
*'[8086:'*) GPU_INTEL=1; [ -z "$IGPU_PCI_ID" ] && IGPU_PCI_ID=$(printf '%s\n' "$gpuline" | grep -oE '8086:[0-9a-fA-F]{4}' | head -1) ;;
*'[1af4:'*|*'[1b36:'*|*'[1234:'*|*'[15ad:'*|*'[80ee:'*) GPU_VM=1 ;;
esac
done < <(lspci -nn 2>/dev/null | grep -Ei 'VGA compatible controller|3D controller|Display controller')
# Hybrid-laptop awareness. An iGPU (Intel/AMD) present ALONGSIDE an NVIDIA dGPU on
# a LAPTOP is almost always muxless Optimus: the internal panel is wired to the
# iGPU and the dGPU is only a secondary render device. Forcing the NVIDIA GLX/
# VA-API globally — correct when the display hangs off the NVIDIA card (desktop,
# or a laptop MUX set to discrete) — then breaks/slows the iGPU-driven Wayland
# session and keeps the dGPU awake on battery. So on a hybrid laptop we keep the
# iGPU primary, ship prime-run for on-demand offload, and enable dGPU runtime
# power management, while STILL installing the NVIDIA driver + KMS so offload
# works. Desktops with NVIDIA present are unaffected (no laptop chassis/battery).
# Override the heuristic with NOSIGNAL_GPU_PRIMARY=nvidia|igpu|auto (default auto).
GPU_HYBRID_LAPTOP=0
IGPU_VAAPI=radeonsi
if [ "$GPU_NVIDIA" = 1 ] && { [ "$GPU_INTEL" = 1 ] || [ "$GPU_AMD" = 1 ]; }; then
is_laptop=0
ct=$(cat /sys/class/dmi/id/chassis_type 2>/dev/null || echo 0)
case "$ct" in 8|9|10|11|14|30|31|32) is_laptop=1 ;; esac
for b in /sys/class/power_supply/BAT*; do [ -e "$b" ] && is_laptop=1; done
case "${NOSIGNAL_GPU_PRIMARY:-auto}" in
igpu) GPU_HYBRID_LAPTOP=1 ;;
nvidia) GPU_HYBRID_LAPTOP=0 ;;
*) GPU_HYBRID_LAPTOP=$is_laptop ;;
esac
# iGPU VA-API backend (the panel-connected decoder): Intel -> iHD, AMD -> radeonsi.
[ "$GPU_INTEL" = 1 ] && IGPU_VAAPI=iHD
fi
GPU_PKGS=(mesa vulkan-icd-loader)
GPU_SUMMARY=()
if [ "$GPU_NVIDIA" = 1 ]; then
# Open-kernel-module NVIDIA (Turing/RTX+; the current default). DKMS so it
# tracks the stock 'linux' kernel; linux-headers builds it during pacstrap.
GPU_PKGS+=(nvidia-open-dkms nvidia-utils egl-wayland libva-nvidia-driver nvidia-settings linux-headers)
GPU_SUMMARY+=("NVIDIA (nvidia-open-dkms + Wayland KMS)")
fi
if [ "$GPU_AMD" = 1 ]; then
GPU_PKGS+=(vulkan-radeon)
GPU_SUMMARY+=("AMD (mesa / vulkan-radeon)")
fi
if [ "$GPU_INTEL" = 1 ]; then
GPU_PKGS+=(vulkan-intel intel-media-driver)
GPU_SUMMARY+=("Intel (mesa / vulkan-intel)")
fi
if [ "$GPU_VM" = 1 ]; then
GPU_PKGS+=(qemu-guest-agent spice-vdagent)
GPU_SUMMARY+=("VM guest (virtio / qemu)")
fi
if [ "${#GPU_SUMMARY[@]}" -eq 0 ]; then
# Nothing recognised — software rendering keeps Hyprland able to start.
GPU_PKGS+=(vulkan-swrast)
GPU_SUMMARY+=("unknown — software fallback (vulkan-swrast)")
fi
if [ "$GPU_HYBRID_LAPTOP" = 1 ]; then
# prime-run for on-demand dGPU offload; iGPU stays primary (see env block below).
GPU_PKGS+=(nvidia-prime)
GPU_SUMMARY+=("hybrid laptop -> iGPU primary + NVIDIA PRIME offload (prime-run)")
fi
echo " Driver target(s): ${GPU_SUMMARY[*]}"
nsi_phase "Installing the NoSignal desktop (this is the slow part)"
pacstrap -C /tmp/nosignal-pacman.conf -K /mnt "${BASE_PKGS[@]}" "${GPU_PKGS[@]}"
echo "==> Generating fstab..."
genfstab -U /mnt >> /mnt/etc/fstab
# F7: genfstab inherits the ESP's live mount options (fmask=0022,dmask=0022),
# which make /boot world-readable — exposing the bootloader random-seed and
# limine.conf (kernel cmdline, root PARTUUID, snapshot history) to any local
# user. systemd's bootctl flags this at every boot as a security hole. Tighten
# the vfat (ESP) line to root rw / group r / others none: files rw-r----- and
# dirs rwxr-x---. (Only the ESP is vfat here, so the vfat-line match is exact.)
sed -i -E '/[[:space:]]vfat[[:space:]]/{ s/fmask=[0-7]+/fmask=0137/; s/dmask=[0-7]+/dmask=0027/ }' /mnt/etc/fstab
if grep -qE '[[:space:]]vfat[[:space:]].*fmask=0137' /mnt/etc/fstab; then
echo " ESP hardened (fmask=0137,dmask=0027 — not world-readable)."
else
echo " WARNING: could not harden ESP mount perms in fstab — review /boot fmask/dmask."
fi
nsi_phase "Configuring the system"
echo "==> Configuring base system in chroot..."
# Localisation from the prompts (written from outside the chroot — simpler
# than threading variables through a quoted heredoc).
ln -sf "/usr/share/zoneinfo/$TIMEZONE" /mnt/etc/localtime
sed -i "s|^#${LOCALE} |${LOCALE} |" /mnt/etc/locale.gen
# en_US.UTF-8 as an always-present fallback locale.
sed -i 's|^#en_US.UTF-8 UTF-8|en_US.UTF-8 UTF-8|' /mnt/etc/locale.gen
echo "LANG=$LOCALE" > /mnt/etc/locale.conf
echo "KEYMAP=$KEYMAP" > /mnt/etc/vconsole.conf
# Resolve .local hostnames via Avahi (printers, other LAN boxes): insert
# mdns_minimal before the resolve/dns sources, the standard nss-mdns setup.
sed -i '/^hosts:/ s/resolve/mdns_minimal [NOTFOUND=return] resolve/' /mnt/etc/nsswitch.conf
arch-chroot /mnt /bin/bash <<'CHROOT'
set -e
hwclock --systohc
locale-gen
sed -i 's/^# %wheel ALL=(ALL:ALL) ALL/%wheel ALL=(ALL:ALL) ALL/' /etc/sudoers
systemctl enable NetworkManager.service
systemctl enable bluetooth.service
systemctl enable cups.socket
systemctl enable avahi-daemon.service
systemctl enable power-profiles-daemon.service
# Process sysusers so the 'sddm' system user exists (pacstrap doesn't run
# the package's sysusers.d automatically). Harmless if already present.
systemd-sysusers || true
CHROOT
# --------------------------------------------------------------- Plymouth ----
# Boot splash between Limine and the SDDM greeter: insert the `plymouth`
# initramfs hook right after the init manager hook and set the NoSignal theme
# (the "NO SIGNAL / 入力信号なし" logo centred on black). With the quiet/splash
# kernel cmdline, the boot shows that splash instead of scrolling kernel text.
echo "==> Configuring Plymouth (NoSignal boot splash)..."
# Ship the NoSignal Plymouth theme (vendored). Root dir is 'nosignal' ->
# /usr/share/plymouth/themes/nosignal. Must land BEFORE the initramfs rebuild
# below so the theme's images/script are embedded in the initramfs.
install -d -m 755 /mnt/usr/share/plymouth/themes
tar -xzf "$NOSIGNAL_PAYLOAD/vendor/nosignal-plymouth.tar.gz" \
-C /mnt/usr/share/plymouth/themes
# The plymouth hook goes right after the init manager hook. Arch's default
# mkinitcpio.conf now uses the `systemd` hook (not `udev`), so handle both:
# plymouth after `systemd` if present, else after `udev`. Idempotent.
if ! grep -qE '^HOOKS=.*\bplymouth\b' /mnt/etc/mkinitcpio.conf; then
if grep -qE '^HOOKS=.*\bsystemd\b' /mnt/etc/mkinitcpio.conf; then
sed -i '/^HOOKS=/ s/\bsystemd\b/systemd plymouth/' /mnt/etc/mkinitcpio.conf
else
sed -i '/^HOOKS=/ s/\budev\b/udev plymouth/' /mnt/etc/mkinitcpio.conf
fi
fi
# Set NoSignal as the default theme (no -R: the mkinitcpio -P below rebuilds the
# initramfs and embeds it). Falls back to the built-in spinner if it can't apply.
arch-chroot /mnt plymouth-set-default-theme nosignal \
|| arch-chroot /mnt plymouth-set-default-theme spinner || true
# ------------------------------------------------------------- NVIDIA KMS ----
# Borrowed from chwd's pre_install hook: early-load the NVIDIA modules and drop
# the 'kms' hook (which would otherwise bind nouveau to the card), enable DRM
# modeset, and set the Wayland EGL/VA env. This is what actually lets Hyprland
# start on NVIDIA. No-op on AMD/Intel/VM-only systems.
if [ "$GPU_NVIDIA" = 1 ]; then
echo "==> Configuring NVIDIA DRM/KMS for Wayland (chwd-style)..."
install -d -m 755 /mnt/etc/mkinitcpio.conf.d
cat > /mnt/etc/mkinitcpio.conf.d/10-nosignal-nvidia.conf <<'NV'
# Generated by NoSignal. Early-load the NVIDIA modules for KMS.
MODULES+=(nvidia nvidia_modeset nvidia_uvm nvidia_drm)
NV
cat > /mnt/etc/mkinitcpio.conf.d/20-nosignal-nvidia-kms.conf <<'NVK'
# Generated by NoSignal. Drop the 'kms' hook so nouveau isn't pulled in early.
HOOKS=(${HOOKS[@]/kms/})
NVK
cat > /mnt/etc/modprobe.d/nvidia.conf <<'NVMOD'
options nvidia_drm modeset=1 fbdev=1
NVMOD
# System-wide EGL/VA env. caelestia symlinks ~/.config/hypr, so per-user hypr
# env files would be clobbered — /etc/environment is read for the session.
if [ "$GPU_HYBRID_LAPTOP" = 1 ]; then
# Muxless Optimus: the iGPU drives the panel. Do NOT force NVIDIA GLX/VA-API
# globally — that would push the whole session onto the dGPU. Leave GLX to the
# iGPU, point VA-API at the iGPU decoder, and let apps opt into the dGPU with
# `prime-run`. Enable dGPU runtime power management (D3) so it sleeps when idle
# (Turing+; nvidia-open supports it — matches the nvidia-open-dkms gate).
cat >> /mnt/etc/environment <<NVENVH
# --- NoSignal hybrid-laptop GPU env (iGPU primary; run dGPU apps with prime-run) ---
LIBVA_DRIVER_NAME=$IGPU_VAAPI
NVENVH
# RTD3 Cause 2 (2026-06-20 hybrid battery finding): renderD128 is the NVIDIA
# node (first/default), so naive Vulkan clients open the dGPU and pin it out of
# runtime-D3. Steer mesa's default Vulkan device to the iGPU so only prime-run
# apps touch the dGPU. prime-run still wins (it sets __NV_PRIME_RENDER_OFFLOAD
# + __VK_LAYER_NV_optimus, which override this). GL/EGL clients on Wayland
# already render on the compositor's iGPU; VA-API is pinned to the iGPU above.
# NOTE: best-effort steering — the on-battery dGPU-sleep re-test is the gate.
if [ -n "$IGPU_PCI_ID" ]; then
cat >> /mnt/etc/environment <<NVENVS
MESA_VK_DEVICE_SELECT=$IGPU_PCI_ID
NVENVS
fi
cat > /mnt/etc/modprobe.d/nvidia-pm.conf <<'NVPM'
# Fine-grained dGPU runtime power management for Optimus battery life (Turing+).
options nvidia NVreg_DynamicPowerManagement=0x02
NVPM
install -d -m 755 /mnt/etc/udev/rules.d
cat > /mnt/etc/udev/rules.d/80-nosignal-nvidia-pm.rules <<'NVUDEV'
# Let the NVIDIA dGPU autosuspend when idle (runtime D3). VGA + 3D controllers.
#
# RTD3 Cause 1 (2026-06-20 hardware test, Acer AMD iGPU + RTX 3050): the nvidia
# driver binds the dGPU INSIDE the initramfs (mkinitcpio `kms` hook pulls it in
# even with MODULES=()), before real-root udevd exists. Coldplug then replays the
# device as ACTION=="add", which an ACTION=="bind" rule never matched, leaving
# power/control at the kernel default "on" (dGPU never reached D3, ~4 W idle).
# DRIVER=nvidia is already set at coldplug, so match the bound device with NO
# ACTION gate. Keep restoring "auto" on unbind for a later rebind/probe.
SUBSYSTEM=="pci", DRIVERS=="nvidia", ATTR{vendor}=="0x10de", ATTR{class}=="0x030000", TEST=="power/control", ATTR{power/control}="auto"
SUBSYSTEM=="pci", DRIVERS=="nvidia", ATTR{vendor}=="0x10de", ATTR{class}=="0x030200", TEST=="power/control", ATTR{power/control}="auto"
ACTION=="unbind", SUBSYSTEM=="pci", DRIVERS=="nvidia", ATTR{vendor}=="0x10de", ATTR{class}=="0x030000", TEST=="power/control", ATTR{power/control}="auto"
ACTION=="unbind", SUBSYSTEM=="pci", DRIVERS=="nvidia", ATTR{vendor}=="0x10de", ATTR{class}=="0x030200", TEST=="power/control", ATTR{power/control}="auto"
NVUDEV
else
# NVIDIA drives the display (desktop, or a laptop MUX set to discrete): force
# the NVIDIA GLX/VA-API session-wide.
cat >> /mnt/etc/environment <<'NVENV'
# --- NoSignal NVIDIA Wayland env ---
LIBVA_DRIVER_NAME=nvidia
__GLX_VENDOR_LIBRARY_NAME=nvidia
NVD_BACKEND=direct
NVENV
fi
fi
# ---------------------------------------------------------- Acer power -------
# Acer Predator/Nitro laptops keep the dGPU's higher power states LOCKED until
# acer_wmi loads with predator_v4=1 — then the NitroSense/Turbo key cycles them
# (e.g. 35->40->50->60 W). NoSignal didn't set this out of the box, so the dGPU
# was capped low (hardware test 2026-06-20). Bake it in, gated on Acer hardware
# AND a kernel acer_wmi that accepts the option, so non-Acer machines are
# untouched. nvidia-powerd (Dynamic Boost) is enabled when an NVIDIA driver is
# present (the unit only exists then). Platform profile stays the Omarchy default
# (balanced) — the user can raise it. Source: 28allday/Acer-Power-Control-Omarchy.
SYS_VENDOR=$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null || echo "")
if printf '%s' "$SYS_VENDOR" | grep -qi acer \
&& modinfo -p acer_wmi 2>/dev/null | grep -q "^predator_v4:"; then
echo "==> Acer laptop detected — unlocking Predator/Nitro GPU power (predator_v4=1)..."
printf 'options acer_wmi predator_v4=1\n' > /mnt/etc/modprobe.d/acer-wmi.conf
if [ "$GPU_NVIDIA" = 1 ]; then
arch-chroot /mnt systemctl enable nvidia-powerd >/dev/null 2>&1 \
&& echo " nvidia-powerd enabled (Dynamic Boost)" \
|| echo " nvidia-powerd not enabled (optional)"
fi
fi
nsi_phase "Installing the bootloader"
echo "==> Installing Limine bootloader (UEFI)..."
ROOT_PARTUUID=$(blkid -s PARTUUID -o value "$ROOT_PART")
# Root is the btrfs @ subvolume — Limine/kernel needs rootflags to mount it.
# The quiet/splash flags give a clean boot: Plymouth shows the NoSignal splash
# (NO SIGNAL logo on black) and the kernel/systemd "loading" text is suppressed,
# so the boot goes Limine -> NoSignal splash -> SDDM greeter with no scroll.
KERNEL_OPTS="root=PARTUUID=$ROOT_PARTUUID rw rootflags=subvol=@"
KERNEL_OPTS="$KERNEL_OPTS quiet splash loglevel=3 systemd.show_status=false rd.udev.log_level=3 vt.global_cursor_default=0"
# Disable zswap: the swap device is zram (already compressed RAM), so zswap in
# front of it would double-compress pages (zswap -> zram) and waste CPU.
KERNEL_OPTS="$KERNEL_OPTS zswap.enabled=0"
# NVIDIA needs DRM modeset on the cmdline too (belt-and-suspenders with the
# modprobe.d option) for Wayland/Hyprland.
[ "$GPU_NVIDIA" = 1 ] && KERNEL_OPTS="$KERNEL_OPTS nvidia_drm.modeset=1"
# /mnt/boot is the FAT32 ESP. Drop Limine's UEFI binary at BOTH the spec-defined
# vendor path (/EFI/limine) and the removable-media fallback (/EFI/BOOT/BOOTX64
# .EFI). The fallback is what makes the disk boot without an NVRAM entry — the
# efibootmgr call below often can't write NVRAM (chroot / VMs / locked firmware),
# so the fallback path is the reliable one; the NVRAM entry is a nicety.
install -d /mnt/boot/EFI/limine /mnt/boot/EFI/BOOT
cp /mnt/usr/share/limine/BOOTX64.EFI /mnt/boot/EFI/limine/BOOTX64.EFI
cp /mnt/usr/share/limine/BOOTX64.EFI /mnt/boot/EFI/BOOT/BOOTX64.EFI
# limine.conf lives on the ESP root; boot():/ resolves to that same ESP, where
# the kernel/ucode/initramfs all sit (since /boot IS the ESP). Microcode modules
# must precede the main initramfs.
# timeout: 10 — keep the Limine menu up 10s before auto-booting (user choice;
# the limine snapshot tooling only manages entries, it never rewrites this).
#
# F4 (dead-boot after a kernel update): with ENABLE_UKI=yes the first kernel
# update DELETES /boot/vmlinuz-linux + initramfs-linux.img (they're folded into
# the UKI). The OLD seed made the *first/default* manual entry `protocol: linux`
# pointing at those now-missing files, with no `default_entry:` — so once the
# UKI hook ran, the default auto-boot target dead-booted to a TTY. The previous
# post-install conversion mis-fired (ran once, no-op'd, marked applied), so the
# durable fix lives HERE in the seed:
# * entry 1 = `protocol: efi` -> the UKI (boots once the UKI exists, which is
# at install in the normal path, or at worst after the first kernel update).
# * entry 2 = a `protocol: linux` fallback for the rare pre-UKI first boot
# (snapshot/limine-update failed at install, so no UKI yet). Becomes a no-op
# stub once the UKI exists; harmless, never the default.
# * `default_entry: 1` pins the UKI entry as default regardless of the auto
# entries limine appends below it on every kernel update (limine preserves
# these manual entries + general directives verbatim across regenerations,
# so this seed is durable — no per-update hook needed).
cat > /mnt/boot/limine.conf <<LIMINE
timeout: 10
default_entry: 1
interface_branding: NoSignal
/NoSignal (Arch Linux)
protocol: efi
path: boot():/EFI/Linux/nosignal_linux.efi
cmdline: $KERNEL_OPTS
/NoSignal (fallback kernel)
protocol: linux
path: boot():/vmlinuz-linux
cmdline: $KERNEL_OPTS
module_path: boot():/intel-ucode.img
module_path: boot():/amd-ucode.img
module_path: boot():/initramfs-linux.img
LIMINE
# Best-effort NVRAM boot entry (ignored if firmware NVRAM isn't writable here).
arch-chroot /mnt efibootmgr --create --disk "$DISK" --part 1 \
--loader '\EFI\limine\BOOTX64.EFI' --label 'NoSignal (Limine)' --unicode 2>/dev/null || true
# ------------------------------------------------- Btrfs + Limine snapshots ---
# Omarchy-style bootable snapshots, configured AT INSTALL TIME (this all lived
# in the first-boot script before Phase 3): limine-snapper-sync + the UKI-based
# limine-mkinitcpio-hook + snapper (root only). After this, every pacman
# transaction auto-snapshots (snap-pac), and each snapshot shows as a bootable
# entry in the Limine menu — pick one to roll back a bad update. Resilient: if
# any of this fails, the basic Limine boot set up above still works.
echo "==> Configuring Btrfs + Limine snapshots..."
# /etc/default/limine is read by the limine tools to build UKIs + boot entries.
# It must exist BEFORE the tools are installed/run. Carries the same cmdline.
cat > /mnt/etc/default/limine <<LIMINE_DEFAULT
TARGET_OS_NAME="NoSignal"
ESP_PATH="/boot"
KERNEL_CMDLINE[default]+="$KERNEL_OPTS"
ENABLE_UKI=yes
CUSTOM_UKI_NAME="nosignal"
ENABLE_LIMINE_FALLBACK=yes
FIND_BOOTLOADERS=yes
BOOT_ORDER="*, *fallback, Snapshots"
MAX_SNAPSHOT_ENTRIES=5
SNAPSHOT_FORMAT_CHOICE=5
LIMINE_DEFAULT
# btrfs-overlayfs initramfs hook — lets Limine boot a read-only snapshot.
# NB: written as a FULL HOOKS= line (current hooks + btrfs-overlayfs). The
# NVIDIA kms-strip override is 20-* so it still applies after this (10-*).
cur_hooks="$(grep '^HOOKS=' /mnt/etc/mkinitcpio.conf | sed 's/^HOOKS=(//; s/)$//')"
if ! grep -qw btrfs-overlayfs <<<"$cur_hooks"; then
install -d -m 755 /mnt/etc/mkinitcpio.conf.d
echo "HOOKS=($cur_hooks btrfs-overlayfs)" > /mnt/etc/mkinitcpio.conf.d/10-nosignal-btrfs.conf
fi
# The snapshot tools come PREBUILT from the omarchy repo (bundled in the
# offline repo) — installed in a second pacstrap now that their config exists.
# KEEP IN SYNC with LIMINE_TOOLS in the builder.
nsi_phase "Installing snapshot + boot tooling"
pacstrap -C /tmp/nosignal-pacman.conf /mnt limine-snapper-sync limine-mkinitcpio-hook
# snapper "root" config. /.snapshots is already a mounted @snapshots subvol,
# which collides with snapper create-config (it wants to make that subvol) —
# so do the documented dance: unmount, let snapper create its own, delete it,
# then remount our @snapshots. --no-dbus because there's no snapperd in chroot.
SNAPSHOTS_OK=0
if umount /mnt/.snapshots \
&& rm -rf /mnt/.snapshots \
&& arch-chroot /mnt snapper --no-dbus -c root create-config / \
&& arch-chroot /mnt btrfs subvolume delete /.snapshots \
&& mkdir /mnt/.snapshots \
&& mount -o "$BTRFS_OPTS,subvol=@snapshots" "$ROOT_PART" /mnt/.snapshots \
&& chmod 750 /mnt/.snapshots; then
# Root-only, keep 5, no timeline (pre/post pacman snapshots via snap-pac).
arch-chroot /mnt snapper --no-dbus -c root set-config NUMBER_LIMIT=5 NUMBER_LIMIT_IMPORTANT=5 TIMELINE_CREATE=no || true
arch-chroot /mnt btrfs quota disable / 2>/dev/null || true # qgroup accounting is a perf drag
arch-chroot /mnt systemctl enable limine-snapper-sync.service || true
SNAPSHOTS_OK=1
echo " Snapshots configured."
else
echo " snapper setup failed — snapshots disabled (basic Limine boot unaffected)."
fi
# Rebuild the initramfs once, now that Plymouth, any NVIDIA overrides and the
# btrfs-overlayfs hook are all in place — then let the limine tooling generate
# the UKIs and the final boot entries.
# Full path: limine-mkinitcpio-hook ships a /usr/local/bin/mkinitcpio wrapper
# that PROMPTS [Y/n] after -P ("run limine-mkinitcpio now?") — an interactive
# blocker mid-install. limine-update below regenerates the entries anyway.
nsi_phase "Building the boot image"
arch-chroot /mnt /usr/bin/mkinitcpio -P
if [ "$SNAPSHOTS_OK" = 1 ]; then
if arch-chroot /mnt limine-update; then
echo " Limine UKI entries generated."
else
echo " limine-update failed — basic Limine boot still works."
fi
fi
# ---------------------------------------------------------------- firewall ---
# Default deny inbound, allow outbound (a desktop needs no open inbound ports).
# ufw in a chroot can't apply rules to netfilter and returns non-zero on some
# versions — tolerate that; ENABLED=yes in ufw.conf + the enabled service make
# the policy apply at boot.
echo "==> Configuring firewall (ufw)..."
arch-chroot /mnt ufw default deny incoming || true
arch-chroot /mnt ufw default allow outgoing || true
# omarchy-send / LocalSend protocol (TCP transfers + UDP discovery).
arch-chroot /mnt ufw allow 53317 || true
# mDNS responses (Avahi: .local resolution, printer/device discovery).
arch-chroot /mnt ufw allow 5353/udp || true
sed -i 's/^ENABLED=no/ENABLED=yes/' /mnt/etc/ufw/ufw.conf 2>/dev/null || true
arch-chroot /mnt systemctl enable ufw.service || true
echo "==> Writing hostname / hosts..."
echo "$HOSTNAME" > /mnt/etc/hostname
cat > /mnt/etc/hosts <<HOSTS
127.0.0.1 localhost
::1 localhost
127.0.1.1 $HOSTNAME.localdomain $HOSTNAME
HOSTS
echo "==> Creating user $USERNAME..."
# video/audio/input groups for a desktop session; wheel for sudo.
arch-chroot /mnt useradd -m -G wheel,video,audio,input -s /bin/bash "$USERNAME"
printf 'root:%s\n%s:%s\n' "$USER_PW" "$USERNAME" "$USER_PW" | arch-chroot /mnt chpasswd
# ----------------------------------------------------------- ssh handoff ----
# SSH is OPTIONAL on a desktop. If the build host baked a public key we install
# it and enable sshd (handy for remote rescue / setup), opening 22 in ufw.
# Otherwise sshd stays disabled — this is a desktop, not a server.
if [ -f /root/master.pub ]; then
echo "==> Installing master SSH key for $USERNAME + enabling sshd..."
install -m 700 -d "/mnt/home/$USERNAME/.ssh"
install -m 600 /root/master.pub "/mnt/home/$USERNAME/.ssh/authorized_keys"
arch-chroot /mnt chown -R "$USERNAME:$USERNAME" "/home/$USERNAME/.ssh"
install -m 755 -d /mnt/etc/ssh/sshd_config.d
cat > /mnt/etc/ssh/sshd_config.d/10-key-only.conf <<'SSHD'
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
SSHD
arch-chroot /mnt systemctl enable sshd.service || true
arch-chroot /mnt ufw allow 22/tcp || true
else
echo " (no /root/master.pub on the ISO — sshd left disabled)"
fi
# ------------------------------------------------- online repos (post-boot) --
# The INSTALLED system uses the normal online mirrors, not the install media.
# The archiso live mirrorlist is reflector-generated only when online — on an
# offline install it can be empty/commented, so write a sane default the user
# can refine later. NB: nothing is synced yet; the user's first package
# operation should be `sudo pacman -Syu`.
echo "==> Writing pacman mirrorlist for the installed system..."
cat > /mnt/etc/pacman.d/mirrorlist <<'MIRROR'
## NoSignal default mirror (geo-routed). Refine with reflector if you like:
## sudo reflector --latest 10 --sort rate --save /etc/pacman.d/mirrorlist
Server = https://geo.mirror.pkgbuild.com/$repo/os/$arch
MIRROR
# The omarchy repo serves PREBUILT updates for the Limine snapshot tools
# (otherwise they rebuild via a ~530MB GraalVM toolchain from the AUR).
# NB: SigLevel is Optional TrustAll (unsigned) — same as Omarchy uses; it means
# trusting omarchy's binaries. Only the two limine-* packages come from it.
echo "==> Adding omarchy repo (prebuilt Limine snapshot tool updates)..."
cat >> /mnt/etc/pacman.conf <<'OMARCHY_REPO'
[omarchy]
SigLevel = Optional TrustAll
Server = https://pkgs.omarchy.org/edge/$arch
OMARCHY_REPO
# Enable [multilib] (layer change 15 — gaming enablement): the lib32-* deps
# Steam/gaming installers (DeckShift) need live there. Uncomments ONLY the
# [multilib] block (leaves [multilib-testing] alone). Nothing is synced here —
# the install is offline; the user's first `pacman -Syu` pulls the db.
echo "==> Enabling [multilib] repo in the installed system..."
sed -i '/^#\[multilib\]/,/^#Include/ s/^#//' /mnt/etc/pacman.conf
# ---------------------------------------------------------- zram swap --------
# A desktop with NO swap OOM-kills heavy workloads; zram gives compressed
# in-RAM swap (no disk partition needed). zram-generator is a systemd
# generator: the package + this config auto-activate systemd-zram-setup@zram0
# at boot, no explicit enable needed.
echo "==> Configuring zram swap..."
cat > /mnt/etc/systemd/zram-generator.conf <<'ZRAM'
[zram0]
zram-size = min(ram, 8192)
compression-algorithm = zstd
ZRAM
chmod 644 /mnt/etc/systemd/zram-generator.conf
# ------------------------------------- CachyOS-derived performance tweaks ----
# Cherry-picked from CachyOS-Settings (github.com/CachyOS/CachyOS-Settings),
# same borrow-the-method approach as the chwd GPU detection: a curated subset
# of their sysctl + udev rules, vendored as plain text (no CachyOS repos or
# kernel). swappiness=100 is correct WITH zram (compressed swap is cheap).
echo "==> Applying CachyOS-derived performance tweaks..."
cat > /mnt/etc/sysctl.d/99-nosignal-performance.conf <<'SYSCTL'
# NoSignal — desktop responsiveness tweaks (curated from CachyOS-Settings).
kernel.nmi_watchdog = 0
kernel.split_lock_mitigate = 0
vm.max_map_count = 2147483642
vm.swappiness = 100
vm.vfs_cache_pressure = 50
# With zram (RAM-fast, per-page decompression) swap read-ahead just wastes CPU,
# so disable it (Arch Wiki / Fedora recommend page-cluster=0 with zram).
vm.page-cluster = 0
net.ipv4.tcp_fastopen = 3
SYSCTL
cat > /mnt/etc/udev/rules.d/60-ioschedulers.rules <<'IOSCHED'
# NoSignal — I/O scheduler per device type (from CachyOS-Settings):
# NVMe -> none, SATA/eMMC SSD -> mq-deadline, rotational -> bfq.
ACTION=="add|change", KERNEL=="nvme[0-9]*", ATTR{queue/rotational}=="0", ATTR{queue/scheduler}="none"
ACTION=="add|change", KERNEL=="sd[a-z]*|mmcblk[0-9]*", ATTR{queue/rotational}=="0", ATTR{queue/scheduler}="mq-deadline"
ACTION=="add|change", KERNEL=="sd[a-z]*", ATTR{queue/rotational}=="1", ATTR{queue/scheduler}="bfq"
IOSCHED
# ---------------------------------------------- ~/.local/bin on PATH --------
# Put ~/.local/bin onto every login shell's PATH so user-installed binaries are
# runnable without manual PATH edits. /etc/profile.d/*.sh is sourced by
# /etc/profile for any login shell, before user dotfiles.
echo "==> Adding ~/.local/bin to PATH for all login shells..."
install -d -m 755 /mnt/etc/profile.d
cat > /mnt/etc/profile.d/local-bin.sh <<'PROFILE_LOCAL_BIN'
# Add ~/.local/bin to PATH for login shells (idempotent).
case ":$PATH:" in
*":$HOME/.local/bin:"*) ;;
*) [ -d "$HOME/.local/bin" ] && PATH="$HOME/.local/bin:$PATH" ;;
esac
PROFILE_LOCAL_BIN
chmod 644 /mnt/etc/profile.d/local-bin.sh
# --------------------------------------------------------------- SDDM --------
# Display manager: SDDM (layer change 16, REQUIRED — DeckShift's
# desktop<->gaming session switching rewrites SDDM config + restarts it;
# greetd/tuigreet couldn't do this). Password greeter, NO autologin baked —
# DeckShift manages its own autologin/session entries post-install (change 17,
# opt-in). Canonical source: the layer's display-manager-sddm/
# sddm-10-nosignal.conf; mirrored here to keep the installer self-contained.
echo "==> Writing + enabling SDDM..."
install -d -m 755 /mnt/etc/sddm.conf.d
cat > /mnt/etc/sddm.conf.d/10-nosignal.conf <<'SDDMCONF'
# NoSignal SDDM config (installed to /etc/sddm.conf.d/10-nosignal.conf).
[General]
# X11 greeter — the reliable, well-trodden path (needs xorg-server). The Hyprland
# SESSION still runs on Wayland; only SDDM's own greeter uses X here.
# For an X-free image instead, use the Wayland greeter:
# DisplayServer=wayland
# [Wayland]
# CompositorCommand=weston --shell=kiosk (adds `weston` to the image)
DisplayServer=x11
Numlock=on
[Users]
# The desktop session to prefer. hyprland-uwsm.desktop launches Hyprland through
# uwsm exactly like the old greetd config — keep this (NOT plain hyprland.desktop),
# or caelestia's uwsm/systemd-managed session env won't apply.
# (SDDM remembers the last-used session per user; DeckShift overrides this when
# switching to/from its Gamescope gaming session.)
SDDMCONF
chmod 644 /mnt/etc/sddm.conf.d/10-nosignal.conf
# Seed SDDM's remembered last user/session so the very first greeter already
# preselects the uwsm-managed Hyprland session (hyprland-uwsm.desktop — the
# same canonical uwsm launch the old tuigreet --cmd used; plain
# hyprland.desktop would lose caelestia's uwsm/systemd-managed session env).
# SDDM rewrites this file itself after every login. mkdir -p (not install -d):
# the sddm package owns /var/lib/sddm with its own ownership/mode — don't touch.
mkdir -p /mnt/var/lib/sddm
cat > /mnt/var/lib/sddm/state.conf <<SDDMSTATE
[Last]
Session=/usr/share/wayland-sessions/hyprland-uwsm.desktop
User=$USERNAME
SDDMSTATE
# 644, NOT 600: the GREETER (running as the 'sddm' user) reads this file to
# prefill the last user/session — 600 root:root leaves the username field
# empty on first boot (caught in VM cert). SDDM itself writes it 644.
chmod 644 /mnt/var/lib/sddm/state.conf
arch-chroot /mnt systemctl enable sddm.service
# ------------------------------------------------- caelestia desktop ---------
# Lay down the complete caelestia desktop for the user AT INSTALL TIME, exactly
# replicating what upstream's install.fish does on a live system (investigated
# against a completed install): dotfiles clone in ~/.local/share/caelestia,
# symlinks from ~/.config into it, the colour scheme seeded, plus the NoSignal
# restraint config and the workspace-overview sidecar. The caelestia PACKAGES
# (shell/cli/meta + deps) were already installed by pacstrap from the bundled
# repo.
echo "==> Installing caelestia desktop for $USERNAME (offline)..."
USER_HOME="/home/$USERNAME"
M_HOME="/mnt$USER_HOME"
install -d -m 755 "$M_HOME/.local/share" "$M_HOME/.config"
tar -xzf "$NOSIGNAL_PAYLOAD/vendor/caelestia-dotfiles.tar.gz" -C "$M_HOME/.local/share"
chmod u+x "$M_HOME/.local/share/caelestia/hypr/scripts/wsaction.fish" 2>/dev/null || true
# The symlink set upstream install.fish creates (targets are the FINAL paths
# as seen on the booted system, so they dangle here in the live env — fine).
for link in hypr foot fish uwsm btop; do
[ -e "$M_HOME/.local/share/caelestia/$link" ] || continue
ln -sfn "$USER_HOME/.local/share/caelestia/$link" "$M_HOME/.config/$link"
done
[ -e "$M_HOME/.local/share/caelestia/starship.toml" ] \
&& ln -sfn "$USER_HOME/.local/share/caelestia/starship.toml" "$M_HOME/.config/starship.toml"
# --- NoSignal restraint config (Phase 2) ---------------------------------------
# Overrides on top of caelestia's defaults. Pure config — Caelestia's own
# token/appearance system reads these; no shell code is touched, and deleting
# any file restores stock caelestia. Canonical source: the nosignal-shell config
# repo (nosignal/config/); mirrored here to keep the installer self-contained.
echo "==> Applying NoSignal desktop restraint config..."
install -d -m 755 "$M_HOME/.config/caelestia"
# Restrained design tokens: SQUARE corners (rounding ramp zeroed — redesign goal;
# the 77 Tokens.rounding.* bindings honour these, appearance.rounding.scale does
# not feed them; rounding.full omitted so genuine circles stay), tighter
# spacing/padding, denser fonts, snappier animations (overrides Caelestia's
# config-driven Tokens system).
cat > "$M_HOME/.config/caelestia/shell-tokens.json" <<'NOSIGNAL_TOKENS'
{
"appearance": {
"rounding": { "extraSmall": 0, "small": 0, "medium": 0, "large": 0, "largeIncreased": 0, "extraLarge": 0, "extraLargeIncreased": 0, "extraExtraLarge": 0 },
"spacing": { "extraSmall": 3, "small": 6, "medium": 9, "large": 13, "largeIncreased": 16, "extraLarge": 22, "extraLargeIncreased": 26, "extraExtraLarge": 38 },
"padding": { "extraSmall": 3, "small": 6, "medium": 9, "large": 13, "largeIncreased": 16, "extraLarge": 22, "extraLargeIncreased": 26, "extraExtraLarge": 38 },
"fontSize": { "small": 10, "smaller": 11, "normal": 12, "larger": 14, "large": 16, "extraLarge": 24 },
"animDurations": { "small": 120, "normal": 200, "large": 320, "extraLarge": 450, "expressiveFastSpatial": 200, "expressiveDefaultSpatial": 300, "expressiveSlowSpatial": 400, "expressiveFastEffects": 100, "expressiveDefaultEffects": 150, "expressiveSlowEffects": 220 }
}
}
NOSIGNAL_TOKENS
# Appearance restraint + config-level cuts: opaque panels, Material Symbols
# Outlined icons, drop the Performance/Weather dashboard tabs, visualiser off.
# F9: NoSignal launches Hyprland via uwsm, but caelestia's built-in default
# session.commands.logout targets a plain (non-uwsm) session, so the session
# menu's Logout button was a no-op. `uwsm stop` is the documented way to end a
# uwsm session (drops back to SDDM); shutdown/reboot/hibernate use systemctl and
# are left at caelestia's defaults.
cat > "$M_HOME/.config/caelestia/shell.json" <<'NOSIGNAL_SHELL'
{
"appearance": {
"transparency": { "enabled": false },
"rounding": { "scale": 0 },
"font": {
"headline": { "family": "JetBrainsMono Nerd Font" },
"title": { "family": "JetBrainsMono Nerd Font" },
"body": { "family": "JetBrainsMono Nerd Font" },
"label": { "family": "JetBrainsMono Nerd Font" },
"mono": { "family": "JetBrainsMono Nerd Font" },
"icon": { "family": "Material Symbols Outlined" }
}
},
"general": {
"apps": {
"terminal": ["kitty"],
"explorer": ["nautilus"]
}
},
"session": {
"commands": {
"logout": ["uwsm", "stop"]
}
},
"dashboard": { "showPerformance": false, "showWeather": true },
"background": { "visualiser": { "enabled": false } },
"bar": {
"entries": [
{ "id": "logo", "enabled": true },
{ "id": "workspaces", "enabled": true },
{ "id": "spacer", "enabled": true },
{ "id": "clock", "enabled": true },
{ "id": "spacer", "enabled": true },
{ "id": "statusIcons", "enabled": true },
{ "id": "tray", "enabled": true },
{ "id": "power", "enabled": true }
]
}
}
NOSIGNAL_SHELL
# Flatten the Hyprland glassmorphism (blur/shadow off, solid windows, tighter
# corners + gaps). Sourced by caelestia's hyprland.conf right after variables.conf.
cat > "$M_HOME/.config/caelestia/hypr-vars.conf" <<'NOSIGNAL_HYPRVARS'
# NoSignal restraint — flatten Caelestia's glassmorphism (deleting restores stock).
# Default applications (caelestia's stock picks aren't installed: zen-browser/
# codium/thunar — point the Super-key app binds at what NoSignal actually ships).
$terminal = kitty
$browser = chromium
$editor = gnome-text-editor
$fileExplorer = nautilus
$blurEnabled = false
$blurPopups = false
$blurInputMethods = false
$shadowEnabled = false
$windowOpacity = 1.0
$windowRounding = 0
$workspaceGaps = 10
$windowGapsIn = 4
$windowGapsOut = 8
$singleWindowGapsOut = 12
NOSIGNAL_HYPRVARS
# --- Workspace Overview (standalone sidecar) ----------------------------------
# quickshell-overview runs as its OWN qs instance next to caelestia, toggled by
# Super+Tab over IPC. Caelestia's shell is never modified (no fork/package).
echo "==> Installing workspace Overview (sidecar)..."
install -d -m 755 "$M_HOME/.config/quickshell"
tar -xzf "$NOSIGNAL_PAYLOAD/vendor/quickshell-overview.tar.gz" -C "$M_HOME/.config/quickshell"
# theme-match caelestia + low-RAM event previews + no blur
cat > "$M_HOME/.config/quickshell/overview/config.json" <<'NOSIGNAL_OV_CFG'
{
"appearance": { "colorSource": "caelestia" },
"overview": { "previewMode": "event", "previewsEnabled": true, "closeOnFocusLoss": true, "hideEmptyRows": true, "effects": { "enableBlur": false, "enableBackdrop": false, "glassMode": false } }
}
NOSIGNAL_OV_CFG
# --- omarchy-send (LAN file transfer, LocalSend-compatible) -------------------
# Vendored release binary; system-wide so every user gets it. The desktop entry
# opens the TUI in a floating kitty (TUI.float class — float rule above).
echo "==> Installing omarchy-send..."
install -m 755 "$NOSIGNAL_PAYLOAD/vendor/omarchy-send" /mnt/usr/local/bin/omarchy-send
install -d -m 755 /mnt/usr/share/applications /mnt/usr/share/icons/hicolor/scalable/apps
cat > /mnt/usr/share/icons/hicolor/scalable/apps/omarchy-send.svg <<'OMS_ICON'
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<rect width="256" height="256" rx="56" fill="#16161e"/>
<path d="M214 42 L42 114 L110 142 Z" fill="#7aa2f7"/>
<path d="M214 42 L110 142 L130 214 L158 166 Z" fill="#5a7fd6"/>
</svg>
OMS_ICON
cat > /mnt/usr/share/applications/omarchy-send.desktop <<'OMS_DESKTOP'
[Desktop Entry]
Name=Omarchy-Send
Comment=Send & receive files over the LAN (LocalSend-compatible)
Exec=kitty --class TUI.float -e omarchy-send
Icon=omarchy-send
Terminal=false
Type=Application
Categories=Network;FileTransfer;
Keywords=localsend;share;transfer;airdrop;
OMS_DESKTOP
# --- OS theme wallpapers -------------------------------------------------------
# The user's own Moebius-style art set. caelestia's dynamic Material scheme is
# generated FROM the default wallpaper (foam-sea.png, change 18) in the scheme
# step below.
echo "==> Installing NoSignal wallpapers..."
install -d -m 755 "$M_HOME/Pictures/Wallpapers"
tar -xf "$NOSIGNAL_PAYLOAD/vendor/nosignal-wallpapers.tar" -C "$M_HOME/Pictures/Wallpapers"
# Chromium on Wayland: pick the ozone backend automatically (native Wayland
# under Hyprland instead of XWayland).
cat > /mnt/etc/chromium-flags.conf <<'CHROMIUM_FLAGS'
--ozone-platform-hint=auto
CHROMIUM_FLAGS
# autostart the sidecar + toggle bind via caelestia's last-sourced user hook.
# Overview lives on Super+GRAVE (not Tab): the Omarchy keymap appended below
# puts next-workspace on Super+Tab — see dev-docs/BUILDER-HANDOFF.md.
cat > "$M_HOME/.config/caelestia/hypr-user.conf" <<'NOSIGNAL_HYPRUSER'
# NoSignal — overview sidecar autostart + keybind (deleting restores stock).
exec-once = qs -c overview -d
bind = Super, Grave, exec, qs ipc -c overview call overview toggle
# F10: open Caelestia Settings (nexus). Super+Alt+Space is free (NoSignal moved
# the old float-toggle to Super+T). caelestia:nexus is the registered global
# shortcut that opens the Settings window.
bind = Super+Alt, Space, global, caelestia:nexus
# hyprmoncfg writes monitor layout/resolution + workspace assignments here
# (the files are pre-created empty so Hyprland never warns about the source;
# hyprmoncfg refuses to write monitors.conf unless it is sourced).
source = ~/.config/hypr/monitors.conf
source = ~/.config/hypr/workspaces.conf
# omarchy-send launches in a floating terminal window
windowrule = float true, match:class TUI\.float
windowrule = size 1100 700, match:class TUI\.float
# Passwordless-sudo password prompt (Settings -> Services). Dedicated class +
# centered/pinned floating window; SudoToggleRow also dispatches `focuswindow` so
# the prompt grabs keyboard focus (without it the prompt missed keystrokes and
# three blank tries tripped pam_faillock — hardware bug 2026-06-20).
# NB: `stayfocused` is NOT valid in Hyprland 0.55's match: windowrule grammar
# (errors at parse time), so focus is forced from the shell via hyprctl instead.
windowrule = float on, match:class nosignal-sudo
windowrule = size 640 220, match:class nosignal-sudo
windowrule = center on, match:class nosignal-sudo
windowrule = pin on, match:class nosignal-sudo
# polkit auth agent — none ran out of the box, so GUI privilege prompts (pkexec,
# nm-connection-editor system connections, etc.) silently failed. Non-fatal.
exec-once = systemctl --user start hyprpolkitagent.service
NOSIGNAL_HYPRUSER
# Keyboard layout from the installer prompt (appended unquoted — $XKB_LAYOUT).
{
echo ''
echo '# Keyboard layout chosen at install'
echo "input:kb_layout = $XKB_LAYOUT"
} >> "$M_HOME/.config/caelestia/hypr-user.conf"
# F1-B / F11-2: the hypr-user.conf override above was NOT winning — caelestia's
# stock hypr/hyprland/input.conf hardcodes `kb_layout = us` and is sourced such
# that it overrides the variable set above, so the Wayland session AND the lock
# surface came up US-layout on a GB system (mistyped password symbols were F1's
# original trigger). Set the chosen layout in that authoritative file too. The
# dotfiles are extracted once at install (not a package), so this is durable.
# ~/.config/hypr is a symlink into the clone; edit the real file under it.
INPUT_CONF="$M_HOME/.local/share/caelestia/hypr/hyprland/input.conf"
# Fall back to locating whichever file in the hypr tree carries kb_layout, in
# case upstream moves it (keeps the fix working across caelestia layout changes).
if [ ! -f "$INPUT_CONF" ]; then
INPUT_CONF=$(grep -rlE '^[[:space:]]*kb_layout[[:space:]]*=' \
"$M_HOME/.local/share/caelestia/hypr/" 2>/dev/null | head -n1)
fi
if [ -n "$INPUT_CONF" ] && [ -f "$INPUT_CONF" ]; then
if grep -qE '^[[:space:]]*kb_layout[[:space:]]*=' "$INPUT_CONF"; then
sed -i "s|^\([[:space:]]*kb_layout[[:space:]]*=[[:space:]]*\).*|\1$XKB_LAYOUT|" "$INPUT_CONF"
else
printf '\n# Keyboard layout chosen at install (NoSignal)\ninput {\n kb_layout = %s\n}\n' "$XKB_LAYOUT" >> "$INPUT_CONF"
fi
echo " Hyprland kb_layout set to $XKB_LAYOUT in input.conf"
else
echo " NOTE: caelestia input.conf not found — relying on hypr-user.conf kb_layout only"
fi
# Empty targets for hyprmoncfg (live in the dotfiles clone — ~/.config/hypr
# is a symlink into it; hyprmoncfg follows the symlink when saving).
touch "$M_HOME/.local/share/caelestia/hypr/monitors.conf" \
"$M_HOME/.local/share/caelestia/hypr/workspaces.conf"
# kitty: bash is the default shell (NoSignal layer change 2 — Omarchy setup);
# ~/.bashrc applies caelestia's colour-scheme escape sequences at terminal
# start (cat sequences.txt), so kitty gets themed the same way fish did.
install -d -m 755 "$M_HOME/.config/kitty"
cat > "$M_HOME/.config/kitty/kitty.conf" <<'NOSIGNAL_KITTY'
# NoSignal defaults — kitty is the default terminal ($terminal in hypr-vars.conf).
shell bash
font_family JetBrainsMono Nerd Font
font_size 12.0
enable_audio_bell no
confirm_os_window_close 0
NOSIGNAL_KITTY
# fastfetch: the NoSignal layout (Hardware / Software / Age-Uptime boxes + the
# NoSignal logo). Shipped as a real config — NOT symlinked into the caelestia
# clone (fastfetch was dropped from the symlink loop above) so it overrides the
# dotfiles default. The logo lives beside it; config.jsonc points at it.
install -d -m 755 "$M_HOME/.config/fastfetch"
install -m 644 "$NOSIGNAL_PAYLOAD/vendor/fastfetch-config.jsonc" \
"$M_HOME/.config/fastfetch/config.jsonc"
install -m 644 "$NOSIGNAL_PAYLOAD/vendor/fastfetch-logo.txt" \
"$M_HOME/.config/fastfetch/logo.txt"
# ---------------------------------------------- NoSignal layer (changes 1-26) --
# The "os updates" rounds, baked in at install time: keybind cheatsheet, bash
# default shell, nosignal-update mechanism, yay+Shelly store, Omarchy default
# keybindings, omadots developer polish, hyprmoncfg, settings Updates page,
# system polish (1-9); universal copy/paste, screenshots, the launcher/
# dashboard/monitor fix-ups, gaming enablement, SDDM + themed greeter (10-16,
# 18-19); Wi-Fi wrong-password recovery, monitor profile hot-load, yay-menu
# pre-answers, tidied cheatsheet, CLIAmp music player, Settings Additions
# page, uuctl hidden (20-26, the 2026-06-12 hardware-test round). Change 17
# (deckshift-login) SHIPS in the tree but is NOT applied — gaming is opt-in
# post-install. The packages came from pacstrap; everything else comes from
# the layer tree itself, so the ISO and a live-updated box are identical.
# Conf fragments are appended FROM the extracted tree so they cannot drift.
nsi_phase "Applying the NoSignal layer"
echo "==> Installing NoSignal layer (os-updates round)..."
tar -xzf "$NOSIGNAL_PAYLOAD/vendor/nosignal-layer.tar.gz" -C "$M_HOME/.local/share"
LAYER="$M_HOME/.local/share/nosignal"
chmod +x "$LAYER/nosignal-update/bin/nosignal-update" "$LAYER/nosignal-update/migrations/"*.sh
# On-box management skill: a fresh Claude Code on this machine auto-discovers
# the `nosignal` skill (its description is surfaced in the available-skills list)
# and can help the USER configure / fix / maintain their install — strictly
# end-user system management, NOT the dev/QA test protocol. The skill's
# references/ are copied from the layer's single-source docs at install time, so
# they track the build and can never drift (the staleness trap the frozen
# ONBOX-TEST-NOTES.md fell into). The originals stay in ~/.local/share/nosignal/.
SKILL_DIR="$M_HOME/.claude/skills/nosignal"
install -d -m 755 "$SKILL_DIR/references"
install -m 644 "$LAYER/onbox-skill/SKILL.md" "$SKILL_DIR/SKILL.md"
install -m 644 "$LAYER/ONBOX-AI-NOTES.md" "$SKILL_DIR/references/ONBOX-AI-NOTES.md"
install -m 644 "$LAYER/NoSignal-keybindings.md" "$SKILL_DIR/references/NoSignal-keybindings.md"
# Commands onto ~/.local/bin (already on PATH via the profile drop above).
# nosignal-update stays a symlink INTO the layer tree — it locates its
# migrations by resolving its own path.
install -d -m 755 "$M_HOME/.local/bin"
install -m 755 "$LAYER/nosignal-keybinds" "$LAYER/nosignal-keybinds-gen" \
"$LAYER/system-polish/nosignal-webapp-install" \
"$LAYER/system-polish/nosignal-webapp-remove" \
"$LAYER/system-polish/nosignal-welcome" \
"$LAYER/updates-panel/nosignal-update-check" \
"$LAYER/super-clipboard/super-copy" \
"$LAYER/super-clipboard/super-paste" \
"$LAYER/screenshots/nosignal-screenshot" \
"$LAYER/additions-installer/nosignal-additions" \
"$LAYER/app-theme-awareness/nosignal-app-theme-sync" \
"$LAYER/kernel-reboot-notify/nosignal-reboot-check" \
"$M_HOME/.local/bin/"
# Omarchy CLI shims (change 15) — let Omarchy-targeted gaming installers
# (DeckShift) run on NoSignal: omarchy-pkg-add -> yay, steam installer, NVIDIA
# GSP probes, restart-walker no-op.
install -m 755 "$LAYER/gaming-enablement/omarchy-pkg-add" \
"$LAYER/gaming-enablement/omarchy-install-gaming-steam" \
"$LAYER/gaming-enablement/omarchy-hw-nvidia-gsp" \
"$LAYER/gaming-enablement/omarchy-hw-nvidia-without-gsp" \
"$LAYER/gaming-enablement/omarchy-restart-walker" \
"$M_HOME/.local/bin/"
ln -sf "$USER_HOME/.local/share/nosignal/nosignal-update/bin/nosignal-update" \
"$M_HOME/.local/bin/nosignal-update"
# --- change 27 (xdg-terminal-exec-handler, finding F2): app2unit hardcodes its
# terminal handler to `xdg-terminal-exec`, which NoSignal never shipped, so any
# Terminal=true desktop entry (and app2unit -T) failed with a critical
# notification. Ship a small shim on PATH that launches the configured terminal
# ($TERMINAL, else kitty), plus a system xdg-terminals.list pointing at kitty.
# (The shim self-removes if the real freedesktop package ever lands in a system
# bin — see the component installer.)
install -m 755 "$LAYER/xdg-terminal-exec-handler/xdg-terminal-exec" \
"$M_HOME/.local/bin/xdg-terminal-exec"
install -d -m 755 /mnt/etc/xdg
printf 'kitty.desktop\n' > /mnt/etc/xdg/xdg-terminals.list
chmod 644 /mnt/etc/xdg/xdg-terminals.list
# Mark every shipped migration as applied — the layer is baked in, so the
# user's first `nosignal-update` must report "0 new migrations" (they are
# idempotent, but re-running needs network/sudo and is noisy). Do NOT create
# the `welcomed` stamp: the first-login welcome notice must fire.
install -d -m 755 "$M_HOME/.local/state/nosignal"
for m in "$LAYER/nosignal-update/migrations/"*.sh; do
basename "$m"
done > "$M_HOME/.local/state/nosignal/applied"
# --- change 29 (limine-uki-dead-entry, finding F4, BLOCKER): FIXED IN THE SEED.
# The dead-boot is now prevented at the source — the seeded limine.conf above
# ships a `protocol: efi` -> UKI entry as entry 1 with `default_entry: 1`, plus a
# `protocol: linux` fallback as entry 2. Limine preserves these manual entries +
# `default_entry` verbatim across every kernel-update regeneration, so the
# default boot target can never become the dead vmlinuz entry. No post-install
# conversion is needed (the old one-shot conversion mis-fired on 20260613). The
# limine-uki-dead-entry/ component is kept in the layer ONLY as a manual repair
# tool for boxes installed from a pre-fix ISO; it is NOT run at install anymore.
# --- change 2: default shell bash, matching Omarchy. The fish PACKAGE stays
# (caelestia-meta hard-depends on it) and the hypr config keeps calling the
# *.fish script paths — replaced with bash ports under the same filenames.
echo "==> Default shell: bash (Omarchy setup)..."
install -d -m 755 "$M_HOME/.local/share/omarchy/default"
cp -a "$LAYER/fish-to-bash/bash" "$M_HOME/.local/share/omarchy/default/bash"
install -m 644 "$LAYER/fish-to-bash/bashrc" "$M_HOME/.bashrc"
install -m 755 "$LAYER/fish-to-bash/hypr-wsaction.bash" \
"$M_HOME/.local/share/caelestia/hypr/scripts/wsaction.fish"
install -m 755 "$LAYER/fish-to-bash/hypr-configs.bash" \
"$M_HOME/.local/share/caelestia/hypr/scripts/configs.fish"
# foot config lives in the dotfiles clone (~/.config/foot is a symlink)
sed -i 's/^shell=.*/shell=bash/' "$M_HOME/.local/share/caelestia/foot/foot.ini"
# Square corners everywhere (redesign goal): caelestia's rules.conf hardcodes
# `rounding 10` on Steam + some XWayland windows, ignoring $windowRounding=0.
# Zero them so those windows aren't the only rounded ones on the desktop.
sed -i -E 's/^(windowrule = )rounding 10,/\1rounding 0,/' \
"$M_HOME/.local/share/caelestia/hypr/hyprland/rules.conf" 2>/dev/null || true
# --- change 6: omadots developer polish. starship/btop replace the caelestia
# symlinks made above (re-linking restores stock); git behaviors arrive via
# include.path so user identity stays out of the image; LazyVim from vendor.
echo "==> omadots developer polish..."
rm -f "$M_HOME/.config/starship.toml"
install -m 644 "$LAYER/omadots-extras/starship.toml" "$M_HOME/.config/starship.toml"
rm -f "$M_HOME/.config/btop"
install -d -m 755 "$M_HOME/.config/btop" "$M_HOME/.config/tmux" "$M_HOME/.config/git"
install -m 644 "$LAYER/omadots-extras/btop.conf" "$M_HOME/.config/btop/btop.conf"
install -m 644 "$LAYER/omadots-extras/tmux.conf" "$M_HOME/.config/tmux/tmux.conf"
install -m 644 "$LAYER/omadots-extras/omadots.gitconfig" "$M_HOME/.config/git/omadots.gitconfig"
cat > "$M_HOME/.gitconfig" <<GITINCLUDE
# NoSignal — omadots git aliases/behaviors. Your identity goes here, per-user.
[include]
path = $USER_HOME/.config/git/omadots.gitconfig
GITINCLUDE
tar -xzf "$NOSIGNAL_PAYLOAD/vendor/lazyvim-starter.tar.gz" -C "$M_HOME/.config"
# --- changes 1+4+5+7+9+10+11: keybindings + binds, appended from the layer
# tree. omarchy-keys-vars remaps the $kb* variables (hypr-vars.conf is sourced
# before keybinds.conf consumes them); the user-conf fragments land last.
# Changes 10+11 are the marked blocks from the component installers, extracted
# verbatim (markers included) so a later nosignal-update re-run of those
# installers detects them as already present and skips.
echo "==> Omarchy keymap + layer binds..."
{
echo ''
cat "$LAYER/omarchy-keys/omarchy-keys-vars.conf"
} >> "$M_HOME/.config/caelestia/hypr-vars.conf"
{
echo ''
cat "$LAYER/hyprland-keybinds-help.conf"
echo ''
cat "$LAYER/software-install/hyprland-software-install.conf"
echo ''
cat "$LAYER/omarchy-keys/omarchy-keys-user.conf"
echo ''
cat "$LAYER/monitor-control/hyprland-monitor-control.conf"
echo ''
sed -n '/# >>> super-clipboard/,/# <<< super-clipboard/p' \
"$LAYER/super-clipboard/install-super-clipboard.sh"
echo ''
sed -n '/# >>> nosignal screenshots/,/# <<< nosignal screenshots/p' \
"$LAYER/screenshots/install-screenshots.sh"
echo ''
# change 24: CLIAmp marked block (float rules + Super+M launch-or-focus) —
# shipped as a standalone conf fragment, appended VERBATIM markers included.
cat "$LAYER/cliamp-music/hyprland-cliamp.conf"
echo ''
echo '# NoSignal — one-time first-login welcome (stamps ~/.local/state/nosignal/welcomed)'
echo 'exec-once = ~/.local/bin/nosignal-welcome'
} >> "$M_HOME/.config/caelestia/hypr-user.conf"
# --- change 11: screenshots land in ~/Pictures/Screenshots (clipboard too);
# swappy (the caelestia region/freeze annotate binds) saves to the same place.
install -d -m 755 "$M_HOME/Pictures/Screenshots" "$M_HOME/.config/swappy"
install -m 644 "$LAYER/screenshots/swappy-config" "$M_HOME/.config/swappy/config"
# --- changes 7+8: user services. No systemd in the chroot — enable = the
# symlink systemctl would create, targets read from each unit's [Install].
echo "==> Enabling user services (hyprmoncfgd, update check timer)..."
install -d -m 755 "$M_HOME/.config/systemd/user/default.target.wants" \
"$M_HOME/.config/systemd/user/timers.target.wants"
install -m 644 "$LAYER/updates-panel/nosignal-update-check.service" \
"$LAYER/updates-panel/nosignal-update-check.timer" \
"$M_HOME/.config/systemd/user/"
ln -sf ../nosignal-update-check.timer \
"$M_HOME/.config/systemd/user/timers.target.wants/nosignal-update-check.timer"
ln -sf /usr/lib/systemd/user/hyprmoncfgd.service \
"$M_HOME/.config/systemd/user/default.target.wants/hyprmoncfgd.service"
# change 12 (fix for change 7): point hyprmoncfgd at hypr-user.conf (the file
# that actually sources monitors.conf — it refuses to write otherwise) and
# gate its start until Hyprland's IPC answers (avoids a login-race error
# notification). Same flags are baked into the Super+Ctrl+H bind above.
install -d -m 755 "$M_HOME/.config/systemd/user/hyprmoncfgd.service.d"
install -m 644 "$LAYER/monitor-control-fix/hyprmoncfgd-override.conf" \
"$M_HOME/.config/systemd/user/hyprmoncfgd.service.d/override.conf"
# change 21 (monitor-hotload): path unit watches ~/.config/hyprmoncfg/profiles
# and try-restarts hyprmoncfgd, so a plain "Save Profile" in the Super+Ctrl+H
# TUI applies live. The watched dir must exist (empty — the no-pre-seeded-
# profiles rule from change 7 still stands).
install -m 644 "$LAYER/monitor-hotload/hyprmoncfgd-rescan.path" \
"$LAYER/monitor-hotload/hyprmoncfgd-rescan.service" \
"$M_HOME/.config/systemd/user/"
ln -sf ../hyprmoncfgd-rescan.path \
"$M_HOME/.config/systemd/user/default.target.wants/hyprmoncfgd-rescan.path"
install -d -m 755 "$M_HOME/.config/hyprmoncfg/profiles"
# --- change 30 (app-theme-awareness, finding F6): make external apps
# (Chrome/Electron/Firefox/GTK/Qt) follow Caelestia's light/dark mode. The sync
# script (installed to ~/.local/bin above) mirrors `caelestia scheme get` to the
# freedesktop appearance portal color-scheme + the GTK prefer-dark flag; a .path
# unit re-runs it whenever Caelestia's scheme.json changes, and .service runs it
# at login. portals.conf pins the Settings portal to gtk so the value is served.
echo "==> Enabling app-theme awareness (follow Caelestia dark/light)..."
install -m 644 "$LAYER/app-theme-awareness/nosignal-app-theme.service" \
"$LAYER/app-theme-awareness/nosignal-app-theme.path" \
"$M_HOME/.config/systemd/user/"
ln -sf ../nosignal-app-theme.path \
"$M_HOME/.config/systemd/user/default.target.wants/nosignal-app-theme.path"
ln -sf ../nosignal-app-theme.service \
"$M_HOME/.config/systemd/user/default.target.wants/nosignal-app-theme.service"
install -d -m 755 "$M_HOME/.config/xdg-desktop-portal"
install -m 644 "$LAYER/app-theme-awareness/portals.conf" \
"$M_HOME/.config/xdg-desktop-portal/portals.conf"
# --- changes 8/25/20: Updates page, Additions page, and Wi-Fi wrong-password
# recovery are now BAKED INTO the pinned nosignal-shell fork (Phase 2 — we own
# the shell, so the old patch-script + pacman-hook machinery that re-applied
# these after every caelestia-shell upgrade is gone). The Updates/Additions
# CLIs + units are still installed above; only the shell-side QML edits moved
# into the fork source. The Additions status cache is still seeded below.
install -d -m 755 /mnt/etc/pacman.d/hooks
# --- change 31 (kernel-reboot-notify, finding F5): a pacman PostTransaction
# hook that prints a "reboot required" reminder (and "boot the UKI entry") in
# the pacman/yay output whenever a kernel image changes, so out-of-band
# `pacman -Syu`/`yay` updates warn too. The desktop notification + state stamp
# are handled by nosignal-reboot-check, which nosignal-update calls at its end.
echo "==> Installing kernel-reboot reminder pacman hook..."
install -m 644 "$LAYER/kernel-reboot-notify/95-nosignal-kernel-reboot.hook" \
/mnt/etc/pacman.d/hooks/95-nosignal-kernel-reboot.hook
# --- change 28 (caelestia-lock-faillock, finding F1, BLOCKER): the faillock-free
# lock-screen PAM service (assets/pam.d/caelestia) and the Pam.qml repoint
# (config: "passwd" -> "caelestia") are now BAKED INTO the pinned nosignal-shell
# fork (Phase 2). No patch script or pacman hook needed — the fork ships the
# correct lock auth, so a desktop screen-lock can never faillock the user out
# of their own session.
# --- change 36 (sudo-timed-nopasswd): a Settings -> Services toggle (+ CLI)
# that grants the user NOPASSWD: ALL for 15 minutes then auto-reverts (a root
# systemd timer), with a boot-clean safety net and a sudoers `visudo -cf`
# validation before install. Installs the CLI + boot-clean service + the panel
# toggle; does NOT enable sudoless (that's a deliberate user action). The
# SudoToggleRow + its ServicesPage insert are now BAKED INTO the pinned
# nosignal-shell fork (Phase 2), so NOSIGNAL_SKIP_SHELL_PATCH tells the installer
# to skip the (now redundant) QML patch and no pacman hook is written. The CLI +
# boot-clean safety-net service still install here.
echo "==> Installing time-boxed passwordless-sudo toggle..."
arch-chroot /mnt env NOSIGNAL_SKIP_SHELL_PATCH=1 sh "$USER_HOME/.local/share/nosignal/sudo-timed-nopasswd/install-sudo-timed-nopasswd.sh" \
|| echo " (sudo-timed-nopasswd install failed — CLI/toggle may be absent)"
# --- change 37 (cachyos-repo-switch): a Settings -> Services toggle (+ CLI) that
# switches the system to/from CachyOS optimized builds + the linux-cachyos kernel,
# passwordless and auto-detecting the best x86-64 tier (v4 > v3). Installs the CLI
# + the visudo-validated sudoers drop-in + the panel toggle; does NOT enable the
# CachyOS repos (a deliberate user action — heavy, networked, reboots the kernel).
# CachyRepoToggleRow + its ServicesPage insert are BAKED INTO the pinned
# nosignal-shell fork (Phase 2), so NOSIGNAL_SKIP_SHELL_PATCH skips the redundant
# QML patch. The CLI + sudoers install here. (gawk/curl/kitty/limine already in
# the base closure; the helper live-fetches the CachyOS keyring/mirrorlist/awk at
# enable time — enabling CachyOS is online by nature.)
echo "==> Installing CachyOS repo + kernel switch toggle..."
arch-chroot /mnt env NOSIGNAL_SKIP_SHELL_PATCH=1 sh "$USER_HOME/.local/share/nosignal/cachyos-repo-switch/install-cachyos-repo-switch.sh" \
|| echo " (cachyos-repo-switch install failed — CLI/toggle may be absent)"
# --- change 4: Flathub remote for Shelly's flatpak pages. The vendored
# .flatpakrepo embeds the GPG key, so this is an offline-safe config write.
# Staged in /root, NOT /tmp: arch-chroot mounts a fresh tmpfs over the
# target's /tmp, which shadows anything copied there from outside.
echo "==> Configuring Flathub remote..."
cp "$NOSIGNAL_PAYLOAD/vendor/flathub.flatpakrepo" /mnt/root/flathub.flatpakrepo
arch-chroot /mnt flatpak remote-add --if-not-exists flathub /root/flathub.flatpakrepo \
|| echo " (flathub remote-add failed — once online run: flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo)"
rm -f /mnt/root/flathub.flatpakrepo
# --- changes 9+26: hide launcher clutter via per-user overrides. The PACKAGES
# stay (avahi-daemon is needed for .local/printer discovery, foot is a
# caelestia-meta dep, uuctl ships with uwsm) — only their menu entries
# disappear.
echo "==> Hiding launcher clutter..."
install -d -m 755 "$M_HOME/.local/share/applications"
for id in avahi-discover bssh bvnc qv4l2 qvidcap foot footclient foot-server \
org.gnupg.pinentry-qt org.gnupg.pinentry-qt5 uuctl; do
[ -f "/mnt/usr/share/applications/$id.desktop" ] || continue
cat > "$M_HOME/.local/share/applications/$id.desktop" <<'HIDDEN_ENTRY'
[Desktop Entry]
Type=Application
Name=Hidden by NoSignal
NoDisplay=true
Hidden=true
HIDDEN_ENTRY
done
# (change 9's printing piece — cups.socket — is already enabled in the base.)
# --- hardware-test 2026-06-15 (finding F-T2): stop the redundant blueman tray
# applet from autostarting. The base ships /etc/xdg/autostart/blueman.desktop,
# but blueman-applet duplicates the redesign's own bar Bluetooth pill + BlueZ
# panel (and was the only thing keeping the now-gated system-tray pill visible).
# A per-user XDG autostart override (Hidden=true) suppresses just the applet;
# the blueman package stays so blueman-manager can still be launched by hand and
# Bluetooth control is unaffected.
echo "==> Disabling redundant blueman tray applet..."
install -d -m 755 "$M_HOME/.config/autostart"
cat > "$M_HOME/.config/autostart/blueman.desktop" <<'BLUEMAN_OFF'
[Desktop Entry]
Type=Application
Name=blueman-applet
Comment=NoSignal: redundant with the bar Bluetooth pill + BlueZ panel — disabled
Hidden=true
X-GNOME-Autostart-enabled=false
BLUEMAN_OFF
chmod 644 "$M_HOME/.config/autostart/blueman.desktop"
# --- change 24: CLIAmp default music player. The package came from pacstrap
# ([omarchy] repo); the Super+M bind is the marked block appended above. Here:
# the desktop entry (launches in kitty, class cliamp) + the audio MIME
# defaults. Nothing else writes ~/.config/mimeapps.list at install time (the
# dotfiles ship none), so a plain write is safe.
echo "==> CLIAmp default music player..."
install -m 644 "$LAYER/cliamp-music/cliamp.desktop" \
"$M_HOME/.local/share/applications/cliamp.desktop"
{
echo '[Default Applications]'
for m in audio/mpeg audio/mp4 audio/x-m4a audio/aac audio/flac audio/x-flac \
audio/ogg audio/x-vorbis+ogg audio/x-opus+ogg audio/wav audio/x-wav \
audio/webm; do
echo "$m=cliamp.desktop"
done
} > "$M_HOME/.config/mimeapps.list"
chmod 644 "$M_HOME/.config/mimeapps.list"
# --- change 19: SDDM greeter themed to match the desktop. Theme dir + sync
# script + [Theme] drop-in baked from the layer tree. The shipped theme.conf
# already carries the foam-sea Material palette (matching the change-18
# default wallpaper), so the first-boot greeter matches the desktop before
# any sync has run; the background is seeded from the same wallpaper here.
# sddm-theme-sync needs a logged-in user to mirror, so it is NOT run in the
# chroot — after a wallpaper change the user runs `sudo sddm-theme-sync`.
echo "==> Installing SDDM greeter theme (caelestia)..."
install -d -m 755 /mnt/usr/share/sddm/themes/caelestia/backgrounds
install -m 644 "$LAYER/sddm-theme/caelestia/Main.qml" \
/mnt/usr/share/sddm/themes/caelestia/Main.qml
install -m 644 "$LAYER/sddm-theme/caelestia/metadata.desktop" \
/mnt/usr/share/sddm/themes/caelestia/metadata.desktop
install -m 644 "$LAYER/sddm-theme/caelestia/theme.conf" \
/mnt/usr/share/sddm/themes/caelestia/theme.conf
install -m 644 "$M_HOME/Pictures/Wallpapers/nosignal/foam-sea.png" \
/mnt/usr/share/sddm/themes/caelestia/backgrounds/wallpaper.png
install -m 755 "$LAYER/sddm-theme/sddm-theme-sync" /mnt/usr/local/bin/sddm-theme-sync
cat > /mnt/etc/sddm.conf.d/20-sddm-theme.conf <<'SDDMTHEME'
# Greeter theme matching the desktop scheme (sddm-theme component).
# Remove this file to fall back to SDDM's default greeter.
[Theme]
Current=caelestia
SDDMTHEME
chmod 644 /mnt/etc/sddm.conf.d/20-sddm-theme.conf
arch-chroot /mnt chown -R "$USERNAME:$USERNAME" "$USER_HOME"
# --- colour scheme + wallpaper -------------------------------------------------
# The OS theme: set the default Moebius wallpaper (foam-sea.png, change 18 —
# matches the baked SDDM greeter theme), then switch the scheme to
# `dynamic` so caelestia generates the Material palette FROM the wallpaper
# (whole shell + terminals pick it up). Both CLI calls are headless-safe (PIL +
# file writes, no compositor needed; verified the same way scheme-set was).
# Order matters: `wallpaper -f` first (writes the state + thumbnail), THEN
# `scheme set -n dynamic` (regenerates colours from that thumbnail).
# Fallbacks keep the old behaviour: shadotheme scheme, no wallpaper state.
echo "==> Seeding caelestia theme (foam-sea wallpaper + dynamic scheme)..."
as_user() {
arch-chroot /mnt runuser -u "$USERNAME" -- \
env HOME="$USER_HOME" XDG_STATE_HOME="$USER_HOME/.local/state" \
XDG_CONFIG_HOME="$USER_HOME/.config" XDG_CACHE_HOME="$USER_HOME/.cache" \
"$@"
}
WALL="$USER_HOME/Pictures/Wallpapers/nosignal/foam-sea.png"
if as_user caelestia wallpaper -f "$WALL" >/dev/null 2>&1 \
&& as_user caelestia scheme set -n dynamic >/dev/null 2>&1 \
&& [ -f "$M_HOME/.local/state/caelestia/scheme.json" ]; then
echo " Dynamic scheme generated from $WALL"
else
echo " Dynamic scheme failed in chroot — falling back to shadotheme."
as_user caelestia scheme set -n shadotheme >/dev/null 2>&1 || true
fi
if [ ! -f "$M_HOME/.local/state/caelestia/scheme.json" ]; then
install -d -m 755 "$M_HOME/.local/state/caelestia"
install -m 644 "$NOSIGNAL_PAYLOAD/vendor/scheme-shadotheme.json" \
"$M_HOME/.local/state/caelestia/scheme.json"
fi
# --- change 25 (cont.): seed the Additions status cache so the page has data
# on first open (all 15 items not-installed on a fresh image — the page IS the
# opt-in). Needs as_user, hence here rather than next to the QML patch.
as_user "$USER_HOME/.local/bin/nosignal-additions" status >/dev/null 2>&1 \
|| echo " (Additions status seed failed — the page populates on first re-check)"
arch-chroot /mnt chown -R "$USERNAME:$USERNAME" "$USER_HOME/.local/state" 2>/dev/null || true
# --------------------------------------------------------- install cleanup ---
# The file:// repo must not leak into the installed system: drop its synced db
# (the [nosignal] repo is not in the target's pacman.conf) and clear the package
# cache copies pacstrap made (~3.5GB — the install media still has them all).
nsi_phase "Finishing up"
echo "==> Cleaning install-time package cache..."
rm -f /mnt/var/lib/pacman/sync/nosignal.*
rm -f /mnt/var/cache/pacman/pkg/*
# Heavy phase done — stop the spinner, restore on-screen output, show a centred
# completion screen.
nsi_spin_stop
trap - ERR
exec >/dev/tty 2>&1
clear
tcecho "NoSignal install complete" "$NSI_GB" "$NSI_R"
printf '\n' >/dev/tty
tcecho "Remove the install media and reboot." "$NSI_B" "$NSI_R"
printf '\n' >/dev/tty
tcecho "First boot goes straight to the graphical login (SDDM, themed) —" "$NSI_DIM" "$NSI_R"
tcecho "log in and the themed Hyprland desktop starts. No internet needed;" "$NSI_DIM" "$NSI_R"
tcecho "run 'sudo pacman -Syu' once when you're online to sync databases." "$NSI_DIM" "$NSI_R"
printf '\n' >/dev/tty
PROMPT="Press ENTER to reboot now (Ctrl+C for a shell)... "
printf '%s' "$(nsi_pad "${#PROMPT}")" >/dev/tty
read -rp "$PROMPT" </dev/tty
# Flush ALL pending writes to disk before unmounting. With the btrfs subvolume
# layout there are 5 mounts under /mnt; `umount -R` can fail partway (it's
# tolerated below), which previously left the just-written bootloader files
# (limine.conf, BOOTX64.EFI) cached-but-unflushed on the FAT ESP — they ended up
# 0 bytes on disk and the machine wouldn't boot. An explicit sync guarantees the
# ESP writes hit the block device regardless of how the unmount goes.
sync
umount -R /mnt || true
sync
reboot
__INSTALLER_PAYLOAD__
chmod +x "$1"
}
# ===========================================================================
# build_offline_payload — assemble ./offline/iso (mapped to /nosignal on the
# ISO): the complete pacman repo, vendored source tarballs, and the base
# package list. Cached: AUR builds stamp offline/aur/.built-*, downloads skip
# files already present.
# ===========================================================================
build_offline_payload() {
echo "==> Building offline payload (cached in $OFFLINE)..."
mkdir -p "$OFFLINE/iso/repo" "$OFFLINE/iso/vendor" "$OFFLINE/aur"
# ---- vendored source tarballs (pinned) ----------------------------------
if [ ! -f "$OFFLINE/iso/vendor/caelestia-dotfiles.tar.gz" ]; then
echo "==> Vendoring caelestia dotfiles ($CAELESTIA_DOTS_COMMIT)..."
rm -rf "$OFFLINE/aur/caelestia"
git clone "$CAELESTIA_DOTS_REPO" "$OFFLINE/aur/caelestia"
git -C "$OFFLINE/aur/caelestia" checkout -q "$CAELESTIA_DOTS_COMMIT"
# Tarball root dir is 'caelestia' -> extracts to ~/.local/share/caelestia.
# .git is kept so the user can pull/diff upstream later.
tar -czf "$OFFLINE/iso/vendor/caelestia-dotfiles.tar.gz" -C "$OFFLINE/aur" caelestia
fi
if [ ! -f "$OFFLINE/iso/vendor/quickshell-overview.tar.gz" ]; then
echo "==> Vendoring quickshell-overview ($OVERVIEW_COMMIT)..."
rm -rf "$OFFLINE/aur/overview"
git clone "$OVERVIEW_REPO" "$OFFLINE/aur/overview"
git -C "$OFFLINE/aur/overview" checkout -q "$OVERVIEW_COMMIT"
# Tarball root dir is 'overview' -> extracts to ~/.config/quickshell/overview.
tar -czf "$OFFLINE/iso/vendor/quickshell-overview.tar.gz" -C "$OFFLINE/aur" overview
fi
# OS theme wallpapers — repo-local user art, re-tarred EVERY build (like the
# layer tree) so image swaps always reach the next ISO. PNGs don't compress,
# so plain tar. Stale Last Horizon tarballs from older builds are removed.
if [ ! -d "$WALLPAPERS_DIR" ] || [ ! -f "$WALLPAPERS_DIR/$DEFAULT_WALLPAPER" ]; then
echo "ERROR: wallpapers not found at $WALLPAPERS_DIR (need $DEFAULT_WALLPAPER)" >&2
exit 1
fi
echo "==> Vendoring OS theme wallpapers..."
rm -f "$OFFLINE/iso/vendor/lasthorizon-wallpapers.tar.gz"
# Tarball root dir is 'nosignal' -> extracts to ~/Pictures/Wallpapers/nosignal.
tar -cf "$OFFLINE/iso/vendor/nosignal-wallpapers.tar" \
-C "$(dirname "$WALLPAPERS_DIR")" \
--transform 's|^wallpapers|nosignal|' wallpapers
# Plymouth boot-splash theme (NO SIGNAL logo on black) — repo-local, re-tarred
# EVERY build (like the wallpapers/layer) so logo/script edits reach the ISO.
# Tarball root dir is 'nosignal' -> extracts to
# /usr/share/plymouth/themes/nosignal.
if [ ! -f "$SCRIPT_DIR/assets/plymouth/nosignal/nosignal.script" ]; then
echo "ERROR: Plymouth theme not found at $SCRIPT_DIR/assets/plymouth/nosignal" >&2
exit 1
fi
echo "==> Vendoring Plymouth boot-splash theme..."
tar -czf "$OFFLINE/iso/vendor/nosignal-plymouth.tar.gz" \
-C "$SCRIPT_DIR/assets/plymouth" nosignal
if [ ! -f "$OFFLINE/iso/vendor/lazyvim-starter.tar.gz" ]; then
echo "==> Vendoring LazyVim starter ($LAZYVIM_COMMIT)..."
rm -rf "$OFFLINE/aur/lazyvim-starter"
git clone "$LAZYVIM_REPO" "$OFFLINE/aur/lazyvim-starter"
git -C "$OFFLINE/aur/lazyvim-starter" checkout -q "$LAZYVIM_COMMIT"
rm -rf "$OFFLINE/aur/lazyvim-starter/.git"
# Tarball root dir is 'nvim' -> extracts to ~/.config/nvim.
tar -czf "$OFFLINE/iso/vendor/lazyvim-starter.tar.gz" -C "$OFFLINE/aur" \
--transform 's|^lazyvim-starter|nvim|' lazyvim-starter
fi
if [ ! -f "$OFFLINE/iso/vendor/flathub.flatpakrepo" ]; then
echo "==> Vendoring Flathub remote definition..."
curl -fSL --proto '=https' --tlsv1.2 \
-o "$OFFLINE/iso/vendor/flathub.flatpakrepo" "$FLATHUB_REPO_URL"
fi
# NoSignal layer tree — local source, small, and actively iterated on: re-tar
# it EVERY build (no cache stamp) so layer edits always reach the next ISO.
if [ ! -d "$NOSIGNAL_LAYER_DIR" ]; then
echo "ERROR: NoSignal layer tree not found at $NOSIGNAL_LAYER_DIR" >&2
exit 1
fi
echo "==> Vendoring NoSignal layer tree..."
# Tarball root dir is 'nosignal' -> extracts to ~/.local/share/nosignal.
tar -czf "$OFFLINE/iso/vendor/nosignal-layer.tar.gz" \
-C "$(dirname "$NOSIGNAL_LAYER_DIR")" \
--transform 's|^os updates|nosignal|' "os updates"
if [ ! -f "$OFFLINE/iso/vendor/omarchy-send" ]; then
echo "==> Vendoring omarchy-send ($OMARCHY_SEND_VERSION)..."
curl -fSL --proto '=https' --tlsv1.2 -o "$OFFLINE/iso/vendor/omarchy-send" \
"https://github.com/$OMARCHY_SEND_REPO/releases/download/$OMARCHY_SEND_VERSION/omarchy-send-linux-amd64"
chmod 755 "$OFFLINE/iso/vendor/omarchy-send"
fi
install -m 644 "$SCRIPT_DIR/assets/scheme-shadotheme.json" "$OFFLINE/iso/vendor/"
install -m 644 "$SCRIPT_DIR/assets/fastfetch-config.jsonc" \
"$SCRIPT_DIR/assets/fastfetch-logo.txt" "$OFFLINE/iso/vendor/"
# ---- bootstrap repo db (so the build chroot can reference it from day 0) -
if [ ! -e "$OFFLINE/iso/repo/nosignal.db" ]; then
tar -czf "$OFFLINE/iso/repo/nosignal.db.tar.gz" --files-from /dev/null
tar -czf "$OFFLINE/iso/repo/nosignal.files.tar.gz" --files-from /dev/null
ln -sf nosignal.db.tar.gz "$OFFLINE/iso/repo/nosignal.db"
ln -sf nosignal.files.tar.gz "$OFFLINE/iso/repo/nosignal.files"
fi
# ---- build chroot --------------------------------------------------------
# Clean-room AUR builds via devtools. The chroot's pacman.conf includes the
# local bootstrap repo: arch-nspawn auto-bind-mounts file:// Server dirs, so
# each built package becomes resolvable by the next build (caelestia-shell
# needs caelestia-cli + quickshell-git etc.).
cat > "$OFFLINE/chroot-pacman.conf" <<CHROOTPAC
[options]
Architecture = auto
SigLevel = Required DatabaseOptional
LocalFileSigLevel = Optional
ParallelDownloads = 8
[nosignal]
SigLevel = Optional TrustAll
Server = file://$OFFLINE/iso/repo
[core]
Include = /etc/pacman.d/mirrorlist
[extra]
Include = /etc/pacman.d/mirrorlist
CHROOTPAC
if [ ! -d "$OFFLINE/chroot/root" ]; then
echo "==> Creating clean build chroot (devtools mkarchroot)..."
mkdir -p "$OFFLINE/chroot"
sudo mkarchroot -C "$OFFLINE/chroot-pacman.conf" "$OFFLINE/chroot/root" base-devel
fi
# ---- AUR packages (clean-chroot builds, dependency order) ----------------
local name p
for name in "${AUR_BUILD_ORDER[@]}"; do
if [ -f "$OFFLINE/aur/.built-$name" ]; then
echo " $name: cached."
continue
fi
echo "==> Building AUR package: $name..."
rm -rf "${OFFLINE:?}/aur/$name"
if [ "$name" = "nosignal-shell" ]; then
# NOT an AUR package — it's our fork of the caelestia shell. Pull the
# PKGBUILD from the fork at the pinned commit and pin the git source to
# the same commit (reproducible, exactly like quickshell-git below). The
# PKGBUILD already carries DISTRIBUTOR + the `git` makedepend that the
# shell's M3Shapes CMake FetchContent needs in a clean chroot, so no
# further patching is required here.
mkdir -p "$OFFLINE/aur/$name"
git clone --depth 1 "$NOSIGNAL_SHELL_REPO" -b nosignal "$OFFLINE/aur/$name.fork"
( cd "$OFFLINE/aur/$name.fork" && git fetch --depth 1 origin "$NOSIGNAL_SHELL_COMMIT" && git checkout -q "$NOSIGNAL_SHELL_COMMIT" )
cp "$OFFLINE/aur/$name.fork/packaging/PKGBUILD" "$OFFLINE/aur/$name/PKGBUILD"
rm -rf "$OFFLINE/aur/$name.fork"
sed -i 's|#branch=nosignal|#commit='"$NOSIGNAL_SHELL_COMMIT"'|' "$OFFLINE/aur/$name/PKGBUILD"
else
git clone --depth 1 "https://aur.archlinux.org/$name.git" "$OFFLINE/aur/$name"
fi
if [ "$name" = "quickshell-git" ]; then
# Pin to the validated commit (makepkg git source fragment).
sed -i 's|git+\$url\.git|&#commit='"$QUICKSHELL_GIT_COMMIT"'|' "$OFFLINE/aur/$name/PKGBUILD"
fi
# Self-heal the clean chroot's [nosignal] repo path: a project rename
# (hyprOS -> nosignal) leaves a stale Server path baked into the chroot's
# persistent pacman.conf, which only bites the first chroot build after the
# move (cached AUR pkgs skip the build). Keep it pointed at the live OFFLINE.
for pc in "$OFFLINE"/chroot/*/etc/pacman.conf; do
[ -f "$pc" ] && sudo sed -i "s#file:///.*/offline/iso/repo#file://$OFFLINE/iso/repo#g" "$pc"
done
# Refresh the ROOT chroot's sync dbs first: makechrootpkg -c clones the
# copy from root, and the dependency install inside runs withOUT -Sy — so
# without this, packages repo-added to [nosignal] after mkarchroot are
# invisible and chained builds fail with "target not found".
sudo arch-nspawn "$OFFLINE/chroot/root" pacman -Syu --noconfirm
( cd "$OFFLINE/aur/$name" && sudo makechrootpkg -c -r "$OFFLINE/chroot" )
for p in "$OFFLINE/aur/$name"/*.pkg.tar.zst; do
[[ "$(basename "$p")" == *-debug-* ]] && continue
cp -f "$p" "$OFFLINE/iso/repo/"
repo-add -R "$OFFLINE/iso/repo/nosignal.db.tar.gz" "$OFFLINE/iso/repo/$(basename "$p")"
done
touch "$OFFLINE/aur/.built-$name"
done
# caelestia-meta builds from the PKGBUILD inside the dotfiles repo itself
# (the AUR snapshot lags it — the dotfiles clone is the authoritative
# source). Pure metapackage: no build() step, so plain makepkg -d on the
# host is safe (no dep pollution; pkgver() just needs git).
# PRIVACY: host makepkg records builddir/startdir in the package's
# .BUILDINFO — built from $HOME that would ship the builder's username on a
# distributable ISO. Build from a neutral /tmp copy instead. (Chroot-built
# packages don't have this problem: their paths are /build.)
if [ ! -f "$OFFLINE/aur/.built-caelestia-meta" ]; then
echo "==> Building caelestia-meta (from the pinned dotfiles clone)..."
if [ ! -d "$OFFLINE/aur/caelestia/.git" ]; then
rm -rf "$OFFLINE/aur/caelestia"
git clone "$CAELESTIA_DOTS_REPO" "$OFFLINE/aur/caelestia"
git -C "$OFFLINE/aur/caelestia" checkout -q "$CAELESTIA_DOTS_COMMIT"
fi
meta_tmp=$(mktemp -d /tmp/nosignal-meta.XXXXXX)
cp -a "$OFFLINE/aur/caelestia" "$meta_tmp/caelestia" # keep .git (pkgver)
( cd "$meta_tmp/caelestia" && rm -f ./*.pkg.tar.zst \
&& BUILDDIR="$meta_tmp/build" makepkg -df --noconfirm )
for p in "$meta_tmp/caelestia"/*.pkg.tar.zst; do
cp -f "$p" "$OFFLINE/iso/repo/"
repo-add -R "$OFFLINE/iso/repo/nosignal.db.tar.gz" "$OFFLINE/iso/repo/$(basename "$p")"
done
rm -rf "$meta_tmp"
touch "$OFFLINE/aur/.built-caelestia-meta"
fi
# ---- full repo dependency closure ----------------------------------------
# Resolve EVERYTHING the installer can possibly pacstrap — base set, every
# GPU variant, the limine tools (prebuilt, from the omarchy repo) and the
# caelestia stack (from the local repo, pulling its repo deps) — against an
# EMPTY local db, and download into the repo dir. Already-present files are
# skipped, so this is cheap on rebuilds.
echo "==> Downloading full package dependency closure..."
cat > "$OFFLINE/dl-pacman.conf" <<DLPAC
[options]
Architecture = x86_64
ParallelDownloads = 8
SigLevel = Required DatabaseOptional
[nosignal]
SigLevel = Optional TrustAll
Server = file://$OFFLINE/iso/repo
[core]
Server = https://geo.mirror.pkgbuild.com/\$repo/os/\$arch
[extra]
Server = https://geo.mirror.pkgbuild.com/\$repo/os/\$arch
[omarchy]
SigLevel = Optional TrustAll
Server = https://pkgs.omarchy.org/edge/\$arch
DLPAC
sudo rm -rf "$OFFLINE/dl-db" # populated by root (sudo pacman -Syw)
mkdir -p "$OFFLINE/dl-db"
sudo pacman -Syw --noconfirm \
--config "$OFFLINE/dl-pacman.conf" \
--dbpath "$OFFLINE/dl-db" \
--cachedir "$OFFLINE/iso/repo" \
"${BASE_PKGS[@]}" "${GPU_ALL_PKGS[@]}" "${LIMINE_TOOLS[@]}"
# ---- finalise the repo ----------------------------------------------------
# Prune superseded package versions, then rebuild the db from everything
# present so it exactly matches the files shipped on the ISO.
if command -v paccache >/dev/null 2>&1; then
paccache -rqk1 -c "$OFFLINE/iso/repo" || true
else
echo " (paccache not found — old package versions may bloat the ISO;"
echo " install pacman-contrib to enable pruning)"
fi
# F5 (2026-06-12 hardware test): hyprland 0.55.3 segfaults at screen lock
# (renderer SEGV during lock/DPMS monitor churn; the watchdog then drops to
# the confusing safe-mode "basic desktop"). 0.55.4 backports the #15048 fix
# and was retested clean on the test box — refuse to ship anything older.
local hyprver
hyprver=$(basename "$(ls "$OFFLINE/iso/repo"/hyprland-[0-9]*.pkg.tar.zst | sort -V | tail -1)")
hyprver=${hyprver#hyprland-}; hyprver=${hyprver%-x86_64.pkg.tar.zst}
if [ "$(vercmp "$hyprver" 0.55.4)" -lt 0 ]; then
echo "ERROR: offline repo has hyprland $hyprver — F5 requires >= 0.55.4" >&2
echo " (lock-screen renderer SEGV). Refresh mirrors and rebuild." >&2
exit 1
fi
echo " hyprland $hyprver (>= 0.55.4, F5 lock-screen fix) OK"
echo "==> Building repo database..."
# Drop detached signatures (omarchy ships them): repo-add would embed them
# in the db and pacman then REQUIRES the signing key even under TrustAll.
# Offline install verifies nothing (SigLevel Never) — the sigs are dead weight.
rm -f "$OFFLINE/iso/repo"/*.sig
rm -f "$OFFLINE/iso/repo"/nosignal.db* "$OFFLINE/iso/repo"/nosignal.files* "$OFFLINE/iso/repo"/*.old
repo-add -q "$OFFLINE/iso/repo/nosignal.db.tar.gz" "$OFFLINE/iso/repo"/*.pkg.tar.zst
# ISO9660-safe: ship the .db/.files as REAL files, not symlinks.
local f
for f in db files; do
rm -f "$OFFLINE/iso/repo/nosignal.$f"
cp "$OFFLINE/iso/repo/nosignal.$f.tar.gz" "$OFFLINE/iso/repo/nosignal.$f"
done
# ---- base package list (single source of truth for the installer) --------
printf '%s\n' "${BASE_PKGS[@]}" > "$OFFLINE/iso/base-packages.list"
echo "==> Offline payload ready: $(du -sh "$OFFLINE/iso" | cut -f1)"
}
# ===========================================================================
# Host-side workflow: locate stock ISO, build offline payload, unsquash,
# inject, re-squash, repack, output.
# ===========================================================================
# ---- locate stock ISO ----------------------------------------------------
shopt -s nullglob
CANDIDATES=("$SCRIPT_DIR"/archlinux-*.iso)
shopt -u nullglob
STOCK_ISO=""
for iso in "${CANDIDATES[@]}"; do
[[ "$(basename "$iso")" == *NoSignal* ]] && continue
STOCK_ISO="$iso"
break
done
if [ -z "$STOCK_ISO" ]; then
echo "ERROR: No stock Arch ISO found in $SCRIPT_DIR" >&2
echo >&2
echo "Download the latest from https://archlinux.org/download/ and put it" >&2
echo "in this folder (filename must start with 'archlinux-')." >&2
exit 1
fi
echo " Stock ISO: $(basename "$STOCK_ISO")"
echo " Output: $(basename "$OUT_ISO")"
echo
# ---- tooling check -------------------------------------------------------
for cmd in xorriso unsquashfs mksquashfs git sha512sum mkarchroot makechrootpkg repo-add makepkg; do
command -v "$cmd" >/dev/null || {
echo "ERROR: missing tool: $cmd" >&2
echo " install with: sudo pacman -S libisoburn squashfs-tools git coreutils devtools pacman-contrib" >&2
exit 1
}
done
# ---- OPTIONAL master SSH public key (OPT-IN ONLY) -------------------------
# The ISO is distributable: by default NO key is baked and sshd stays disabled
# on installed systems — nothing identifying the build host ships on the
# image. For a personal/dev build (remote rescue over SSH), opt in explicitly:
# SSH_PUBKEY=/path/to/key.pub ./nosignal.sh
# (The old behaviour of auto-baking ~/.ssh/id_*.pub was removed deliberately —
# do not reintroduce it; a give-away ISO must never carry the builder's key.)
SSH_PUBKEY="${SSH_PUBKEY:-}"
HAVE_KEY=0
if [ -z "$SSH_PUBKEY" ] || [ "$SSH_PUBKEY" = "none" ]; then
echo " SSH key: (none — sshd disabled on installs; distributable default."
echo " Opt in with SSH_PUBKEY=/path/to/key.pub for a dev build.)"
elif [ -f "$SSH_PUBKEY" ]; then
HAVE_KEY=1
echo " SSH key: $SSH_PUBKEY (DEV BUILD — key baked in, sshd enabled on installs;"
echo " do NOT distribute this ISO)"
else
echo "ERROR: SSH_PUBKEY=$SSH_PUBKEY not found" >&2
exit 1
fi
# ---- offline payload ------------------------------------------------------
build_offline_payload
# ---- workspace -----------------------------------------------------------
sudo rm -rf "$WORK"
mkdir -p "$WORK"
SFS_DIR="$WORK/airootfs"
# ---- pull squashfs out of the stock ISO ---------------------------------
echo "==> Extracting airootfs.sfs from stock ISO..."
xorriso -osirrox on -indev "$STOCK_ISO" \
-extract /arch/x86_64/airootfs.sfs "$WORK/airootfs-stock.sfs" 2>&1 | tail -3
echo "==> Unsquashing airootfs (slow: ~1 min)..."
sudo unsquashfs -d "$SFS_DIR" "$WORK/airootfs-stock.sfs" >/dev/null
# ---- inject payload ------------------------------------------------------
if [ "$HAVE_KEY" = 1 ]; then
echo "==> Staging master SSH key for first-boot SSH access..."
sudo install -m 644 -o root -g root "$SSH_PUBKEY" "$SFS_DIR/root/master.pub"
fi
echo "==> Injecting installer.sh..."
INSTALLER_TMP="$WORK/installer.sh"
write_installer "$INSTALLER_TMP"
sudo install -m 755 -o root -g root "$INSTALLER_TMP" "$SFS_DIR/root/installer.sh"
echo "==> Masking online-only units (they hang/hijack the console offline)..."
# With no network, archlinux-keyring-wkd-sync pulls systemd-time-wait-sync,
# which waits for NTP sync FOREVER ("no limit") and repaints the console with
# its start-job spinner right over the running installer. Useless offline.
for unit in systemd-time-wait-sync.service archlinux-keyring-wkd-sync.service archlinux-keyring-wkd-sync.timer; do
sudo ln -sf /dev/null "$SFS_DIR/etc/systemd/system/$unit"
done
echo "==> Wiring getty@tty1 to auto-launch the installer..."
sudo mkdir -p "$SFS_DIR/etc/systemd/system/getty@tty1.service.d"
sudo tee "$SFS_DIR/etc/systemd/system/getty@tty1.service.d/override.conf" >/dev/null <<'EOF'
[Service]
ExecStart=
ExecStart=-/usr/bin/bash /root/installer.sh
StandardInput=tty
StandardOutput=tty
Restart=no
RestartPreventExitStatus=1 2 3 4 5 6 7 8
EOF
# ---- extend USB enumeration timeout in boot loader cmdline --------------
# Slow / fussy USB controllers (some Intel mini-PCs, NUCs) don't enumerate USB
# block devices within archiso's default search window. Adding rootdelay=60 to
# the kernel cmdline makes initramfs wait 60s before mounting root — fast
# hardware finds the device in 2s and moves on; slow hardware gets breathing
# room. consoleblank=0 keeps the live install console from DPMS-blanking.
echo "==> Patching bootloader cmdlines (rootdelay=60 for slow USB enumeration)..."
mkdir -p "$WORK/boot-edit"
BOOT_CFG_FILES=(
/loader/entries/01-archiso-linux.conf
/loader/entries/02-archiso-speech-linux.conf
/boot/syslinux/archiso_sys-linux.cfg
)
for path in "${BOOT_CFG_FILES[@]}"; do
dest="$WORK/boot-edit/$(basename "$path")"
# Don't pipe xorriso straight into tail — that masks its exit status under
# 'set -e', so a missing boot file would slip through here and instead blow
# up later at the unconditional -map during repack, with a murkier error.
if ! xorriso -osirrox on -indev "$STOCK_ISO" -extract "$path" "$dest" 2>"$WORK/xorriso-extract.log"; then
echo "ERROR: failed to extract $path from stock ISO:" >&2
tail -3 "$WORK/xorriso-extract.log" >&2
exit 1
fi
done
for f in "$WORK/boot-edit"/*; do
[ -f "$f" ] || continue
sed -i -E '/archisosearchuuid=/ { /rootdelay=/! s/$/ rootdelay=60/ }' "$f"
sed -i -E '/archisosearchuuid=/ { /consoleblank=/! s/$/ consoleblank=0/ }' "$f"
done
# ---- re-squash -----------------------------------------------------------
echo "==> Re-squashing airootfs (slow: ~2 min)..."
sudo rm -f "$WORK/airootfs.sfs"
sudo mksquashfs "$SFS_DIR" "$WORK/airootfs.sfs" \
-comp xz -Xbcj x86 -b 1M -noappend -no-progress -quiet
# ---- regenerate sha512 --------------------------------------------------
echo "==> Recalculating airootfs.sha512..."
( cd "$WORK" && sudo sha512sum airootfs.sfs | sudo tee airootfs.sha512 >/dev/null )
# ---- repack ISO ---------------------------------------------------------
# -boot_image any replay regenerates the hybrid MBR/GPT + El Torito layout
# SIZED TO THE NEW IMAGE. Never copy the stock system_area verbatim: its
# partition table describes the stock 1.2GB ISO, so everything past that
# (the /nosignal payload) lands OUTSIDE any partition and is unreadable when
# the media is mounted via a partition device (USB boot on real hardware).
echo "==> Repacking ISO (offline repo + replayed boot layout + volume UUID)..."
sudo rm -f "$OUT_ISO"
# Force output volume UUID to match the stock's. archiso's initramfs hook
# searches for the boot media by this UUID (also baked into the kernel cmdline
# as archisosearchuuid=) — if xorriso regenerates the UUID with a fresh build
# timestamp, the search misses the USB entirely. Pinning the UUID keeps stock
# cmdline + marker file in sync.
STOCK_UUID=$(blkid -s UUID -o value "$STOCK_ISO")
STOCK_UUID_RAW=$(echo "$STOCK_UUID" | tr -d '-')
xorriso \
-indev "$STOCK_ISO" \
-outdev "$OUT_ISO" \
-volume_date "uuid" "$STOCK_UUID_RAW" \
-boot_image any replay \
-rm /arch/x86_64/airootfs.sfs -- \
-map "$WORK/airootfs.sfs" /arch/x86_64/airootfs.sfs \
-rm /arch/x86_64/airootfs.sha512 -- \
-map "$WORK/airootfs.sha512" /arch/x86_64/airootfs.sha512 \
-rm /loader/entries/01-archiso-linux.conf -- \
-map "$WORK/boot-edit/01-archiso-linux.conf" /loader/entries/01-archiso-linux.conf \
-rm /loader/entries/02-archiso-speech-linux.conf -- \
-map "$WORK/boot-edit/02-archiso-speech-linux.conf" /loader/entries/02-archiso-speech-linux.conf \
-rm /boot/syslinux/archiso_sys-linux.cfg -- \
-map "$WORK/boot-edit/archiso_sys-linux.cfg" /boot/syslinux/archiso_sys-linux.cfg \
-map "$OFFLINE/iso" /nosignal \
-end 2>&1 | tail -5
# ---- cleanup ------------------------------------------------------------
sudo chown "$INVOKING_USER:$INVOKING_GROUP" "$OUT_ISO"
sudo rm -rf "$WORK"
echo
echo "=========================================================="
echo " Done: $OUT_ISO"
echo " Size: $(du -h "$OUT_ISO" | cut -f1)"
echo
echo " Fully offline installer — no network needed on the target."
echo
echo " Burn to USB:"
echo " sudo dd if='$OUT_ISO' of=/dev/sdX bs=4M status=progress conv=fsync"
echo
echo " Or drop $(basename "$OUT_ISO") into your Ventoy USB."
echo "=========================================================="