iso: add bos-rescue, first-boot probe, optional Calamares refresh

Live-ISO bos-rescue finds the installed btrfs @ and ESP, then offers
arch-chroot and/or the same GRUB NVRAM + --removable sequence as
post-install.sh. Recovery is grub-btrfs or this reinstall — GRUB pins
rootflags=subvol=@.

bos-first-boot runs once after the first graphical login: NVIDIA offer
file + notify (no driver install), VM-without-GL notify, HiDPI hint
file (never rewrites monitors.json).

Re-enable the Calamares packages module as a refresh-only step with
skip_if_no_internet and ignore_update_db_error so offline installs
cannot abort on pacman -Sy.
This commit is contained in:
Breadway 2026-08-15 23:59:38 +08:00
parent fd385bafae
commit 744f18cd90
11 changed files with 838 additions and 18 deletions

View file

@ -360,14 +360,15 @@ not shipped.
**The system won't boot (broken GRUB / lost EFI entry):** **The system won't boot (broken GRUB / lost EFI entry):**
1. Boot the BOS ISO and open a terminal (`SUPER+Return`). 1. Boot the BOS ISO and open a terminal (`SUPER+Return`).
2. Mount the installed root and EFI, then chroot: 2. Run `sudo bos-rescue`. It finds the installed btrfs `@` and the ESP,
prints the devices it will use, and asks `YES` before writing. It can
`arch-chroot` and/or reinstall GRUB with the same sequence the
installer uses (NVRAM + `--removable` + `grub-mkconfig`).
3. Manual equivalent, if you would rather type it:
```sh ```sh
mount -o subvol=@ /dev/sdXN /mnt mount -o subvol=@ /dev/sdXN /mnt
mount /dev/sdXP /mnt/boot/efi # the EFI partition mount /dev/sdXP /mnt/boot/efi # the EFI partition
arch-chroot /mnt arch-chroot /mnt
```
3. Reinstall the bootloader (the same sequence the installer uses):
```sh
grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=BOS --recheck grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=BOS --recheck
grub-install --target=x86_64-efi --efi-directory=/boot/efi --removable --recheck grub-install --target=x86_64-efi --efi-directory=/boot/efi --removable --recheck
grub-mkconfig -o /boot/grub/grub.cfg grub-mkconfig -o /boot/grub/grub.cfg

View file

@ -8,6 +8,15 @@ BOS ships the generic **Mesa** stack. AMD and Intel work out of the box.
firmware is not on the image, and there is no Hyprland NVIDIA env wiring. firmware is not on the image, and there is no Hyprland NVIDIA env wiring.
Installing `nvidia` / `nvidia-utils` after the fact is not a product path. Installing `nvidia` / `nvidia-utils` after the fact is not a product path.
On first graphical login, `bos-first-boot` probes `lspci` / `/proc` and,
if an NVIDIA GPU is present, writes `~/.local/state/bos/nvidia-offer.json`
and notifies that the proprietary driver is not on the ISO. It does **not**
install anything. bos-settings can grow a panel that reads that file later.
The same probe leaves a HiDPI hint at `~/.local/state/bos/hidpi-hint.json`
when scale > 1 or the panel is dense; it never rewrites `monitors.json`.
A VM without `/dev/dri` gets a notification only.
## Recovery ## Recovery
An update that breaks the system is recovered from the **GRUB "snapshots" An update that breaks the system is recovered from the **GRUB "snapshots"
@ -17,5 +26,8 @@ BOS GRUB pins `rootflags=subvol=@`. `snapper rollback` swaps the default
subvolume; the installed `grub.cfg` will still boot `@`. Pick the grub-btrfs subvolume; the installed `grub.cfg` will still boot `@`. Pick the grub-btrfs
entry so the kernel command line matches the snapshot you want. entry so the kernel command line matches the snapshot you want.
A/B root swapping is not implemented. See the README Recovery section for If the system will not boot (lost EFI entry / broken GRUB), boot the live
the "system will not boot" GRUB/EFI repair path. ISO and run `sudo bos-rescue`. It mounts `@` + the ESP and offers the same
`grub-install` NVRAM + `--removable` sequence as `post-install.sh`.
A/B root swapping is not implemented. See the README Recovery section.

View file

@ -1,10 +1,24 @@
--- ---
# Optional online pacman refresh. The previous packages step used
# update_db:true with no skip/ignore, so `pacman -Sy` aborted offline
# installs (the case bos-netcheck exists for). skip_if_no_internet
# skips the whole module when Calamares sees no network;
# ignore_update_db_error keeps a flake-mirror -Sy from failing the
# install. update_system stays false — this is not a -Syu.
#
# try_install is empty: pipewire-pulse / pipewire-alsa already come
# from packages.x86_64 via unpackfs. No extra packages (and no
# nvidia) are pulled here.
backend: pacman backend: pacman
options: skip_if_no_internet: true
- update_db: true update_db: true
ignore_update_db_error: true
update_system: false
operations: pacman:
- try_install: num_retries: 1
- pipewire-pulse disable_download_timeout: false
- pipewire-alsa needed_only: true
operations: []

View file

@ -35,12 +35,6 @@ sequence:
- users - users
- networkcfg - networkcfg
- hwclock - hwclock
# packages module removed: it set update_db:true with no
# skip_if_no_internet/ignore_update_db_error, so an offline install (the
# exact case bos-welcome's nmtui step exists for) aborted here with a
# fatal pacman -Sy failure. Its only try_install packages (pipewire-pulse,
# pipewire-alsa) are already in packages.x86_64 and installed by
# unpackfs, so the step did nothing useful even when it succeeded.
# archiso strips the kernel from the squashfs; stage it, drop the archiso # archiso strips the kernel from the squashfs; stage it, drop the archiso
# initramfs config, and write a stock mkinitcpio preset before initcpio runs. # initramfs config, and write a stock mkinitcpio preset before initcpio runs.
- shellprocess@kernel - shellprocess@kernel
@ -57,6 +51,12 @@ sequence:
# BOS finalization: GRUB install + cleanup + snapper + services + dotfiles. # BOS finalization: GRUB install + cleanup + snapper + services + dotfiles.
# All fast, and runs after initcpio so /boot has the kernel + initramfs. # All fast, and runs after initcpio so /boot has the kernel + initramfs.
- shellprocess - shellprocess
# Optional online pacman -Sy. After post-install so the target keyring
# exists. skip_if_no_internet + ignore_update_db_error: an offline
# install (or a flake-mirror -Sy) must not abort. operations is empty —
# pipewire-pulse/alsa already come from unpackfs; nothing extra (and
# no nvidia) is installed here.
- packages
- umount - umount
- show: - show:
- finished - finished

View file

@ -3,6 +3,7 @@
{ "command": "breadbar", "label": "Bar (breadbar)", "enabled": true }, { "command": "breadbar", "label": "Bar (breadbar)", "enabled": true },
{ "command": "hypridle", "label": "Idle / lock daemon (hypridle)", "enabled": true }, { "command": "hypridle", "label": "Idle / lock daemon (hypridle)", "enabled": true },
{ "command": "bos-netcheck", "label": "Network connectivity check", "enabled": true }, { "command": "bos-netcheck", "label": "Network connectivity check", "enabled": true },
{ "command": "bash -c 'command -v bos-first-boot >/dev/null && exec bos-first-boot'", "label": "First-boot hardware probe", "enabled": true },
{ "command": "breadhelp --autostart", "label": "BOS Help (first-run onboarding)", "enabled": true }, { "command": "breadhelp --autostart", "label": "BOS Help (first-run onboarding)", "enabled": true },
{ "command": "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", "label": "Wallpaper command bus (breadpaper listen)", "enabled": true }, { "command": "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", "label": "Wallpaper command bus (breadpaper listen)", "enabled": true },
{ "command": "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", "label": "Screenshot command bus (breadshot listen)", "enabled": true }, { "command": "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", "label": "Screenshot command bus (breadshot listen)", "enabled": true },

View file

@ -174,6 +174,7 @@ hl.on("hyprland.start", function()
"breadbar", "breadbar",
"hypridle", "hypridle",
"bos-netcheck", "bos-netcheck",
"bash -c 'command -v bos-first-boot >/dev/null && exec bos-first-boot'",
"breadhelp --autostart", "breadhelp --autostart",
"bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'",
"bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'",

View file

@ -18,6 +18,7 @@ local DEFAULT_EXTRA = {
{ command = "breadbar", enabled = true }, { command = "breadbar", enabled = true },
{ command = "hypridle", enabled = true }, { command = "hypridle", enabled = true },
{ command = "bos-netcheck", enabled = true }, { command = "bos-netcheck", enabled = true },
{ command = "bash -c 'command -v bos-first-boot >/dev/null && exec bos-first-boot'", enabled = true },
{ command = "breadhelp --autostart", enabled = true }, { command = "breadhelp --autostart", enabled = true },
{ command = "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", enabled = true }, { command = "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", enabled = true },
{ command = "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", enabled = true }, { command = "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", enabled = true },

View file

@ -0,0 +1,187 @@
#!/bin/bash
# bos-first-boot — one-shot hardware probe after the first graphical login.
#
# Detects NVIDIA (offer file + notify; never auto-installs a driver), a VM
# without GL, and HiDPI (hint file only — never rewrites monitors.json).
#
# Non-fatal: missing tools, notify-send, or hyprctl must not block login.
# Guarded with `command -v`. Flag: ~/.local/state/bos/first-boot-done.
set -u
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/bos"
FLAG="$STATE_DIR/first-boot-done"
NVIDIA_OFFER="$STATE_DIR/nvidia-offer.json"
HIDPI_HINT="$STATE_DIR/hidpi-hint.json"
VM_HINT="$STATE_DIR/vm-gl-hint.json"
# Never run on the live/installer session — only on an installed system.
[[ "$(id -un)" == "liveuser" ]] && exit 0
# Already probed this home.
[[ -f "$FLAG" ]] && exit 0
notify() {
local msg="$1"
local urgency="${2:-normal}"
command -v notify-send >/dev/null 2>&1 || return 0
notify-send -u "$urgency" "BOS" "$msg" 2>/dev/null || true
}
json_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
iso_now() {
date -Iseconds 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%SZ
}
# Best-effort: hyprland.start can beat the notification daemon by a beat.
if [[ -z "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]]; then
sleep 1
fi
mkdir -p "$STATE_DIR" 2>/dev/null || exit 0
# ---------------------------------------------------------------------------
# NVIDIA — hardware only. Do not install nvidia / nvidia-utils.
# ---------------------------------------------------------------------------
nvidia_present=0
nvidia_pci=""
if command -v lspci >/dev/null 2>&1; then
nvidia_pci="$(lspci -d 10de: -nn 2>/dev/null | grep -iE 'VGA|3D|Display' || true)"
[[ -n "$nvidia_pci" ]] && nvidia_present=1
fi
if [[ "$nvidia_present" != "1" ]]; then
if [[ -d /proc/driver/nvidia || -d /sys/module/nvidia ]]; then
nvidia_present=1
nvidia_pci="${nvidia_pci:-module}"
fi
fi
if [[ "$nvidia_present" == "1" ]]; then
cat >"$NVIDIA_OFFER" <<EOF
{
"detected": true,
"pci": "$(json_escape "$nvidia_pci")",
"driver_on_iso": false,
"auto_install": false,
"message": "NVIDIA GPU detected. The proprietary driver is not on the ISO.",
"offered_at": "$(iso_now)"
}
EOF
notify "NVIDIA GPU detected. The proprietary driver is not on the ISO — open BOS Settings later. Nothing was installed." normal
fi
# ---------------------------------------------------------------------------
# VM without GL (no /dev/dri). Notify only when both are true.
# ---------------------------------------------------------------------------
virt="none"
if command -v systemd-detect-virt >/dev/null 2>&1; then
virt="$(systemd-detect-virt 2>/dev/null || true)"
[[ -n "$virt" ]] || virt="none"
fi
has_gl=0
shopt -s nullglob
dri_nodes=(/dev/dri/card* /dev/dri/renderD*)
(( ${#dri_nodes[@]} > 0 )) && has_gl=1
shopt -u nullglob
if [[ "$virt" != "none" && "$has_gl" != "1" ]]; then
cat >"$VM_HINT" <<EOF
{
"virt": "$(json_escape "$virt")",
"gl": false,
"dri": false,
"noted_at": "$(iso_now)"
}
EOF
notify "This looks like a virtual machine without hardware GL. Hyprland may use software rendering." normal
fi
# ---------------------------------------------------------------------------
# HiDPI — hint file for bos-settings. Do not rewrite monitors.json.
# scale > 1 from hyprctl, or computed DPI >= 140.
# ---------------------------------------------------------------------------
if command -v hyprctl >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1; then
# Compositor may still be settling when autostart fires.
mon_json=""
tries=0
while [[ -z "$mon_json" && "$tries" -lt 5 ]]; do
mon_json="$(hyprctl -j monitors 2>/dev/null || true)"
if [[ -z "$mon_json" || "$mon_json" == "[]" ]]; then
mon_json=""
sleep 1
fi
tries=$((tries + 1))
done
if [[ -n "$mon_json" ]]; then
BOS_HYPR_MONITORS="$mon_json" python3 - "$HIDPI_HINT" "$(iso_now)" <<'PY' || true
import json, os, sys
hint_path, noted_at = sys.argv[1], sys.argv[2]
try:
monitors = json.loads(os.environ.get("BOS_HYPR_MONITORS") or "")
except Exception:
sys.exit(0)
if not isinstance(monitors, list):
sys.exit(0)
hits = []
for m in monitors:
if not isinstance(m, dict):
continue
name = m.get("name") or m.get("output") or ""
try:
scale = float(m.get("scale") or 1)
except (TypeError, ValueError):
scale = 1.0
try:
w = int(m.get("width") or 0)
h = int(m.get("height") or 0)
except (TypeError, ValueError):
w = h = 0
mm_w = mm_h = 0
phys = m.get("physicalSize")
if isinstance(phys, dict):
mm_w = phys.get("x") or phys.get("width") or 0
mm_h = phys.get("y") or phys.get("height") or 0
elif isinstance(phys, (list, tuple)) and len(phys) >= 2:
mm_w, mm_h = phys[0], phys[1]
else:
mm_w = m.get("physicalWidth") or 0
mm_h = m.get("physicalHeight") or 0
try:
mm_w = float(mm_w or 0)
mm_h = float(mm_h or 0)
except (TypeError, ValueError):
mm_w = mm_h = 0.0
dpi = round(w / (mm_w / 25.4), 1) if mm_w and w else 0.0
px_per_mm = round(w / mm_w, 3) if mm_w and w else 0.0
# High px/mm (dense panel) or Hyprland already chose scale > 1.
hidpi = scale > 1.01 or dpi >= 140
if hidpi:
hits.append({
"name": name,
"width": w,
"height": h,
"scale": scale,
"dpi": dpi,
"px_per_mm": px_per_mm,
})
if not hits:
sys.exit(0)
with open(hint_path, "w") as f:
json.dump({
"suggested": True,
"rewrote_monitors_json": False,
"reason": "scale > 1 or DPI >= 140",
"monitors": hits,
"noted_at": noted_at,
}, f, indent=2)
f.write("\n")
PY
fi
fi
# Mark done even if every probe was a no-op — do not nag next login.
printf '%s\n' "$(iso_now)" >"$FLAG" 2>/dev/null || true
exit 0

View file

@ -0,0 +1,598 @@
#!/bin/bash
# bos-rescue — live-ISO helper for an installed BOS that will not boot.
#
# Finds the installed btrfs `@` and the ESP, mounts them, then offers to
# arch-chroot and/or reinstall GRUB using the same sequence as
# post-install.sh / README Recovery:
# UEFI: grub-install NVRAM + --removable, then grub-mkconfig
# BIOS: grub-install i386-pc onto the disk hosting /
#
# Recovery is this script or the GRUB "snapshots" submenu (grub-btrfs).
# GRUB pins rootflags=subvol=@ — a snapper-swapped default subvolume is
# not what the installed grub.cfg will boot. Never snapper-rollback.
#
# Safe: prints the devices it will use and requires YES before writing.
# Best-effort: do not use `set -e`; a failed probe must not abort the rest.
set -uo pipefail
MNT="${BOS_RESCUE_MNT:-}"
MOUNTED_ROOT=0
MOUNTED_ESP=0
ROOT_DEV=""
ESP_DEV=""
ROOT_ENCRYPTED=0
bold() { printf '\033[1m%s\033[0m\n' "$1" >&2; }
info() { printf ' %s\n' "$1" >&2; }
warn() { printf 'WARN: %s\n' "$1" >&2; }
usage() {
cat <<'EOF'
Usage: bos-rescue
Live-ISO helper: find the installed BOS btrfs @ and ESP, mount them,
then arch-chroot and/or reinstall GRUB.
UEFI: grub-install (NVRAM) + grub-install --removable + grub-mkconfig
BIOS: grub-install --target=i386-pc onto the disk hosting /
Prints the devices it will use and asks YES before writing anything.
Do not snapper-rollback. GRUB pins rootflags=subvol=@. Pick a grub-btrfs
snapshot entry, or reinstall GRUB with this script.
Must be run as root. Intended from the live ISO (SUPER+Return).
EOF
}
need_root() {
if [[ "$(id -u)" -ne 0 ]]; then
echo "bos-rescue must run as root (sudo bos-rescue)." >&2
exit 1
fi
}
confirm_yes() {
local prompt="$1"
local reply=""
printf '%s [type YES]: ' "$prompt" >&2
read -r reply || return 1
[[ "$reply" == "YES" ]]
}
is_live_iso() {
[[ -d /run/archiso ]] || [[ -x /usr/local/bin/bos-live-setup ]]
}
already_on_installed() {
# Installed BOS: / is the @ subvolume and this is not the live medium.
is_live_iso && return 1
local src opts
src="$(findmnt -no SOURCE / 2>/dev/null | sed 's/\[.*\]//')"
opts="$(findmnt -no OPTIONS / 2>/dev/null || true)"
[[ -n "$src" ]] || return 1
[[ "$opts" == *subvol=/@* || "$opts" == *subvol=@* ]] || return 1
[[ -f /etc/os-release ]] && grep -qE '^ID=bos$' /etc/os-release
}
pick_mnt() {
if [[ -n "$MNT" ]]; then
return
fi
if findmnt -n /mnt >/dev/null 2>&1; then
MNT=/mnt/bos-rescue
info "/mnt is already a mountpoint — using $MNT"
else
MNT=/mnt
fi
}
lsblk_line() {
lsblk -pnlo NAME,FSTYPE,SIZE,LABEL,UUID,PARTTYPENAME "$1" 2>/dev/null | head -n1
}
# Open LUKS containers so a later btrfs scan can see @.
offer_luks() {
command -v cryptsetup >/dev/null || return 0
local dev name reply
while read -r dev; do
[[ -n "$dev" ]] || continue
[[ -e "$dev" ]] || continue
if lsblk -no TYPE "$dev" 2>/dev/null | grep -qx crypt; then
continue
fi
# Skip already-mapped parents.
if lsblk -nlo TYPE "$dev" 2>/dev/null | grep -qx crypt; then
continue
fi
printf '\nLUKS container: %s\n %s\n' "$dev" "$(lsblk_line "$dev")" >&2
printf 'Unlock this container? [y/N]: ' >&2
read -r reply || reply=""
if [[ "$reply" == [yY] ]]; then
name="bos-rescue-$(basename "$dev")"
if cryptsetup open "$dev" "$name"; then
info "opened $dev as /dev/mapper/$name"
else
warn "cryptsetup open failed for $dev"
fi
fi
done < <(lsblk -pnlo NAME,FSTYPE | awk '$2 == "crypto_LUKS" { print $1 }')
}
# Probe a btrfs device for an @ subvolume that looks like BOS (or any @).
# Prints: DEVICE<TAB>KIND<TAB>PRETTY where KIND is bos|other
probe_btrfs_dev() {
local dev="$1"
local tmp pretty kind id
tmp="$(mktemp -d /tmp/bos-rescue.XXXXXX)" || return 1
kind="other"
pretty=""
if mount -o ro,subvol=@ "$dev" "$tmp" 2>/dev/null; then
if [[ -f "$tmp/etc/os-release" ]]; then
id="$(grep -E '^ID=' "$tmp/etc/os-release" | head -n1 | cut -d= -f2- | tr -d '"')"
pretty="$(grep -E '^PRETTY_NAME=' "$tmp/etc/os-release" | head -n1 | cut -d= -f2- | tr -d '"')"
[[ "$id" == "bos" ]] && kind="bos"
fi
umount "$tmp" 2>/dev/null || umount -l "$tmp" 2>/dev/null || true
rmdir "$tmp" 2>/dev/null || true
printf '%s\t%s\t%s\n' "$dev" "$kind" "${pretty:-btrfs @}"
return 0
fi
# Some volumes only accept a top-level probe first.
if mount -o ro,subvolid=5 "$dev" "$tmp" 2>/dev/null; then
if [[ -d "$tmp/@" ]] || btrfs subvolume show "$tmp/@" &>/dev/null; then
umount "$tmp" 2>/dev/null || umount -l "$tmp" 2>/dev/null || true
rmdir "$tmp" 2>/dev/null || true
printf '%s\t%s\t%s\n' "$dev" "other" "btrfs @ (unreadable os-release)"
return 0
fi
umount "$tmp" 2>/dev/null || umount -l "$tmp" 2>/dev/null || true
fi
rmdir "$tmp" 2>/dev/null || true
return 1
}
find_root_candidates() {
local dev
while read -r dev; do
[[ -n "$dev" ]] || continue
probe_btrfs_dev "$dev" || true
done < <(lsblk -pnlo NAME,FSTYPE | awk '$2 == "btrfs" { print $1 }')
}
# Prefer the ESP named in the installed fstab; else EFI type / BOS bits.
find_esp_for_root() {
local root="$1"
local tmp fstab_uuid fstab_dev dev fstype parttype label
tmp="$(mktemp -d /tmp/bos-rescue.XXXXXX)" || return 1
if mount -o ro,subvol=@ "$root" "$tmp" 2>/dev/null; then
if [[ -f "$tmp/etc/fstab" ]]; then
fstab_uuid="$(awk '$2 == "/boot/efi" {
if ($1 ~ /^UUID=/) { sub(/^UUID=/, "", $1); print $1; exit }
}' "$tmp/etc/fstab")"
fi
umount "$tmp" 2>/dev/null || umount -l "$tmp" 2>/dev/null || true
fi
rmdir "$tmp" 2>/dev/null || true
if [[ -n "${fstab_uuid:-}" ]]; then
fstab_dev="$(blkid -U "$fstab_uuid" 2>/dev/null || true)"
if [[ -n "$fstab_dev" ]]; then
printf '%s\n' "$fstab_dev"
return 0
fi
fi
local best="" scored=0 score
# PARTTYPE is the GPT GUID — no spaces, unlike PARTTYPENAME ("EFI System").
local efi_guid="c12a7328-f81f-11d2-ba4b-00a716dde993"
while read -r dev fstype parttype; do
[[ -n "$dev" ]] || continue
score=0
[[ "$fstype" == "vfat" || "$fstype" == "fat32" || "$fstype" == "FAT-32" ]] && score=$((score + 1))
[[ "${parttype,,}" == "$efi_guid" ]] && score=$((score + 3))
if (( score > scored )); then
best="$dev"
scored=$score
fi
done < <(lsblk -pnlo NAME,FSTYPE,PARTTYPE)
# Prefer an ESP that already has BOS or removable fallback bits.
local probe mp
for dev in $best $(lsblk -pnlo NAME,FSTYPE | awk '$2 == "vfat" { print $1 }'); do
[[ -n "$dev" ]] || continue
mp="$(mktemp -d /tmp/bos-rescue.XXXXXX)" || continue
if mount -o ro "$dev" "$mp" 2>/dev/null; then
if [[ -f "$mp/EFI/BOS/grubx64.efi" || -f "$mp/EFI/BOOT/BOOTX64.EFI" ]]; then
umount "$mp" 2>/dev/null || true
rmdir "$mp" 2>/dev/null || true
printf '%s\n' "$dev"
return 0
fi
umount "$mp" 2>/dev/null || true
fi
rmdir "$mp" 2>/dev/null || true
done
[[ -n "$best" ]] && printf '%s\n' "$best"
}
select_from_list() {
local title="$1"
shift
local -a items=("$@")
local i choice
if (( ${#items[@]} == 0 )); then
return 1
fi
if (( ${#items[@]} == 1 )); then
printf '%s\n' "${items[0]}"
return 0
fi
bold "$title"
for i in "${!items[@]}"; do
printf ' %d) %s\n' "$((i + 1))" "${items[$i]}" >&2
done
printf 'Select [1-%d]: ' "${#items[@]}" >&2
read -r choice || return 1
if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#items[@]} )); then
printf '%s\n' "${items[$((choice - 1))]}"
return 0
fi
return 1
}
discover_and_choose() {
bold "Scanning for an installed BOS (btrfs @) …"
offer_luks
local -a bos_devs=() other_devs=()
local dev kind pretty line
while IFS=$'\t' read -r dev kind pretty; do
[[ -n "$dev" ]] || continue
line="$dev (${pretty:-$kind})"
if [[ "$kind" == "bos" ]]; then
bos_devs+=("$dev")
else
other_devs+=("$dev")
fi
info "found $line"
done < <(find_root_candidates)
if (( ${#bos_devs[@]} == 0 && ${#other_devs[@]} == 0 )); then
echo "No btrfs @ subvolume found. Unlock LUKS first if the install is encrypted." >&2
return 1
fi
if (( ${#bos_devs[@]} == 1 )); then
ROOT_DEV="${bos_devs[0]}"
info "Using BOS root $ROOT_DEV"
elif (( ${#bos_devs[@]} > 1 )); then
ROOT_DEV="$(select_from_list "More than one BOS @ found:" "${bos_devs[@]}")" || return 1
else
warn "No ID=bos os-release on @ — offering every btrfs @ found"
ROOT_DEV="$(select_from_list "Select the installed root device:" "${other_devs[@]}")" || return 1
fi
ESP_DEV="$(find_esp_for_root "$ROOT_DEV" || true)"
if [[ -n "$ESP_DEV" ]]; then
info "Using ESP $ESP_DEV"
fi
if [[ -z "$ESP_DEV" ]]; then
local -a esps=()
while read -r dev; do
[[ -n "$dev" ]] && esps+=("$dev")
done < <(lsblk -pnlo NAME,FSTYPE,PARTTYPE | awk '
$2 == "vfat" || tolower($3) == "c12a7328-f81f-11d2-ba4b-00a716dde993" { print $1 }
')
if (( ${#esps[@]} == 0 )); then
warn "No ESP found. GRUB reinstall on UEFI will fail; chroot is still available."
else
ESP_DEV="$(select_from_list "Select the EFI System Partition:" "${esps[@]}")" || true
fi
fi
}
mount_install() {
pick_mnt
mkdir -p "$MNT"
if ! findmnt -n "$MNT" >/dev/null 2>&1; then
if ! mount -o subvol=@ "$ROOT_DEV" "$MNT"; then
warn "failed to mount $ROOT_DEV subvol=@ at $MNT"
return 1
fi
MOUNTED_ROOT=1
fi
if [[ -n "$ESP_DEV" ]]; then
mkdir -p "$MNT/boot/efi"
if ! findmnt -n "$MNT/boot/efi" >/dev/null 2>&1; then
if mount "$ESP_DEV" "$MNT/boot/efi"; then
MOUNTED_ESP=1
else
warn "failed to mount ESP $ESP_DEV at $MNT/boot/efi"
fi
fi
fi
if [[ "$(lsblk -no TYPE "$ROOT_DEV" 2>/dev/null)" == "crypt" ]]; then
ROOT_ENCRYPTED=1
fi
}
unmount_install() {
if [[ "$MOUNTED_ESP" == "1" ]]; then
umount "$MNT/boot/efi" 2>/dev/null || umount -l "$MNT/boot/efi" 2>/dev/null || true
MOUNTED_ESP=0
fi
if [[ "$MOUNTED_ROOT" == "1" ]]; then
umount "$MNT" 2>/dev/null || umount -l "$MNT" 2>/dev/null || true
MOUNTED_ROOT=0
fi
}
print_plan() {
echo >&2
bold "Devices"
info "root: ${ROOT_DEV:-unset} $([[ -n "$ROOT_DEV" ]] && lsblk_line "$ROOT_DEV")"
info "ESP: ${ESP_DEV:-none} $([[ -n "$ESP_DEV" ]] && lsblk_line "$ESP_DEV")"
info "mount: ${MNT:-unset}"
if [[ -d /sys/firmware/efi ]]; then
info "firmware: UEFI"
else
info "firmware: BIOS"
fi
if [[ "$ROOT_ENCRYPTED" == "1" ]]; then
info "root is LUKS (grub-install will include cryptodisk modules)"
fi
echo >&2
info "Recovery is grub-btrfs (GRUB snapshots submenu) or this GRUB reinstall."
info "GRUB pins rootflags=subvol=@ — do not swap the default subvolume."
}
run_in_target() {
local cmd="$1"
if command -v arch-chroot >/dev/null; then
arch-chroot "$MNT" bash -c "$cmd"
return $?
fi
# arch-install-scripts is not guaranteed on the ISO — bind the API
# filesystems the same way arch-chroot would, then chroot.
mount --bind /proc "$MNT/proc" 2>/dev/null || mount -t proc proc "$MNT/proc"
mount --bind /sys "$MNT/sys" 2>/dev/null || mount -t sysfs sys "$MNT/sys"
mount --bind /dev "$MNT/dev" 2>/dev/null || mount -t devtmpfs udev "$MNT/dev"
mkdir -p "$MNT/run"
mount --bind /run "$MNT/run" 2>/dev/null || mount -t tmpfs tmpfs "$MNT/run"
if [[ -d /sys/firmware/efi ]]; then
mkdir -p "$MNT/sys/firmware/efi/efivars"
mount -t efivarfs efivarfs "$MNT/sys/firmware/efi/efivars" 2>/dev/null || true
fi
chroot "$MNT" bash -c "$cmd"
local rc=$?
umount "$MNT/sys/firmware/efi/efivars" 2>/dev/null || true
umount "$MNT/run" 2>/dev/null || true
umount "$MNT/dev" 2>/dev/null || true
umount "$MNT/sys" 2>/dev/null || true
umount "$MNT/proc" 2>/dev/null || true
return "$rc"
}
grub_commands_preview() {
if [[ -d /sys/firmware/efi ]]; then
cat <<'EOF' >&2
grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=BOS --recheck
grub-install --target=x86_64-efi --efi-directory=/boot/efi --removable --recheck
grub-mkconfig -o /boot/grub/grub.cfg
EOF
else
cat <<'EOF' >&2
grub-install --target=i386-pc --recheck <disk-hosting-root>
grub-mkconfig -o /boot/grub/grub.cfg
EOF
fi
}
reinstall_grub() {
if [[ ! -d "$MNT/boot" ]]; then
warn "target $MNT/boot missing — mount the installed @ first"
return 1
fi
echo >&2
bold "This will write a bootloader using:"
info "root ${ROOT_DEV:-/} ESP ${ESP_DEV:-n/a} chroot $MNT"
grub_commands_preview
echo >&2
if ! confirm_yes "Reinstall GRUB now?"; then
info "skipped"
return 0
fi
# Same sequence as post-install.sh (UEFI NVRAM + --removable, or BIOS MBR).
local script
script="$(cat <<'EOS'
set -uo pipefail
ROOT_SRC="$(findmnt -no SOURCE / | sed 's/\[.*\]//')"
if [[ "$(lsblk -no TYPE "$ROOT_SRC" 2>/dev/null)" == "crypt" ]]; then
ROOT_ENCRYPTED=1
else
ROOT_ENCRYPTED=0
fi
if [[ "$ROOT_ENCRYPTED" == "1" ]] && [[ -f /etc/default/grub ]] \
&& ! grep -q '^GRUB_ENABLE_CRYPTODISK=' /etc/default/grub; then
echo 'GRUB_ENABLE_CRYPTODISK=y' >> /etc/default/grub \
|| echo "WARN: adding GRUB_ENABLE_CRYPTODISK failed"
fi
if ! command -v grub-install >/dev/null; then
echo "ERROR: grub-install not found in the installed system" >&2
exit 1
fi
CRYPT_MODULES=()
[[ "$ROOT_ENCRYPTED" == "1" ]] && CRYPT_MODULES=(--modules="cryptodisk luks luks2")
if [[ -d /sys/firmware/efi ]]; then
grub-install --target=x86_64-efi --efi-directory=/boot/efi \
--bootloader-id=BOS --recheck "${CRYPT_MODULES[@]}" \
|| echo "WARN: grub-install (nvram) failed"
grub-install --target=x86_64-efi --efi-directory=/boot/efi \
--removable --recheck "${CRYPT_MODULES[@]}" \
|| echo "WARN: grub-install (removable) failed"
else
ROOT_DEV="$(findmnt -no SOURCE / | sed 's/\[.*\]//')"
ROOT_DISK="$(lsblk -no pkname "$ROOT_DEV" 2>/dev/null)"
if [[ -n "$ROOT_DISK" ]]; then
grub-install --target=i386-pc --recheck "${CRYPT_MODULES[@]}" "/dev/$ROOT_DISK" \
|| echo "WARN: grub-install (BIOS) failed"
else
echo "WARN: could not determine the disk hosting / — BIOS grub-install skipped"
fi
fi
if command -v grub-mkconfig >/dev/null; then
grub-mkconfig -o /boot/grub/grub.cfg || echo "WARN: grub-mkconfig failed"
else
echo "WARN: grub-mkconfig not found"
fi
EOS
)"
if run_in_target "$script"; then
bold "GRUB reinstall finished."
info "Firmware that lost its NVRAM entry can still boot EFI/BOOT/BOOTX64.EFI."
else
warn "GRUB reinstall returned non-zero — see messages above"
return 1
fi
}
do_chroot() {
if [[ ! -d "$MNT/etc" ]]; then
warn "target $MNT is not a mounted system"
return 1
fi
bold "Entering chroot at $MNT (exit to return)."
if command -v arch-chroot >/dev/null; then
arch-chroot "$MNT"
else
run_in_target "exec bash -l"
fi
}
menu_live() {
local choice
while true; do
echo
bold "bos-rescue"
print_plan
cat <<'EOF' >&2
1) arch-chroot into the installed system
2) Reinstall GRUB (NVRAM + --removable + grub-mkconfig)
3) Reinstall GRUB, then chroot
4) Unmount and quit
q) Quit (leave mounts)
EOF
printf 'Choice: ' >&2
read -r choice || choice="q"
case "$choice" in
1) do_chroot ;;
2) reinstall_grub ;;
3) reinstall_grub; do_chroot ;;
4) unmount_install; bold "Unmounted."; return 0 ;;
q|Q) info "Leaving mounts in place at $MNT"; return 0 ;;
*) info "unknown choice" ;;
esac
done
}
menu_installed() {
ROOT_DEV="$(findmnt -no SOURCE / | sed 's/\[.*\]//')"
ESP_DEV="$(findmnt -no SOURCE /boot/efi 2>/dev/null || true)"
MNT="/"
if [[ "$(lsblk -no TYPE "$ROOT_DEV" 2>/dev/null)" == "crypt" ]]; then
ROOT_ENCRYPTED=1
fi
echo
bold "Already running the installed BOS (not the live ISO)."
info "Root and ESP are already mounted — chroot is not needed."
print_plan
if confirm_yes "Reinstall GRUB on this running system?"; then
# Running on the installed root: no extra mount/chroot.
local old_mnt="$MNT"
MNT="/"
# run_in_target would chroot into / — just run locally.
if [[ -d /sys/firmware/efi && -z "$ESP_DEV" ]]; then
warn " /boot/efi is not mounted — refusing to write"
return 1
fi
bash -c "$(cat <<'EOS'
set -uo pipefail
ROOT_SRC="$(findmnt -no SOURCE / | sed 's/\[.*\]//')"
if [[ "$(lsblk -no TYPE "$ROOT_SRC" 2>/dev/null)" == "crypt" ]]; then
ROOT_ENCRYPTED=1
else
ROOT_ENCRYPTED=0
fi
if [[ "$ROOT_ENCRYPTED" == "1" ]] && [[ -f /etc/default/grub ]] \
&& ! grep -q '^GRUB_ENABLE_CRYPTODISK=' /etc/default/grub; then
echo 'GRUB_ENABLE_CRYPTODISK=y' >> /etc/default/grub \
|| echo "WARN: adding GRUB_ENABLE_CRYPTODISK failed"
fi
CRYPT_MODULES=()
[[ "$ROOT_ENCRYPTED" == "1" ]] && CRYPT_MODULES=(--modules="cryptodisk luks luks2")
if [[ -d /sys/firmware/efi ]]; then
grub-install --target=x86_64-efi --efi-directory=/boot/efi \
--bootloader-id=BOS --recheck "${CRYPT_MODULES[@]}" \
|| echo "WARN: grub-install (nvram) failed"
grub-install --target=x86_64-efi --efi-directory=/boot/efi \
--removable --recheck "${CRYPT_MODULES[@]}" \
|| echo "WARN: grub-install (removable) failed"
else
ROOT_DISK="$(lsblk -no pkname "$ROOT_SRC" 2>/dev/null)"
if [[ -n "$ROOT_DISK" ]]; then
grub-install --target=i386-pc --recheck "${CRYPT_MODULES[@]}" "/dev/$ROOT_DISK" \
|| echo "WARN: grub-install (BIOS) failed"
fi
fi
grub-mkconfig -o /boot/grub/grub.cfg || echo "WARN: grub-mkconfig failed"
EOS
)"
MNT="$old_mnt"
else
info "skipped"
fi
}
main() {
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
need_root
local req
for req in mount lsblk blkid findmnt; do
if ! command -v "$req" >/dev/null; then
echo "bos-rescue: missing required tool '$req'" >&2
exit 1
fi
done
bold "bos-rescue"
info "Live-ISO recovery helper. Prints devices and asks YES before writing."
info "Use grub-btrfs (GRUB snapshots submenu) for a bootable snapshot."
info "Do not snapper-rollback — GRUB pins rootflags=subvol=@."
echo
if already_on_installed; then
menu_installed
return 0
fi
if ! is_live_iso; then
warn "This does not look like the BOS live ISO (/run/archiso missing)."
info "Continuing anyway — will scan disks for a BOS @."
fi
discover_and_choose || exit 1
print_plan
if ! confirm_yes "Mount these devices and continue?"; then
info "nothing mounted"
exit 0
fi
mount_install || exit 1
menu_live
}
main "$@"

View file

@ -29,4 +29,6 @@ file_permissions=(
["/usr/local/bin/bos-session"]="0:0:755" ["/usr/local/bin/bos-session"]="0:0:755"
["/usr/local/bin/bos-netcheck"]="0:0:755" ["/usr/local/bin/bos-netcheck"]="0:0:755"
["/usr/local/bin/bos-update"]="0:0:755" ["/usr/local/bin/bos-update"]="0:0:755"
["/usr/local/bin/bos-rescue"]="0:0:755"
["/usr/local/bin/bos-first-boot"]="0:0:755"
) )

View file

@ -65,6 +65,8 @@ check "breadhelp installed" "command -v breadhelp"
check "breadhelp content installed" \ check "breadhelp content installed" \
"[ -d \"\$HOME/.local/share/breadhelp/content\" ] || [ -d /etc/skel/.local/share/breadhelp/content ]" "[ -d \"\$HOME/.local/share/breadhelp/content\" ] || [ -d /etc/skel/.local/share/breadhelp/content ]"
check "bos-netcheck present" "command -v bos-netcheck" check "bos-netcheck present" "command -v bos-netcheck"
check "bos-rescue present" "command -v bos-rescue"
check "bos-first-boot present" "command -v bos-first-boot"
echo "== default dotfiles ==" echo "== default dotfiles =="
check "hyprland.lua present" "[ -f \"\$HOME/.config/hypr/hyprland.lua\" ]" check "hyprland.lua present" "[ -f \"\$HOME/.config/hypr/hyprland.lua\" ]"
@ -72,6 +74,7 @@ check "binds.json present" "[ -f \"\$HOME/.config/hypr/binds.json\" ]"
check "monitors.json present" "[ -f \"\$HOME/.config/hypr/monitors.json\" ]" check "monitors.json present" "[ -f \"\$HOME/.config/hypr/monitors.json\" ]"
check "settings.json present" "[ -f \"\$HOME/.config/hypr/settings.json\" ]" check "settings.json present" "[ -f \"\$HOME/.config/hypr/settings.json\" ]"
check "autostart.json present" "[ -f \"\$HOME/.config/hypr/autostart.json\" ]" check "autostart.json present" "[ -f \"\$HOME/.config/hypr/autostart.json\" ]"
check "autostart includes first-boot probe" "grep -q bos-first-boot \"\$HOME/.config/hypr/autostart.json\""
check "hypr scripts/lib present" "[ -f \"\$HOME/.config/hypr/scripts/lib/json.lua\" ]" check "hypr scripts/lib present" "[ -f \"\$HOME/.config/hypr/scripts/lib/json.lua\" ]"
check "mimeapps.list present" "[ -f \"\$HOME/.config/mimeapps.list\" ]" check "mimeapps.list present" "[ -f \"\$HOME/.config/mimeapps.list\" ]"
check "kitty config present" "[ -f \"\$HOME/.config/kitty/kitty.conf\" ]" check "kitty config present" "[ -f \"\$HOME/.config/kitty/kitty.conf\" ]"