From fd385bafae4018f71134d55338c7487c7c2264f5 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 23:47:03 +0800 Subject: [PATCH 01/10] ci: install grub on the ISO builder profiledef.sh uses uefi.grub. mkarchiso checks for grub-install on the host before building; archiso does not pull grub, so the v0.6.0 bake aborted after bakery staging. --- .forgejo/workflows/release-iso.yml | 5 ++++- scripts/ci-verify-bake.sh | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/release-iso.yml b/.forgejo/workflows/release-iso.yml index 7191c22..a30e291 100644 --- a/.forgejo/workflows/release-iso.yml +++ b/.forgejo/workflows/release-iso.yml @@ -41,7 +41,10 @@ jobs: steps: - name: Install build dependencies run: | - pacman -Syu --noconfirm archiso curl python git minisign + # grub is required by profiledef.sh bootmodes=('uefi.grub'): + # mkarchiso validates grub-install on the *builder*, not the image. + # archiso pulls syslinux/squashfs-tools/libisoburn; it does not pull grub. + pacman -Syu --noconfirm archiso grub curl python git minisign - name: Determine tag and version id: vars diff --git a/scripts/ci-verify-bake.sh b/scripts/ci-verify-bake.sh index 1b01cab..d02d0b0 100755 --- a/scripts/ci-verify-bake.sh +++ b/scripts/ci-verify-bake.sh @@ -42,6 +42,12 @@ PY echo "== lockfile $LOCKFILE ==" echo " ${#REQUIRED_BINS[@]} required, ${#OPTIONAL_BINS[@]} optional" +echo "== host tools ==" +if command -v grub-install >/dev/null 2>&1; then + ok "grub-install (uefi.grub bootmode)" +else + bad "grub-install missing — mkarchiso uefi.grub will abort (install grub on the builder)" +fi echo "== builder home $LAPTOP_HOME ==" check_exec() { From 744f18cd90788fd05ff0c9c23c1d8edc7ebb1fcd Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 23:59:38 +0800 Subject: [PATCH 02/10] iso: add bos-rescue, first-boot probe, optional Calamares refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 9 +- docs/hardware.md | 16 +- .../etc/calamares/modules/packages.conf | 26 +- iso/airootfs/etc/calamares/settings.conf | 12 +- .../etc/skel/.config/hypr/autostart.json | 1 + .../etc/skel/.config/hypr/hyprland.lua | 1 + .../.config/hypr/scripts/system/autostart.lua | 1 + iso/airootfs/usr/local/bin/bos-first-boot | 187 ++++++ iso/airootfs/usr/local/bin/bos-rescue | 598 ++++++++++++++++++ iso/profiledef.sh | 2 + scripts/smoke-test.sh | 3 + 11 files changed, 838 insertions(+), 18 deletions(-) create mode 100755 iso/airootfs/usr/local/bin/bos-first-boot create mode 100755 iso/airootfs/usr/local/bin/bos-rescue diff --git a/README.md b/README.md index ccaf576..5c7348c 100644 --- a/README.md +++ b/README.md @@ -360,14 +360,15 @@ not shipped. **The system won't boot (broken GRUB / lost EFI entry):** 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 mount -o subvol=@ /dev/sdXN /mnt mount /dev/sdXP /mnt/boot/efi # the EFI partition 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 --removable --recheck grub-mkconfig -o /boot/grub/grub.cfg diff --git a/docs/hardware.md b/docs/hardware.md index 4bc2d40..8add314 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -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. 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 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 entry so the kernel command line matches the snapshot you want. -A/B root swapping is not implemented. See the README Recovery section for -the "system will not boot" GRUB/EFI repair path. +If the system will not boot (lost EFI entry / broken GRUB), boot the live +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. diff --git a/iso/airootfs/etc/calamares/modules/packages.conf b/iso/airootfs/etc/calamares/modules/packages.conf index c327cb6..a3646e0 100644 --- a/iso/airootfs/etc/calamares/modules/packages.conf +++ b/iso/airootfs/etc/calamares/modules/packages.conf @@ -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 -options: - - update_db: true +skip_if_no_internet: true +update_db: true +ignore_update_db_error: true +update_system: false -operations: - - try_install: - - pipewire-pulse - - pipewire-alsa +pacman: + num_retries: 1 + disable_download_timeout: false + needed_only: true + +operations: [] diff --git a/iso/airootfs/etc/calamares/settings.conf b/iso/airootfs/etc/calamares/settings.conf index 8872182..1daec33 100644 --- a/iso/airootfs/etc/calamares/settings.conf +++ b/iso/airootfs/etc/calamares/settings.conf @@ -35,12 +35,6 @@ sequence: - users - networkcfg - 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 # initramfs config, and write a stock mkinitcpio preset before initcpio runs. - shellprocess@kernel @@ -57,6 +51,12 @@ sequence: # BOS finalization: GRUB install + cleanup + snapper + services + dotfiles. # All fast, and runs after initcpio so /boot has the kernel + initramfs. - 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 - show: - finished diff --git a/iso/airootfs/etc/skel/.config/hypr/autostart.json b/iso/airootfs/etc/skel/.config/hypr/autostart.json index 87de11a..ee66107 100644 --- a/iso/airootfs/etc/skel/.config/hypr/autostart.json +++ b/iso/airootfs/etc/skel/.config/hypr/autostart.json @@ -3,6 +3,7 @@ { "command": "breadbar", "label": "Bar (breadbar)", "enabled": true }, { "command": "hypridle", "label": "Idle / lock daemon (hypridle)", "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": "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 }, diff --git a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua index 57360a3..d71731a 100644 --- a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua +++ b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua @@ -174,6 +174,7 @@ hl.on("hyprland.start", function() "breadbar", "hypridle", "bos-netcheck", + "bash -c 'command -v bos-first-boot >/dev/null && exec bos-first-boot'", "breadhelp --autostart", "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", diff --git a/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua b/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua index ce4465f..46f4970 100644 --- a/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua +++ b/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua @@ -18,6 +18,7 @@ local DEFAULT_EXTRA = { { command = "breadbar", enabled = true }, { command = "hypridle", 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 = "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 }, diff --git a/iso/airootfs/usr/local/bin/bos-first-boot b/iso/airootfs/usr/local/bin/bos-first-boot new file mode 100755 index 0000000..d17fc70 --- /dev/null +++ b/iso/airootfs/usr/local/bin/bos-first-boot @@ -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" </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" < 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 diff --git a/iso/airootfs/usr/local/bin/bos-rescue b/iso/airootfs/usr/local/bin/bos-rescue new file mode 100755 index 0000000..f814865 --- /dev/null +++ b/iso/airootfs/usr/local/bin/bos-rescue @@ -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: DEVICEKINDPRETTY 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 + 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 "$@" diff --git a/iso/profiledef.sh b/iso/profiledef.sh index c438138..5034cbc 100644 --- a/iso/profiledef.sh +++ b/iso/profiledef.sh @@ -29,4 +29,6 @@ file_permissions=( ["/usr/local/bin/bos-session"]="0:0:755" ["/usr/local/bin/bos-netcheck"]="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" ) diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index ab70559..19e7d75 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -65,6 +65,8 @@ check "breadhelp installed" "command -v breadhelp" check "breadhelp content installed" \ "[ -d \"\$HOME/.local/share/breadhelp/content\" ] || [ -d /etc/skel/.local/share/breadhelp/content ]" 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 ==" 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 "settings.json present" "[ -f \"\$HOME/.config/hypr/settings.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 "mimeapps.list present" "[ -f \"\$HOME/.config/mimeapps.list\" ]" check "kitty config present" "[ -f \"\$HOME/.config/kitty/kitty.conf\" ]" From 34043086b964cde8c98fc4088c175bdde62be31a Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:10:32 +0800 Subject: [PATCH 03/10] iso: bake bakery apps into /usr/local BOS opts in to bakery's system prefix so desktop apps live on @ and ride snapper/grub-btrfs snapshots. The builder home stays ~/.local; build-local.sh copies bins, share/data, and user units onto the image. Per-user installed.json and the index cache stay in skel. Recovery is still grub-btrfs, not snapper rollback. --- AGENTS.md | 5 +- README.md | 27 ++-- build-local.sh | 122 ++++++++++++------ docs/hardware.md | 4 +- iso/airootfs/etc/bakery/config.toml | 2 + iso/airootfs/etc/calamares/post-install.sh | 7 +- iso/airootfs/etc/greetd/breadgreet.toml | 5 +- iso/airootfs/etc/pacman.conf | 2 +- iso/airootfs/etc/profile.d/bos-local-bin.sh | 10 +- .../etc/skel/.config/hypr/hyprland.lua | 11 +- .../skel/.config/systemd/user/breadd.service | 4 +- iso/airootfs/etc/skel/.zshrc | 2 +- iso/airootfs/usr/local/bin/bos-session | 9 +- iso/airootfs/usr/local/bin/bos-update | 16 +-- iso/bread-lockfile.toml | 2 +- iso/packages.x86_64 | 4 +- iso/pacman.conf | 2 +- scripts/ci-verify-bake.sh | 85 ++++++++++-- scripts/smoke-test.sh | 2 +- 19 files changed, 218 insertions(+), 103 deletions(-) create mode 100644 iso/airootfs/etc/bakery/config.toml diff --git a/AGENTS.md b/AGENTS.md index 3a9f4bf..8019d5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,4 +52,7 @@ There is no `dev` integration branch. - Don't bake an ISO (`sudo ./build-local.sh`) unless asked — lockfile/docs work does not require it. - Don't tell users to `snapper rollback` blindly; GRUB pins - `rootflags=subvol=@`. Recovery is grub-btrfs reboot. + `rootflags=subvol=@`. Recovery is grub-btrfs reboot. Bakery desktop + apps on BOS are system-prefix `/usr/local` (`/etc/bakery/config.toml`); + snapper `@` snapshots include them. Do not move those bits back to + `~/.local` on the image (hermes / default bakery stay user-layout). diff --git a/README.md b/README.md index 5c7348c..387c5f4 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,9 @@ wiring up dotfiles, no per-tool bakery installs. - **Compositor**: Hyprland with a native-Lua config (`hyprland.lua`), curated keybinds, snappy animations, blur, and pywal-driven colours on a black base. -- **bread ecosystem**, baked into `/etc/skel` from bakery-managed binaries - (no network needed at install time): the `bread`/`breadd` automation daemon +- **bread ecosystem**, baked into `/usr/local` from bakery-managed binaries + (no network needed at install time; per-user bakery state is seeded in + `/etc/skel`): the `bread`/`breadd` automation daemon (`bread-emit` / `bread-module-host` when the stable bread release publishes them), `breadbar` (status bar + notifications), `breadbox` (launcher), `breadclip` (clipboard history), `breadcrumbs` (Wi-Fi profiles), @@ -58,7 +59,7 @@ wiring up dotfiles, no per-tool bakery installs. | Channel | What | |---------|------| -| **Bakery, required** | `bakery`, `bread` / `breadd`, `breadbar`, `breadbox` / `breadbox-sync`, `breadcrumbs`, `breadpad` / `breadman`, `breadpaper`, `bread-theme`, `breadmon`, `breadsearch` / `breadmill`, `breadclip` / `breadclipd`, `breadshot`, `bos-settings`, `breadhelp` (+ breadhelp content under `~/.local/share/breadhelp/`) | +| **Bakery, required** | `bakery`, `bread` / `breadd`, `breadbar`, `breadbox` / `breadbox-sync`, `breadcrumbs`, `breadpad` / `breadman`, `breadpaper`, `bread-theme`, `breadmon`, `breadsearch` / `breadmill`, `breadclip` / `breadclipd`, `breadshot`, `bos-settings`, `breadhelp` (+ breadhelp content under `/usr/local/share/breadhelp/`) | | **Bakery, optional** | `bread-emit`, `bread-module-host` — baked when the verified stable index publishes them; skipped (not a failed bake) until bread ships them | | **pacman (`packages.x86_64`)** | `breadlock`, plus the rest of the distro (Hyprland, Calamares, Zen, …) | | **Not shipped** | `breadcast`, `breadarr` | @@ -123,9 +124,15 @@ Push `origin` (and `github` when mirroring). Do not treat origin as GitHub. ## Building the ISO -`build-local.sh` builds the image natively (no container) and bakes this -machine's bakery-installed bread binaries + breadhelp content into -`/etc/skel`: +`build-local.sh` builds the image natively (no container) and copies this +machine's bakery-installed bread binaries + breadhelp content from the +builder's `~/.local` into the image at `/usr/local` (bins, share/data, +desktop files, licenses) and `/usr/lib/systemd/user` (units). Per-user +bakery state (`installed.json` + index cache) is seeded in `/etc/skel`. +BOS opts in via `/etc/bakery/config.toml` (`prefix = "/usr/local"`); +default bakery without that file is still `~/.local`. Snapper `@` +snapshots include `/usr/local`; recovery is still grub-btrfs, not +`snapper rollback`. ```sh sudo ./build-local.sh # release-quality (xz squashfs) @@ -190,8 +197,8 @@ Hyprland session in QEMU. The disk lives on NVMe (not the tmpfs `/tmp`) to avoid memory pressure. Post-install, `scripts/smoke-test.sh` (run as the installed user) checks -subvolumes, services, bakery bins, and breadhelp content under -`~/.local/share/breadhelp/content`. +subvolumes, services, bakery bins on PATH, and breadhelp content under +`/usr/local/share/breadhelp/content`. ## bos-settings @@ -234,7 +241,7 @@ repo, not here. ## The bread ecosystem Everything below is a separate bakery-distributed project with its own repo -and release cadence, baked into `/etc/skel` at ISO build time so a fresh +and release cadence, baked into `/usr/local` at ISO build time so a fresh install has them all with no network round-trip. Some ship more than one binary from a single package — that's noted where it applies. Most have a corresponding **bos-settings** panel; this table is about *using* the app @@ -258,7 +265,7 @@ directly. | `breadman` | The fuller notes manager view (browse/organize) — ships from the same `breadpad` package as a second binary | `SUPER+M` | | `breadclip` | Clipboard history. `breadclipd` is the background daemon that actually records history; `breadclip` is the GTK4 popup that browses it | `SUPER+V` / `SUPER+Shift+V` | | `breadsearch` | Semantic system-wide search (indexes files/notes, embeds locally — CPU/ROCm/CUDA backend configurable). `breadmill` is its indexing daemon. | via breadbox, or BOS Settings → File Search | -| `breadhelp` | Onboarding + in-session help/cheatsheet. Content lives at `~/.local/share/breadhelp/content` (bakery `content.tar.gz`, baked into skel). | `SUPER+/` | +| `breadhelp` | Onboarding + in-session help/cheatsheet. Content lives at `/usr/local/share/breadhelp/content` (bakery `content.tar.gz`, baked into the image). | `SUPER+/` | **System** diff --git a/build-local.sh b/build-local.sh index b06f58d..8548d86 100755 --- a/build-local.sh +++ b/build-local.sh @@ -41,13 +41,15 @@ if [ "${FAST_BUILD:-0}" = "1" ]; then fi grep airootfs_image_tool_options "$STAGE/profiledef.sh" -# --- Bake this machine's bakery-installed bread ecosystem into /etc/skel ------ +# --- Bake this machine's bakery-installed bread ecosystem into the image ------ # The bread desktop apps are bakery-managed (release binaries from # dl.breadway.dev / GitHub), not pacman. bakery needs DNS at install time, # which the live/installed image doesn't have — so instead of running bakery # on the target, we copy the binaries + bakery manifest this builder already -# has into skel. Every user created from skel then gets those versions fully -# offline. Copied at build time so the binaries never bloat the git repo. +# has. Builder home stays user-layout (~/.local); the *image* is system-prefix +# /usr/local so apps live on @ and ride snapper/grub-btrfs snapshots. +# installed.json + index cache stay per-user in skel. Copied at build time +# so the binaries never bloat the git repo. # # CI should prefer the stable bakery index when populating the builder home. # Local builds still snapshot the builder. required_bins fail the bake if @@ -97,9 +99,14 @@ BAKERY_BIN="$LAPTOP_HOME/.local/bin" BAKERY_STATE="$LAPTOP_HOME/.local/state/bakery" BAKERY_CACHE="$LAPTOP_HOME/.cache/bakery" BAKERY_SHARE="$LAPTOP_HOME/.local/share" -SKEL="$STAGE/airootfs/etc/skel" +AIROOTFS="$STAGE/airootfs" +IMAGE_BIN="$AIROOTFS/usr/local/bin" +IMAGE_SHARE="$AIROOTFS/usr/local/share" +IMAGE_UNITS="$AIROOTFS/usr/lib/systemd/user" +SKEL="$AIROOTFS/etc/skel" echo "=== baking bakery bread ecosystem from $LAPTOP_HOME ===" echo "lockfile: $LOCKFILE (${#REQUIRED_BINS[@]} required, ${#OPTIONAL_BINS[@]} optional)" +echo "image prefix: /usr/local (bins $IMAGE_BIN, share $IMAGE_SHARE, units $IMAGE_UNITS)" missing=() for b in "${REQUIRED_BINS[@]}"; do @@ -124,9 +131,9 @@ for b in "${OPTIONAL_BINS[@]}"; do fi done -install -d -m 0755 "$SKEL/.local/bin" "$SKEL/.local/state/bakery" "$SKEL/.cache/bakery" +install -d -m 0755 "$IMAGE_BIN" "$SKEL/.local/state/bakery" "$SKEL/.cache/bakery" for b in "${BREAD_BINS[@]}"; do - install -m 0755 "$BAKERY_BIN/$b" "$SKEL/.local/bin/$b" + install -m 0755 "$BAKERY_BIN/$b" "$IMAGE_BIN/$b" done # Drop packages that are not in the lockfile (breadcast/breadarr must not @@ -164,26 +171,27 @@ if [[ ! -f "$BAKERY_CACHE/index.json" ]]; then exit 1 fi install -m 0644 "$BAKERY_CACHE/index.json" "$SKEL/.cache/bakery/index.json" -echo "baked bins: $(ls "$SKEL/.local/bin")" +echo "baked bins: $(ls "$IMAGE_BIN")" # --- Bake bakery data dirs the apps need offline ------------------------------ # bakery extracts data_archive (breadhelp's content.tar.gz) to -# ~/.local/share// and writes desktop entries + licenses next to it. -# Copy those — never laptop-local state (clipboard history, WebKit cache, -# bread sync-repo, models). -echo "=== baking bakery share/data into skel ===" +# $prefix/share// and writes desktop entries + licenses next to it. +# Builder home is still ~/.local/share; copy into the image at +# /usr/local/share. Never laptop-local state (clipboard history, WebKit +# cache, bread sync-repo, models). +echo "=== baking bakery share/data into /usr/local/share ===" BREADHELP_CONTENT="$BAKERY_SHARE/breadhelp/content" if [[ ! -d "$BREADHELP_CONTENT" ]]; then echo "ERROR: breadhelp content missing: $BREADHELP_CONTENT" >&2 - echo "bakery installs this from content.tar.gz into ~/.local/share/breadhelp/content" >&2 + echo "bakery installs this from content.tar.gz into ~/.local/share/breadhelp/content on the builder" >&2 echo "A breadhelp binary without content is a hollow ISO." >&2 exit 1 fi -install -d -m 0755 "$SKEL/.local/share" -cp -a "$BAKERY_SHARE/breadhelp" "$SKEL/.local/share/breadhelp" -echo " baked $SKEL/.local/share/breadhelp/content" +install -d -m 0755 "$IMAGE_SHARE" +cp -a "$BAKERY_SHARE/breadhelp" "$IMAGE_SHARE/breadhelp" +echo " baked $IMAGE_SHARE/breadhelp/content" -python3 - "$BAKERY_CACHE/index.json" "$BAKERY_SHARE" "$SKEL/.local/share" "${BREAD_BINS[@]}" <<'PY' +python3 - "$BAKERY_CACHE/index.json" "$BAKERY_SHARE" "$IMAGE_SHARE" "${BREAD_BINS[@]}" <<'PY' import json, os, shutil, sys index_path, src_share, dest_share, *bins = sys.argv[1:] wanted = set(bins) @@ -242,14 +250,16 @@ PY # silently left out, so those daemons never start on a fresh install/live # boot until the user re-runs `bakery install` (which needs network). # Source of truth is the *filtered* installed.json we just wrote: only -# lockfile packages. Copy each unit with ExecStart rewritten from this -# laptop's literal home path to the portable `%h` specifier, and recreate -# whichever *.target.wants enable symlink bakery created locally. Units -# already committed by hand (breadd.service carries a -# RuntimeDirectoryPreserve=yes fix not yet upstreamed) are left alone. -echo "=== baking bakery service units into skel ===" +# lockfile packages. Units go to /usr/lib/systemd/user with ExecStart +# rewritten to /usr/local/bin (not %h/.local/bin). Recreate whichever +# *.target.wants enable symlink bakery created locally (or that skel +# already ships). Hand-committed skel units (breadd.service carries a +# RuntimeDirectoryPreserve=yes fix not yet upstreamed) are the source +# for that unit and also get their ExecStart rewritten in skel. +echo "=== baking bakery service units into /usr/lib/systemd/user ===" SYSTEMD_USER_DIR="$LAPTOP_HOME/.config/systemd/user" SKEL_SYSTEMD="$SKEL/.config/systemd/user" +install -d -m 0755 "$IMAGE_UNITS" mapfile -t SERVICE_UNITS < <(python3 - "$SKEL/.local/state/bakery/installed.json" <<'PY' import json, sys with open(sys.argv[1]) as f: @@ -259,40 +269,72 @@ for pkg in d.get("packages", d).values(): print(s["unit"] if isinstance(s, dict) else s) PY ) +rewrite_exec_start() { + local src="$1" dest="$2" + python3 - "$src" "$dest" <<'PY' +import os, sys +src, dest = sys.argv[1], sys.argv[2] +text = open(src).read() +lines = [] +for line in text.splitlines(): + if line.lstrip().startswith("ExecStart="): + key, rest = line.split("=", 1) + argv = rest.split() + if argv: + name = os.path.basename(argv[0]) + argv[0] = "/usr/local/bin/" + name + line = key + "=" + " ".join(argv) + lines.append(line) +out = "\n".join(lines) +if text.endswith("\n"): + out += "\n" +os.makedirs(os.path.dirname(dest), exist_ok=True) +with open(dest, "w") as f: + f.write(out) +PY +} for unit in "${SERVICE_UNITS[@]}"; do [[ -n "$unit" ]] || continue if [[ -f "$SKEL_SYSTEMD/$unit" ]]; then - echo " $unit already committed in skel, leaving as-is" - continue + src="$SKEL_SYSTEMD/$unit" + echo " $unit using committed skel unit as source" + else + src="$SYSTEMD_USER_DIR/$unit" + if [[ ! -f "$src" ]]; then + echo "ERROR: $unit listed in bakery installed.json but not found at $src" >&2 + echo "Refusing to bake an image whose daemons will never start." >&2 + exit 1 + fi fi - src="$SYSTEMD_USER_DIR/$unit" - if [[ ! -f "$src" ]]; then - echo "ERROR: $unit listed in bakery installed.json but not found at $src" >&2 - echo "Refusing to bake a skel whose daemons will never start." >&2 - exit 1 + rewrite_exec_start "$src" "$IMAGE_UNITS/$unit" + if [[ -f "$SKEL_SYSTEMD/$unit" ]]; then + rewrite_exec_start "$src" "$SKEL_SYSTEMD/$unit" fi - install -d -m 0755 "$SKEL_SYSTEMD" - sed "s#ExecStart=$LAPTOP_HOME/.local/bin/#ExecStart=%h/.local/bin/#" "$src" > "$SKEL_SYSTEMD/$unit" - for wants_dir in "$SYSTEMD_USER_DIR"/*.target.wants; do - [[ -L "$wants_dir/$unit" ]] || continue - target_name="$(basename "$wants_dir")" - install -d -m 0755 "$SKEL_SYSTEMD/$target_name" - ln -sf "../$unit" "$SKEL_SYSTEMD/$target_name/$unit" + for base in "$SYSTEMD_USER_DIR" "$SKEL_SYSTEMD"; do + [[ -d "$base" ]] || continue + for wants_dir in "$base"/*.target.wants; do + [[ -e "$wants_dir" || -L "$wants_dir" ]] || continue + [[ -L "$wants_dir/$unit" ]] || continue + target_name="$(basename "$wants_dir")" + install -d -m 0755 "$IMAGE_UNITS/$target_name" + ln -sf "../$unit" "$IMAGE_UNITS/$target_name/$unit" + done done - echo " baked $unit" + echo " baked $unit -> $IMAGE_UNITS/$unit" done # mkarchiso resets every airootfs file to 0644, so executables must be declared # in profiledef.sh's file_permissions array or they ship non-executable and the # exec-once launches fail with "permission denied". Inject a 0755 entry for each -# baked binary right after the array opener (keeps the binary list in one place). +# baked bakery binary right after the array opener (bos-* bins are already +# listed; keeps the bakery list in one place — the lockfile). perm_file="$(mktemp)" for b in "${BREAD_BINS[@]}"; do - printf ' ["/etc/skel/.local/bin/%s"]="0:0:755"\n' "$b" >>"$perm_file" + printf ' ["/usr/local/bin/%s"]="0:0:755"\n' "$b" >>"$perm_file" done sed -i "/^file_permissions=(/r $perm_file" "$STAGE/profiledef.sh" rm -f "$perm_file" -echo "=== file_permissions after injection ==="; grep -A14 '^file_permissions=(' "$STAGE/profiledef.sh" +echo "=== file_permissions after injection ==="; grep -A40 '^file_permissions=(' "$STAGE/profiledef.sh" # Pin one timestamp for the whole build. Without this, mkarchiso derives the # boot-config UUID (%ARCHISO_UUID%) when it starts and the iso9660 volume UUID diff --git a/docs/hardware.md b/docs/hardware.md index 8add314..2f87838 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -24,7 +24,9 @@ submenu** (grub-btrfs), not `snapper rollback`. BOS GRUB pins `rootflags=subvol=@`. `snapper rollback` swaps the default 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. Bakery +desktop apps live under `/usr/local` on `@`, so those same snapshots +include them. If the system will not boot (lost EFI entry / broken GRUB), boot the live ISO and run `sudo bos-rescue`. It mounts `@` + the ESP and offers the same diff --git a/iso/airootfs/etc/bakery/config.toml b/iso/airootfs/etc/bakery/config.toml new file mode 100644 index 0000000..996b04a --- /dev/null +++ b/iso/airootfs/etc/bakery/config.toml @@ -0,0 +1,2 @@ +# Bakery desktop apps live under /usr/local so they ride snapper @ snapshots. +prefix = "/usr/local" diff --git a/iso/airootfs/etc/calamares/post-install.sh b/iso/airootfs/etc/calamares/post-install.sh index 2076dcf..ac13a97 100644 --- a/iso/airootfs/etc/calamares/post-install.sh +++ b/iso/airootfs/etc/calamares/post-install.sh @@ -425,9 +425,10 @@ if command -v ufw &>/dev/null; then fi # The whole bread ecosystem (bakery, bread, breadbar, breadbox, breadcrumbs, -# breadpad, bos-settings, breadhelp, ...) is bakery-managed, not pacman: the -# binaries and bakery manifest live in /etc/skel/.local (baked in at ISO -# build time from iso/bread-lockfile.toml) and are copied into the user's +# breadpad, bos-settings, breadhelp, ...) is bakery-managed, not pacman: +# binaries, share/data, and user units are baked into /usr/local and +# /usr/lib/systemd/user (system prefix). Per-user bakery state (installed.json +# + index cache) is seeded from /etc/skel/.local and copied into the user's # home below, so the install works fully offline with no DNS for bakery. # --------------------------------------------------------------------------- diff --git a/iso/airootfs/etc/greetd/breadgreet.toml b/iso/airootfs/etc/greetd/breadgreet.toml index 311dfad..24b48a4 100644 --- a/iso/airootfs/etc/greetd/breadgreet.toml +++ b/iso/airootfs/etc/greetd/breadgreet.toml @@ -7,8 +7,9 @@ # alongside BOS's own bos.desktop, and breadgreet's session picker matches by # .desktop file stem — with no override it picks "hyprland.desktop" over # "bos.desktop", which skips bos-session's PATH fixup (adds ~/.local/bin for -# the bakery bread apps; greetd starts no login shell, so /etc/profile.d is -# never sourced any other way). Confirmed via breadgreet's own test suite +# per-user tools; bakery apps are in /usr/local/bin). greetd starts no login +# shell, so /etc/profile.d is never sourced any other way. Confirmed via +# breadgreet's own test suite # (sessions.rs: discover_prefers_configured_default_over_first_entry). [sessions] diff --git a/iso/airootfs/etc/pacman.conf b/iso/airootfs/etc/pacman.conf index 7e15c7a..4e4435e 100644 --- a/iso/airootfs/etc/pacman.conf +++ b/iso/airootfs/etc/pacman.conf @@ -29,7 +29,7 @@ Include = /etc/pacman.d/mirrorlist # Breadway custom repo — breadlock plus AUR republishes the ISO needs # (calamares, zen-browser-bin, bibata-cursor-theme-bin, yay-bin, # zsh-theme-powerlevel10k). bakery / breadbar / bos-settings / breadhelp -# are NOT here; they are bakery-baked into /etc/skel at ISO build time. +# are NOT here; they are bakery-baked into /usr/local at ISO build time. # # Packages are published to the Forgejo Arch registry (group "os") by the # .forgejo/workflows/*.yml workflows in this repo (and breadlock's). diff --git a/iso/airootfs/etc/profile.d/bos-local-bin.sh b/iso/airootfs/etc/profile.d/bos-local-bin.sh index 642af43..734db46 100644 --- a/iso/airootfs/etc/profile.d/bos-local-bin.sh +++ b/iso/airootfs/etc/profile.d/bos-local-bin.sh @@ -1,8 +1,8 @@ -# Put the per-user bakery bin dir on PATH. The bread ecosystem (breadd, breadbar, -# breadbox, …) is installed there by bakery, and the Hyprland session launches -# them via `exec-once`, which resolves against the PATH it inherits from the -# login shell. Arch's stock /etc/profile does not add ~/.local/bin, so do it here -# for every login shell (live user and installed user alike). +# Keep ~/.local/bin on PATH for per-user tools. Arch already includes +# /usr/local/bin (where bakery desktop apps live on BOS). The Hyprland +# session resolves exec-once against the PATH it inherits from the login +# shell; Arch's stock /etc/profile does not add ~/.local/bin, so do it +# here for every login shell (live user and installed user alike). case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) export PATH="$HOME/.local/bin:$PATH" ;; diff --git a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua index d71731a..07ed155 100644 --- a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua +++ b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua @@ -124,8 +124,8 @@ hl.on("hyprland.start", function() "gsettings set org.gnome.desktop.interface cursor-theme Bibata-Modern-Ice", "gsettings set org.gnome.desktop.interface cursor-size 24", -- Clipboard history is breadclipd, a bakery-managed systemd --user - -- service (auto-started via skel — see build-local.sh's service bake) - -- rather than an exec-once here. + -- service (auto-started from /usr/lib/systemd/user — see + -- build-local.sh's service bake) rather than an exec-once here. "/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1", "awww-daemon", -- Set the default wallpaper once the daemon is up (retry until ready). @@ -140,8 +140,8 @@ hl.on("hyprland.start", function() -- breadpaper reads) is baked into skel too, right beside colors.json. -- pywal only runs for real once the user picks a wallpaper themselves. [[bash -c 'until awww img /usr/share/backgrounds/bos/bread-background.png 2>/dev/null; do sleep 0.3; done']], - -- breadd runs as a systemd user service (~/.config/systemd/user/breadd.service, - -- enabled in skel). It autostarts at login but before Hyprland exists, so + -- breadd runs as a systemd user service (/usr/lib/systemd/user/breadd.service, + -- plus a skel copy). It autostarts at login but before Hyprland exists, so -- push the compositor's Wayland env into the user manager and restart breadd -- to pick it up — that's how it gets HYPRLAND_INSTANCE_SIGNATURE to talk to Hyprland. "dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE", @@ -162,7 +162,8 @@ hl.on("hyprland.start", function() -- breadbox-sync is a Type=oneshot systemd --user service -- (WantedBy=default.target, no Hyprland IPC dependency) — it already - -- runs on login via the unit baked into skel, independent of this list. + -- runs on login via the unit baked into /usr/lib/systemd/user, + -- independent of this list. local ok, extra = pcall(function() return dofile(script_dir .. "system/autostart.lua")() end) diff --git a/iso/airootfs/etc/skel/.config/systemd/user/breadd.service b/iso/airootfs/etc/skel/.config/systemd/user/breadd.service index 49d6741..945c09c 100644 --- a/iso/airootfs/etc/skel/.config/systemd/user/breadd.service +++ b/iso/airootfs/etc/skel/.config/systemd/user/breadd.service @@ -3,8 +3,8 @@ Description=Bread Runtime Daemon [Service] Type=simple -# %h = the user's home — works for any account created from this skel. -ExecStart=%h/.local/bin/breadd +# System-prefix bakery install — same path for every account. +ExecStart=/usr/local/bin/breadd Restart=on-failure RestartSec=2 UMask=0077 diff --git a/iso/airootfs/etc/skel/.zshrc b/iso/airootfs/etc/skel/.zshrc index e4f1e69..3de4cc4 100644 --- a/iso/airootfs/etc/skel/.zshrc +++ b/iso/airootfs/etc/skel/.zshrc @@ -89,7 +89,7 @@ alias alt-install='yay -S' alias alt-uninstall='yay -R' alias alt-srchpkg='yay -Ss' -# ~/.local/bin holds the bread* binaries baked in at build time. +# Per-user tools. Bakery desktop apps live in /usr/local/bin (already on PATH). export PATH="$HOME/.local/bin:$PATH" # Powerlevel10k prompt configuration. diff --git a/iso/airootfs/usr/local/bin/bos-session b/iso/airootfs/usr/local/bin/bos-session index d7655fc..5f27974 100644 --- a/iso/airootfs/usr/local/bin/bos-session +++ b/iso/airootfs/usr/local/bin/bos-session @@ -2,11 +2,10 @@ # BOS graphical session launcher, run by greetd on the INSTALLED system after # the user authenticates (see /etc/greetd/config.toml). # -# greetd does not start a login shell, so /etc/profile.d is never sourced — which -# means ~/.local/bin (where bakery installs the bread ecosystem: breadd, breadbar, -# breadbox-sync, …) would be missing from PATH and the Hyprland `exec-once` -# launches would fail. Source the login profile here so PATH is correct, set the -# Wayland session hints, then hand off to Hyprland. +# greetd does not start a login shell, so /etc/profile.d is never sourced. +# Bakery desktop apps live in /usr/local/bin (already on Arch PATH). Source +# the login profile here so ~/.local/bin (per-user tools) is also on PATH, +# set the Wayland session hints, then hand off to Hyprland. # # Launched via start-hyprland (ships with the hyprland package) rather than the # raw Hyprland binary — Hyprland upstream no longer recommends exec'ing it diff --git a/iso/airootfs/usr/local/bin/bos-update b/iso/airootfs/usr/local/bin/bos-update index 65f27c0..3b3cc04 100644 --- a/iso/airootfs/usr/local/bin/bos-update +++ b/iso/airootfs/usr/local/bin/bos-update @@ -8,11 +8,12 @@ # or other bakery desktop apps. Every transaction is # snapshotted by snap-pac; recover via the GRUB "snapshots" # submenu (grub-btrfs), not `snapper rollback`. -# 2. bakery — the bread ecosystem apps in ~/.local/bin (whatever `bakery list` +# 2. bakery — the bread ecosystem apps in /usr/local (whatever `bakery list` # reports as installed — bakery, bread, breadbar, breadbox, # breadcrumbs, breadpad, breadman, bread-theme, breadpaper, # breadmon, breadsearch, breadclip, breadshot, bos-settings, -# breadhelp, ...). +# breadhelp, ...). Those bits live on @ and ride snapper +# root snapshots; recover via grub-btrfs, not `snapper rollback`. # # Best-effort: a failure in one channel doesn't abort the other. set -uo pipefail @@ -20,12 +21,11 @@ set -uo pipefail bold() { printf '\033[1m%s\033[0m\n' "$1"; } # Timed snapper pre snapshot before either channel. snap-pac already -# snapshots root around pacman; bakery writes ~/.local/bin ($HOME / @home), -# which is outside that root snapshot. This extra snapshot is still -# best-effort and covers bakery $HOME updates as well as possible — a -# home config if the installer created one, otherwise the root timeline -# around the whole update. Never fail the update if snapper is missing -# or the create errors. +# snapshots root around pacman; bakery now writes /usr/local (on @), so +# that root snapshot includes the desktop apps. This extra snapshot is +# still best-effort — a home config if the installer created one (user +# bakery state), plus a root timeline around the whole update. Never +# fail the update if snapper is missing or the create errors. if command -v snapper >/dev/null; then if snapper -c home list >/dev/null 2>&1; then snapper -c home create -t pre -c number \ diff --git a/iso/bread-lockfile.toml b/iso/bread-lockfile.toml index ac34e31..e1ef73a 100644 --- a/iso/bread-lockfile.toml +++ b/iso/bread-lockfile.toml @@ -1,4 +1,4 @@ -# Bakery binaries baked into the live/installed skel. +# Bakery binaries baked into the live/installed image at /usr/local. # # build-local.sh and CI (scripts/ci-stage-bakery.py) read this file. A missing # *required* binary fails the bake: a hollow ISO is worse than a failed build. diff --git a/iso/packages.x86_64 b/iso/packages.x86_64 index cc8e2e2..498ed39 100644 --- a/iso/packages.x86_64 +++ b/iso/packages.x86_64 @@ -206,10 +206,10 @@ yay-bin # /etc/pam.d/breadlock). Everything else — bakery, bread/breadd/bread-emit/ # bread-module-host, breadbar, breadbox, breadcrumbs, breadpad, breadpaper, # bread-theme, breadmon, breadsearch, breadclip, breadshot, bos-settings, -# breadhelp — is bakery-managed and baked into /etc/skel/.local at ISO build +# breadhelp — is bakery-managed and baked into /usr/local at ISO build # time from iso/bread-lockfile.toml (see build-local.sh). breadcast and # breadarr are not shipped. bos-settings/breadhelp desktop entries are -# committed under iso/airootfs/etc/skel/.local/share/applications/. Runtime +# also committed under iso/airootfs/etc/skel/.local/share/applications/. Runtime # deps stay listed even though no bread package depends on them via pacman # (gtk4, gtk4-layer-shell, webkit2gtk-4.1, iw, libpulse, librsvg, …). diff --git a/iso/pacman.conf b/iso/pacman.conf index 506b4bb..be3d52f 100644 --- a/iso/pacman.conf +++ b/iso/pacman.conf @@ -46,7 +46,7 @@ Include = /etc/pacman.d/mirrorlist # Breadway custom repo — breadlock plus AUR republishes the ISO needs # (calamares, zen-browser-bin, bibata-cursor-theme-bin, yay-bin, # zsh-theme-powerlevel10k). bakery / breadbar / bos-settings / breadhelp -# are NOT here; they are bakery-baked into /etc/skel at ISO build time. +# are NOT here; they are bakery-baked into /usr/local at ISO build time. # # Packages are published to the Forgejo Arch registry (group "os") by the # .forgejo/workflows/*.yml workflows in this repo (and breadlock's). diff --git a/scripts/ci-verify-bake.sh b/scripts/ci-verify-bake.sh index d02d0b0..0fcd06a 100755 --- a/scripts/ci-verify-bake.sh +++ b/scripts/ci-verify-bake.sh @@ -1,15 +1,20 @@ #!/usr/bin/env bash -# Read-only checks that a builder home (and optionally a staged skel) has +# Read-only checks that a builder home (and optionally a staged image) has # everything build-local.sh needs before mkarchiso. Exit non-zero on failure. # +# Builder home stays user-layout (~/.local). The image is system-prefix +# /usr/local; pass SKEL and/or AIROOTFS to check those destinations. +# # LAPTOP_HOME=/build-home ./scripts/ci-verify-bake.sh # SKEL=/tmp/bos-iso-stage/airootfs/etc/skel ./scripts/ci-verify-bake.sh +# AIROOTFS=/tmp/bos-iso-stage/airootfs ./scripts/ci-verify-bake.sh set -euo pipefail REPO="$(cd "$(dirname "$0")/.." && pwd)" LOCKFILE="${LOCKFILE:-$REPO/iso/bread-lockfile.toml}" LAPTOP_HOME="${LAPTOP_HOME:-/build-home}" SKEL="${SKEL:-}" +AIROOTFS="${AIROOTFS:-}" pass=0 fail=0 @@ -112,21 +117,73 @@ else done fi -if [[ -n "$SKEL" ]]; then - echo "== staged skel $SKEL ==" - for b in "${REQUIRED_BINS[@]}"; do - check_exec "$SKEL/.local/bin/$b" "skel required bin $b" - done - check_dir "$SKEL/.local/share/breadhelp/content" "skel breadhelp content" - check_file "$SKEL/.cache/bakery/index.json" "skel bakery index cache" - for unit in "${UNITS[@]}"; do - [[ -n "$unit" ]] || continue - if [[ -f "$SKEL/.config/systemd/user/$unit" ]]; then - ok "skel unit $unit" +if [[ -n "$SKEL" && -z "$AIROOTFS" ]]; then + if [[ -d "$SKEL/usr/local/bin" ]]; then + AIROOTFS="$SKEL" + SKEL="$AIROOTFS/etc/skel" + elif [[ -d "$SKEL/../../usr/local" ]]; then + AIROOTFS="$(cd "$SKEL/../.." && pwd)" + fi +elif [[ -n "$AIROOTFS" && -z "$SKEL" ]]; then + SKEL="$AIROOTFS/etc/skel" +fi + +if [[ -n "$AIROOTFS" || -n "$SKEL" ]]; then + if [[ -n "$AIROOTFS" ]]; then + echo "== staged image $AIROOTFS ==" + check_file "$AIROOTFS/etc/bakery/config.toml" "bakery prefix config" + if [[ -f "$AIROOTFS/etc/bakery/config.toml" ]] && grep -q 'prefix[[:space:]]*=[[:space:]]*"/usr/local"' "$AIROOTFS/etc/bakery/config.toml"; then + ok "bakery prefix = /usr/local" else - bad "skel unit missing: $SKEL/.config/systemd/user/$unit" + bad "bakery prefix is not /usr/local in $AIROOTFS/etc/bakery/config.toml" fi - done + for b in "${REQUIRED_BINS[@]}"; do + check_exec "$AIROOTFS/usr/local/bin/$b" "image required bin $b" + done + check_dir "$AIROOTFS/usr/local/share/breadhelp/content" "image breadhelp content" + fi + if [[ -n "$SKEL" ]]; then + echo "== staged skel $SKEL ==" + check_file "$SKEL/.cache/bakery/index.json" "skel bakery index cache" + check_file "$SKEL/.local/state/bakery/installed.json" "skel bakery installed.json" + for b in "${REQUIRED_BINS[@]}"; do + if [[ -e "$SKEL/.local/bin/$b" ]]; then + bad "skel still has bakery bin $b (belongs in /usr/local/bin)" + fi + done + fi + image_units_json="" + if [[ -n "$SKEL" && -f "$SKEL/.local/state/bakery/installed.json" ]]; then + image_units_json="$SKEL/.local/state/bakery/installed.json" + fi + if [[ -n "$image_units_json" ]]; then + mapfile -t IMAGE_UNITS < <(python3 - "$image_units_json" <<'PY' +import json, sys +path = sys.argv[1] +with open(path) as f: + data = json.load(f) +pkgs = data.get("packages", data) +for pkg in pkgs.values(): + for s in pkg.get("services", []): + print(s["unit"] if isinstance(s, dict) else s) +PY +) + else + IMAGE_UNITS=("${UNITS[@]}") + fi + if [[ -n "$AIROOTFS" ]]; then + for unit in "${IMAGE_UNITS[@]}"; do + [[ -n "$unit" ]] || continue + check_file "$AIROOTFS/usr/lib/systemd/user/$unit" "image unit $unit" + if [[ -f "$AIROOTFS/usr/lib/systemd/user/$unit" ]]; then + if grep -q '^ExecStart=/usr/local/bin/' "$AIROOTFS/usr/lib/systemd/user/$unit"; then + ok "image unit $unit ExecStart uses /usr/local/bin" + elif grep -q '^ExecStart=' "$AIROOTFS/usr/lib/systemd/user/$unit"; then + bad "image unit $unit ExecStart is not /usr/local/bin: $(grep '^ExecStart=' "$AIROOTFS/usr/lib/systemd/user/$unit")" + fi + fi + done + fi fi echo diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 19e7d75..d5b6fa9 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -63,7 +63,7 @@ check "bos-settings installed" "command -v bos-settings" echo "== breadhelp ==" check "breadhelp installed" "command -v breadhelp" check "breadhelp content installed" \ - "[ -d \"\$HOME/.local/share/breadhelp/content\" ] || [ -d /etc/skel/.local/share/breadhelp/content ]" + "[ -d /usr/local/share/breadhelp/content ] || [ -d \"\$HOME/.local/share/breadhelp/content\" ]" 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" From 70d4dd424bb62eb722d91c8d9682622c33ddc197 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:27:14 +0800 Subject: [PATCH 04/10] iso: enable bakery user units globally for later accounts Bins live in /usr/local, so a later useradd no longer gets ~/.local/bin copies. systemctl --global enable the bakery --user units (bake writes /etc/systemd/user/*.wants/, and post-install + live-setup run the same enable) so first login starts breadd, breadbox-sync, breadclipd, breadcrumbs, and breadmill. Stock useradd -m copies skel (Hyprland + bakery state). Rollback is still grub-btrfs. --- README.md | 43 +++++- build-local.sh | 126 ++++++++++++++++-- iso/airootfs/etc/calamares/post-install.sh | 9 ++ iso/airootfs/etc/default/useradd | 3 + .../etc/skel/.config/hypr/hyprland.lua | 3 +- .../user/default.target.wants/breadd.service | 1 + .../systemd/user-preset/90-bos-bakery.preset | 12 ++ .../local/bin/bos-enable-bakery-user-units | 90 +++++++++++++ iso/airootfs/usr/local/bin/bos-live-setup | 12 +- iso/profiledef.sh | 1 + scripts/ci-verify-bake.sh | 29 ++++ scripts/smoke-test.sh | 28 ++++ 12 files changed, 336 insertions(+), 21 deletions(-) create mode 120000 iso/airootfs/etc/systemd/user/default.target.wants/breadd.service create mode 100644 iso/airootfs/usr/lib/systemd/user-preset/90-bos-bakery.preset create mode 100755 iso/airootfs/usr/local/bin/bos-enable-bakery-user-units diff --git a/README.md b/README.md index 387c5f4..e8f2eba 100644 --- a/README.md +++ b/README.md @@ -129,10 +129,11 @@ machine's bakery-installed bread binaries + breadhelp content from the builder's `~/.local` into the image at `/usr/local` (bins, share/data, desktop files, licenses) and `/usr/lib/systemd/user` (units). Per-user bakery state (`installed.json` + index cache) is seeded in `/etc/skel`. -BOS opts in via `/etc/bakery/config.toml` (`prefix = "/usr/local"`); -default bakery without that file is still `~/.local`. Snapper `@` -snapshots include `/usr/local`; recovery is still grub-btrfs, not -`snapper rollback`. +User units are `systemctl --global enable`'d so a later `useradd -m` +starts them on first login. BOS opts in via `/etc/bakery/config.toml` +(`prefix = "/usr/local"`); default bakery without that file is still +`~/.local`. Snapper `@` snapshots include `/usr/local`; recovery is +still grub-btrfs, not `snapper rollback`. ```sh sudo ./build-local.sh # release-quality (xz squashfs) @@ -197,8 +198,38 @@ Hyprland session in QEMU. The disk lives on NVMe (not the tmpfs `/tmp`) to avoid memory pressure. Post-install, `scripts/smoke-test.sh` (run as the installed user) checks -subvolumes, services, bakery bins on PATH, and breadhelp content under -`/usr/local/share/breadhelp/content`. +subvolumes, services, bakery bins on PATH, breadhelp content under +`/usr/local/share/breadhelp/content`, and that bakery user units are +`--global` enabled (or the preset / wants files exist). + +## Second account + +Bakery desktop apps live in `/usr/local` — shared, already on PATH. A later +account does **not** get a private copy of those binaries. + +`/etc/default/useradd` keeps `SKEL=/etc/skel`. Stock `useradd -m` is enough: + +```sh +sudo useradd -m alice +sudo passwd alice +``` + +- **Apps**: `/usr/local/bin` (and `/usr/local/share`) — already there. +- **Session files**: `useradd -m` copies `/etc/skel` (Hyprland, bread + config, bakery `installed.json` + index cache) so first login has a + session. Skel does not contain bakery binaries. +- **Daemons**: `breadd`, `breadbox-sync`, `breadclipd`, `breadcrumbs`, + `breadmill`, … are `systemctl --global enable`'d at install (and on + the live image). Creating a user starts them on first login. +- **Login**: greetd/breadgreet lists any local user with a login shell + (`SHELL=/usr/bin/zsh` is the useradd default). + +`breadclipd` is WantedBy=`graphical-session.target`. BOS does not activate +that target (no uwsm), so Hyprland still `systemctl --user start`s it after +the compositor is up. `--global enable` still records it for every account. + +Rollback is still the GRUB snapshots submenu (grub-btrfs), not +`snapper rollback`. `/usr/local` rides the `@` snapshot. ## bos-settings diff --git a/build-local.sh b/build-local.sh index 8548d86..e360d42 100755 --- a/build-local.sh +++ b/build-local.sh @@ -249,26 +249,87 @@ PY # bakery package's service (breadbox-sync, breadmill, breadclipd, ...) was # silently left out, so those daemons never start on a fresh install/live # boot until the user re-runs `bakery install` (which needs network). -# Source of truth is the *filtered* installed.json we just wrote: only -# lockfile packages. Units go to /usr/lib/systemd/user with ExecStart -# rewritten to /usr/local/bin (not %h/.local/bin). Recreate whichever +# Units come from installed.json + the bakery index + local unit files +# whose ExecStart is a lockfile binary (installed.json has omitted +# breadcrumbs.service before). Units go to /usr/lib/systemd/user with +# ExecStart rewritten to /usr/local/bin. Recreate whichever # *.target.wants enable symlink bakery created locally (or that skel -# already ships). Hand-committed skel units (breadd.service carries a +# already ships), and write /etc/systemd/user/*.wants/ (--global). +# Hand-committed skel units (breadd.service carries a # RuntimeDirectoryPreserve=yes fix not yet upstreamed) are the source # for that unit and also get their ExecStart rewritten in skel. echo "=== baking bakery service units into /usr/lib/systemd/user ===" SYSTEMD_USER_DIR="$LAPTOP_HOME/.config/systemd/user" SKEL_SYSTEMD="$SKEL/.config/systemd/user" install -d -m 0755 "$IMAGE_UNITS" -mapfile -t SERVICE_UNITS < <(python3 - "$SKEL/.local/state/bakery/installed.json" <<'PY' -import json, sys -with open(sys.argv[1]) as f: - d = json.load(f) -for pkg in d.get("packages", d).values(): - for s in pkg.get("services", []): - print(s["unit"] if isinstance(s, dict) else s) +# installed.json on the builder can omit a service even when the index and +# the local unit file exist (breadcrumbs has done this). Merge all three +# so every lockfile daemon is baked and can be --global enabled. +mapfile -t SERVICE_UNITS < <(python3 - \ + "$SKEL/.local/state/bakery/installed.json" \ + "$BAKERY_CACHE/index.json" \ + "$SYSTEMD_USER_DIR" \ + "${BREAD_BINS[@]}" <<'PY' +import json, os, sys + +installed_path, index_path, user_dir, *bins = sys.argv[1:] +wanted = set(bins) +units = set() + +def add_svc(svc): + name = svc["unit"] if isinstance(svc, dict) else svc + if not name or str(name).startswith(("breadcast", "breadarr")): + return + units.add(str(name)) + +if os.path.isfile(installed_path): + with open(installed_path) as f: + data = json.load(f) + for pkg in data.get("packages", data).values(): + if isinstance(pkg, dict): + for svc in pkg.get("services") or []: + add_svc(svc) + +if os.path.isfile(index_path): + with open(index_path) as f: + idx = json.load(f) + for name, pkg in (idx.get("packages") or {}).items(): + if not isinstance(pkg, dict): + continue + pbins = [] + for b in pkg.get("binaries") or []: + n = b["name"] if isinstance(b, dict) else b + pbins.append(str(n).removesuffix("-x86_64")) + if name in wanted or any(b in wanted for b in pbins): + for svc in pkg.get("services") or []: + add_svc(svc) + +if os.path.isdir(user_dir): + for fn in os.listdir(user_dir): + if not fn.endswith(".service"): + continue + path = os.path.join(user_dir, fn) + if not os.path.isfile(path): + continue + try: + text = open(path).read() + except OSError: + continue + for line in text.splitlines(): + if line.lstrip().startswith("ExecStart="): + argv0 = line.split("=", 1)[1].split() + if argv0 and os.path.basename(argv0[0]) in wanted: + add_svc(fn) + break + +for unit in sorted(units): + print(unit) PY ) +if [[ ! " ${SERVICE_UNITS[*]} " =~ " breadd.service " ]]; then + echo "ERROR: breadd.service not in the bakery unit list — refusing to bake" >&2 + exit 1 +fi rewrite_exec_start() { local src="$1" dest="$2" python3 - "$src" "$dest" <<'PY' @@ -301,7 +362,7 @@ for unit in "${SERVICE_UNITS[@]}"; do else src="$SYSTEMD_USER_DIR/$unit" if [[ ! -f "$src" ]]; then - echo "ERROR: $unit listed in bakery installed.json but not found at $src" >&2 + echo "ERROR: $unit listed as a bakery service but not found at $src" >&2 echo "Refusing to bake an image whose daemons will never start." >&2 exit 1 fi @@ -320,9 +381,50 @@ for unit in "${SERVICE_UNITS[@]}"; do ln -sf "../$unit" "$IMAGE_UNITS/$target_name/$unit" done done + # systemctl --global enable equivalent: /etc/systemd/user/.wants/ + # so the live image and a later useradd inherit the unit without a per-home + # enable. Vendor wants above are extra; this is what --global writes. + python3 - "$IMAGE_UNITS/$unit" "$AIROOTFS/etc/systemd/user" "$unit" <<'PY' +import os, sys +unit_path, etc_user, unit = sys.argv[1:] +in_install = False +targets = [] +for line in open(unit_path): + s = line.strip() + if s.startswith("[") and s.endswith("]"): + in_install = s == "[Install]" + continue + if in_install and s.startswith("WantedBy="): + targets.extend(t for t in s.split("=", 1)[1].split() if t) +for target in targets: + wants = os.path.join(etc_user, f"{target}.wants") + os.makedirs(wants, exist_ok=True) + dest = os.path.join(wants, unit) + if os.path.lexists(dest): + os.remove(dest) + os.symlink(f"/usr/lib/systemd/user/{unit}", dest) + print(f" global enable {unit} -> {dest}") +PY echo " baked $unit -> $IMAGE_UNITS/$unit" done +# Document the baked set. The committed preset is the fallback; the staged +# copy lists whatever this bake actually shipped. +preset_dest="$AIROOTFS/usr/lib/systemd/user-preset/90-bos-bakery.preset" +install -d -m 0755 "$(dirname "$preset_dest")" +{ + echo "# Bakery systemd --user units baked into this image." + echo "# Applied by systemctl --global enable (post-install + live setup)" + echo "# so a later useradd starts them on first login." + echo "# breadclipd is also started from hyprland.lua: WantedBy=" + echo "# graphical-session.target is not reached on BOS (no uwsm)." + for unit in "${SERVICE_UNITS[@]}"; do + [[ -n "$unit" ]] || continue + printf 'enable %s\n' "$unit" + done +} >"$preset_dest" +echo " wrote $preset_dest" + # mkarchiso resets every airootfs file to 0644, so executables must be declared # in profiledef.sh's file_permissions array or they ship non-executable and the # exec-once launches fail with "permission denied". Inject a 0755 entry for each diff --git a/iso/airootfs/etc/calamares/post-install.sh b/iso/airootfs/etc/calamares/post-install.sh index ac13a97..5817f2e 100644 --- a/iso/airootfs/etc/calamares/post-install.sh +++ b/iso/airootfs/etc/calamares/post-install.sh @@ -430,6 +430,15 @@ fi # /usr/lib/systemd/user (system prefix). Per-user bakery state (installed.json # + index cache) is seeded from /etc/skel/.local and copied into the user's # home below, so the install works fully offline with no DNS for bakery. +# +# systemd --user units in /usr/lib/systemd/user are not enabled for new +# accounts unless enabled --global (or the user enables them). Do that here +# so a later `useradd -m` starts breadd / breadbox-sync / breadclipd / +# breadcrumbs / breadmill on first login. Safe if the helper is missing. +if [[ -x /usr/local/bin/bos-enable-bakery-user-units ]]; then + /usr/local/bin/bos-enable-bakery-user-units \ + || echo "WARN: enabling bakery user units globally failed" +fi # --------------------------------------------------------------------------- # Deploy dotfiles + the bakery bread ecosystem into the user's home (Calamares diff --git a/iso/airootfs/etc/default/useradd b/iso/airootfs/etc/default/useradd index f16b7d8..4cae86c 100644 --- a/iso/airootfs/etc/default/useradd +++ b/iso/airootfs/etc/default/useradd @@ -3,5 +3,8 @@ GROUP=users HOME=/home INACTIVE=-1 EXPIRE= +# useradd -m copies Hyprland + bakery per-user state from here. Bakery +# binaries live in /usr/local/bin (not skel). User units are enabled +# --global so a second account starts them on first login. SKEL=/etc/skel CREATE_MAIL_SPOOL=no diff --git a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua index 07ed155..c6c8e0b 100644 --- a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua +++ b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua @@ -141,7 +141,8 @@ hl.on("hyprland.start", function() -- pywal only runs for real once the user picks a wallpaper themselves. [[bash -c 'until awww img /usr/share/backgrounds/bos/bread-background.png 2>/dev/null; do sleep 0.3; done']], -- breadd runs as a systemd user service (/usr/lib/systemd/user/breadd.service, - -- plus a skel copy). It autostarts at login but before Hyprland exists, so + -- enabled --global so every account starts it). It autostarts at login + -- but before Hyprland exists, so -- push the compositor's Wayland env into the user manager and restart breadd -- to pick it up — that's how it gets HYPRLAND_INSTANCE_SIGNATURE to talk to Hyprland. "dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE", diff --git a/iso/airootfs/etc/systemd/user/default.target.wants/breadd.service b/iso/airootfs/etc/systemd/user/default.target.wants/breadd.service new file mode 120000 index 0000000..d284527 --- /dev/null +++ b/iso/airootfs/etc/systemd/user/default.target.wants/breadd.service @@ -0,0 +1 @@ +/usr/lib/systemd/user/breadd.service \ No newline at end of file diff --git a/iso/airootfs/usr/lib/systemd/user-preset/90-bos-bakery.preset b/iso/airootfs/usr/lib/systemd/user-preset/90-bos-bakery.preset new file mode 100644 index 0000000..f7f73f9 --- /dev/null +++ b/iso/airootfs/usr/lib/systemd/user-preset/90-bos-bakery.preset @@ -0,0 +1,12 @@ +# Bakery systemd --user units. `systemctl --global enable` (post-install and +# live setup) applies these so a later `useradd -m` starts them on first login. +# Bake rewrites this list from the units actually copied into the image. +# +# breadclipd is WantedBy=graphical-session.target. BOS does not activate that +# target (no uwsm); Hyprland still `systemctl --user start`s it after the +# compositor is up. --global enable still records it for every account. +enable breadd.service +enable breadbox-sync.service +enable breadclipd.service +enable breadcrumbs.service +enable breadmill.service diff --git a/iso/airootfs/usr/local/bin/bos-enable-bakery-user-units b/iso/airootfs/usr/local/bin/bos-enable-bakery-user-units new file mode 100755 index 0000000..46c128e --- /dev/null +++ b/iso/airootfs/usr/local/bin/bos-enable-bakery-user-units @@ -0,0 +1,90 @@ +#!/bin/bash +# Enable bakery systemd --user units for every account (current and future). +# +# `systemctl --global enable` writes /etc/systemd/user/.wants/ so a +# later `useradd -m` does not need per-home enablement. Bins live in +# /usr/local; only per-user state comes from skel. +# +# Safe on the live image and in the Calamares post-install chroot. +# Idempotent. Does not start units (no user session required). +# +# breadclipd is WantedBy=graphical-session.target. BOS does not activate +# that target (no uwsm), so Hyprland still `systemctl --user start`s it. +# --global enable still records it for every account / bos-settings. +set -uo pipefail + +UNITS_DIR=/usr/lib/systemd/user +PRESET=/usr/lib/systemd/user-preset/90-bos-bakery.preset + +is_blocked() { + case "$1" in + breadcast*|breadarr*) return 0 ;; + *) return 1 ;; + esac +} + +is_bakery_unit() { + local unit="$1" path="$UNITS_DIR/$unit" + [[ -f "$path" ]] || return 1 + is_blocked "$unit" && return 1 + grep -qE '^ExecStart=/usr/local/bin/' "$path" +} + +list_from_preset() { + [[ -f "$PRESET" ]] || return 0 + awk '/^enable[[:space:]]/ { print $2 }' "$PRESET" +} + +list_from_units_dir() { + [[ -d "$UNITS_DIR" ]] || return 0 + local path unit + for path in "$UNITS_DIR"/*.service; do + [[ -f "$path" ]] || continue + unit="$(basename "$path")" + is_bakery_unit "$unit" && printf '%s\n' "$unit" + done +} + +list_from_installed_json() { + local json=/etc/skel/.local/state/bakery/installed.json + [[ -f "$json" ]] || return 0 + command -v python3 >/dev/null 2>&1 || return 0 + python3 - "$json" <<'PY' +import json, sys +with open(sys.argv[1]) as f: + data = json.load(f) +for pkg in data.get("packages", data).values(): + if not isinstance(pkg, dict): + continue + for svc in pkg.get("services") or []: + name = svc["unit"] if isinstance(svc, dict) else svc + if name and not str(name).startswith(("breadcast", "breadarr")): + print(name) +PY +} + +mapfile -t units < <( + { list_from_preset; list_from_units_dir; list_from_installed_json; } \ + | sed '/^$/d' | sort -u +) + +if [[ ${#units[@]} -eq 0 ]]; then + echo "WARN: no bakery user units found to enable globally" + exit 0 +fi + +if ! command -v systemctl >/dev/null 2>&1; then + echo "WARN: systemctl missing — cannot --global enable bakery user units" + exit 0 +fi + +for unit in "${units[@]}"; do + [[ -f "$UNITS_DIR/$unit" ]] || continue + is_blocked "$unit" && continue + if ! grep -q '^\[Install\]' "$UNITS_DIR/$unit"; then + echo "WARN: $unit has no [Install] section — skip --global enable" + continue + fi + systemctl --global enable "$unit" \ + || echo "WARN: systemctl --global enable $unit failed" +done diff --git a/iso/airootfs/usr/local/bin/bos-live-setup b/iso/airootfs/usr/local/bin/bos-live-setup index f7b1d26..0609cac 100644 --- a/iso/airootfs/usr/local/bin/bos-live-setup +++ b/iso/airootfs/usr/local/bin/bos-live-setup @@ -7,9 +7,17 @@ # bos-launch-calamares). Runs once at boot, before the tty1 autologin getty. set -e +# Bakery user units live in /usr/lib/systemd/user. --global enable writes +# /etc/systemd/user/*.wants/ so liveuser (and any later account) starts +# them on first login. Idempotent; bins are already in /usr/local. +if [[ -x /usr/local/bin/bos-enable-bakery-user-units ]]; then + /usr/local/bin/bos-enable-bakery-user-units \ + || echo "WARN: enabling bakery user units globally failed" +fi + # useradd -m copies /etc/skel, so the live user gets the real BOS desktop -# (breadd + breadbar + breadbox + keybinds) — proper live-media functionality, -# not an installer kiosk. +# (hypr + bread config + bakery state) — proper live-media functionality, +# not an installer kiosk. Binaries are /usr/local, not skel. if ! id liveuser &>/dev/null; then useradd -m -s /usr/bin/zsh liveuser for g in wheel video input audio storage power; do diff --git a/iso/profiledef.sh b/iso/profiledef.sh index 5034cbc..f52295b 100644 --- a/iso/profiledef.sh +++ b/iso/profiledef.sh @@ -31,4 +31,5 @@ file_permissions=( ["/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" + ["/usr/local/bin/bos-enable-bakery-user-units"]="0:0:755" ) diff --git a/scripts/ci-verify-bake.sh b/scripts/ci-verify-bake.sh index 0fcd06a..68e357d 100755 --- a/scripts/ci-verify-bake.sh +++ b/scripts/ci-verify-bake.sh @@ -151,6 +151,7 @@ if [[ -n "$AIROOTFS" || -n "$SKEL" ]]; then bad "skel still has bakery bin $b (belongs in /usr/local/bin)" fi done + check_file "$SKEL/.config/hypr/hyprland.lua" "skel hyprland.lua" fi image_units_json="" if [[ -n "$SKEL" && -f "$SKEL/.local/state/bakery/installed.json" ]]; then @@ -183,6 +184,34 @@ PY fi fi done + check_file "$AIROOTFS/usr/lib/systemd/user-preset/90-bos-bakery.preset" \ + "bakery user preset" + if [[ -L "$AIROOTFS/etc/systemd/user/default.target.wants/breadd.service" ]] \ + || [[ -f "$AIROOTFS/etc/systemd/user/default.target.wants/breadd.service" ]]; then + ok "breadd.service globally enabled (etc wants)" + else + bad "breadd.service missing from /etc/systemd/user/default.target.wants" + fi + # After bake the image has /usr/local/bin/breadd and every preset unit. + # The committed airootfs only has the preset + breadd wants. + if [[ -f "$AIROOTFS/usr/lib/systemd/user-preset/90-bos-bakery.preset" ]] \ + && [[ -x "$AIROOTFS/usr/local/bin/breadd" ]]; then + while read -r verb unit; do + [[ "$verb" == enable && -n "$unit" ]] || continue + check_file "$AIROOTFS/usr/lib/systemd/user/$unit" "preset unit $unit" + if [[ -L "$AIROOTFS/etc/systemd/user/default.target.wants/$unit" ]] \ + || [[ -L "$AIROOTFS/etc/systemd/user/graphical-session.target.wants/$unit" ]]; then + ok "$unit globally enabled (etc wants)" + else + bad "$unit missing from /etc/systemd/user/*.target.wants" + fi + done < "$AIROOTFS/usr/lib/systemd/user-preset/90-bos-bakery.preset" + fi + if [[ -x "$AIROOTFS/usr/local/bin/bos-enable-bakery-user-units" ]]; then + ok "bos-enable-bakery-user-units executable" + else + bad "bos-enable-bakery-user-units missing or not executable" + fi fi fi diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index d5b6fa9..81bad6a 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -68,6 +68,34 @@ 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 "== bakery user units (global enable) ==" +# A later useradd does not enable --user units unless they were enabled +# --global (or the user enables them). post-install + live-setup + bake +# write /etc/systemd/user/.wants/ and a preset listing the set. +check "bakery user preset present" \ + "[ -f /usr/lib/systemd/user-preset/90-bos-bakery.preset ]" +check "bos-enable-bakery-user-units present" \ + "command -v bos-enable-bakery-user-units" +check "breadd.service globally enabled" \ + "systemctl --global is-enabled breadd.service || [ -L /etc/systemd/user/default.target.wants/breadd.service ]" +if [[ -f /usr/lib/systemd/user-preset/90-bos-bakery.preset ]]; then + while read -r verb unit; do + [[ "$verb" == enable && -n "$unit" ]] || continue + [[ -f /usr/lib/systemd/user/$unit ]] || continue + check "$unit globally enabled" \ + "systemctl --global is-enabled $unit || [ -L /etc/systemd/user/default.target.wants/$unit ] || [ -L /etc/systemd/user/graphical-session.target.wants/$unit ]" + done < /usr/lib/systemd/user-preset/90-bos-bakery.preset +fi +check "skel hyprland.lua present" "[ -f /etc/skel/.config/hypr/hyprland.lua ]" +check "skel bakery installed.json present" \ + "[ -f /etc/skel/.local/state/bakery/installed.json ]" +check "skel bakery index cache present" \ + "[ -f /etc/skel/.cache/bakery/index.json ]" +check "skel has no bakery binaries" \ + "! [ -e /etc/skel/.local/bin/bakery ] && ! [ -e /etc/skel/.local/bin/breadd ]" +check "useradd SKEL is /etc/skel" \ + "grep -q '^SKEL=/etc/skel' /etc/default/useradd" + echo "== default dotfiles ==" check "hyprland.lua present" "[ -f \"\$HOME/.config/hypr/hyprland.lua\" ]" check "binds.json present" "[ -f \"\$HOME/.config/hypr/binds.json\" ]" From f3d6c55234c602fbfde529791042acb5344c4242 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:35:26 +0800 Subject: [PATCH 05/10] iso: prefer bread-polkit when it is on PATH The themed agent lives in bread-ecosystem and is not on the ISO lockfile yet. Fall back to polkit-gnome so install-time auth still works. --- iso/airootfs/etc/skel/.config/hypr/hyprland.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua index c6c8e0b..d16a363 100644 --- a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua +++ b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua @@ -126,7 +126,10 @@ hl.on("hyprland.start", function() -- Clipboard history is breadclipd, a bakery-managed systemd --user -- service (auto-started from /usr/lib/systemd/user — see -- build-local.sh's service bake) rather than an exec-once here. - "/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1", + -- Prefer bread-polkit when bakery has published it; otherwise the + -- ISO's polkit-gnome agent. command -v so a missing binary does not + -- leave the session without an auth agent. + "sh -c 'if command -v bread-polkit >/dev/null; then exec bread-polkit; else exec /usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1; fi'", "awww-daemon", -- Set the default wallpaper once the daemon is up (retry until ready). -- Raw `awww img`, NOT `breadpaper set` — breadpaper set also runs real From 43c0a5e2b54d2c70e62713eaeb72f6c0d9c2ea00 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:48:25 +0800 Subject: [PATCH 06/10] iso: print GRUB snapshot recovery after bos-update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recovery is reboot → GRUB “snapshots” submenu. snapper rollback does not change what GRUB boots (rootflags=subvol=@). Same wording in README Recovery and docs/hardware.md. --- README.md | 19 ++++++++++--------- docs/hardware.md | 18 +++++++++++------- iso/airootfs/usr/local/bin/bos-update | 4 ++++ 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index e8f2eba..d50f3d0 100644 --- a/README.md +++ b/README.md @@ -264,7 +264,7 @@ also get live systemd status + Start/Stop/Restart/Logs. | Packages | `bakery` installed list + updates, pacman system update | | AUR | Search via `yay`; installing opens a terminal (AUR build scripts need review) | | Firmware | `fwupd` device list + updates | -| Snapshots | `snapper` list / boot-into (grub-btrfs) / delete | +| Snapshots | `snapper` list (number / date / description); reboot to pick in GRUB (grub-btrfs); delete | Source and build live in the [bos-settings](https://git.breadway.dev/Breadway/bos-settings) repo, not here. @@ -382,15 +382,16 @@ until `dl.breadway.dev/arch` exists). ## Recovery -**An update broke something (system still boots):** open BOS Settings → -Snapshots and pick a snapshot to boot, **or** choose one from the **GRUB -“snapshots” submenu** (grub-btrfs) at boot, then reboot into it. +**An update broke something (system still boots):** reboot → **GRUB +“snapshots” submenu** (grub-btrfs), then boot that entry. -Do **not** run `snapper rollback` as the default recovery step. BOS GRUB -pins `rootflags=subvol=@`, so a snapper-swapped default subvolume is not -what the installed grub.cfg will boot next. Use the grub-btrfs entry so the -kernel command line matches the snapshot you want. Details: -[docs/hardware.md](docs/hardware.md). +BOS Settings → Snapshots lists each snapshot’s number, date, and +description so you know which GRUB entry to pick. It does not roll the +running root back in place. + +Do **not** run `snapper rollback`. BOS GRUB pins `rootflags=subvol=@`, so +a snapper-swapped default subvolume is not what the installed grub.cfg +will boot next. Details: [docs/hardware.md](docs/hardware.md). A/B root swapping (SteamOS-style) is a **future** idea in DESIGN.md — it is not shipped. diff --git a/docs/hardware.md b/docs/hardware.md index 2f87838..9f31711 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -19,14 +19,18 @@ A VM without `/dev/dri` gets a notification only. ## Recovery -An update that breaks the system is recovered from the **GRUB "snapshots" -submenu** (grub-btrfs), not `snapper rollback`. +An update that breaks the system is recovered by **reboot → GRUB +“snapshots” submenu** (grub-btrfs). `snapper rollback` will not change +what GRUB boots (`rootflags=subvol=@`). -BOS GRUB pins `rootflags=subvol=@`. `snapper rollback` swaps the default -subvolume; the installed `grub.cfg` will still boot `@`. Pick the grub-btrfs -entry so the kernel command line matches the snapshot you want. Bakery -desktop apps live under `/usr/local` on `@`, so those same snapshots -include them. +`snapper rollback` swaps the default subvolume; the installed `grub.cfg` +still boots `@`. Pick the grub-btrfs entry so the kernel command line +matches the snapshot you want. + +BOS Settings → Snapshots lists snapshot number, date, and description so +you know which GRUB entry to pick. It does not roll the running root back +in place. Bakery desktop apps live under `/usr/local` on `@`, so those +same snapshots include them. If the system will not boot (lost EFI entry / broken GRUB), boot the live ISO and run `sudo bos-rescue`. It mounts `@` + the ESP and offers the same diff --git a/iso/airootfs/usr/local/bin/bos-update b/iso/airootfs/usr/local/bin/bos-update index 3b3cc04..16b9da8 100644 --- a/iso/airootfs/usr/local/bin/bos-update +++ b/iso/airootfs/usr/local/bin/bos-update @@ -54,3 +54,7 @@ fi echo bold "==> BOS is up to date." +echo +bold "Recovery" +echo "If this update goes badly: reboot → GRUB “snapshots” submenu." +echo "snapper rollback will not change what GRUB boots (rootflags=subvol=@)." From 863fb80de2d5ee25717fc4341223f18fb17d865a Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:54:40 +0800 Subject: [PATCH 07/10] iso: add bos-nvidia-setup for optional proprietary NVIDIA Click-to-install path for machines first-boot already offers. Installs nvidia + nvidia-utils (never cuda), writes ~/.config/hypr/nvidia.lua, and hyprland.lua dofiles that file only if it exists. Mesa stays unchanged. Not on the ISO. Reboot after. --- docs/hardware.md | 15 +- .../etc/skel/.config/hypr/hyprland.lua | 11 ++ iso/airootfs/usr/local/bin/bos-nvidia-setup | 150 ++++++++++++++++++ iso/profiledef.sh | 1 + scripts/ci-verify-bake.sh | 11 ++ scripts/smoke-test.sh | 9 ++ 6 files changed, 193 insertions(+), 4 deletions(-) create mode 100755 iso/airootfs/usr/local/bin/bos-nvidia-setup diff --git a/docs/hardware.md b/docs/hardware.md index 9f31711..cf6ebbe 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -4,14 +4,21 @@ BOS ships the generic **Mesa** stack. AMD and Intel work out of the box. -**NVIDIA is unsupported.** The proprietary driver is not included, NVIDIA -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. +The proprietary NVIDIA driver is **not on the ISO**. NVIDIA firmware is +not on the image either (`linux-firmware-nvidia` stays commented out in +`packages.x86_64`). Default Hyprland env is vendor-neutral. 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. +install anything. + +The optional proprietary path is `bos-nvidia-setup` or the Settings → +Updates NVIDIA button. That installs `nvidia` + `nvidia-utils` (never +cuda) and writes `~/.config/hypr/nvidia.lua`. `hyprland.lua` dofiles that +drop-in **only if the file exists**, so Mesa machines stay unchanged. +Reboot after. Installing the packages by hand without the drop-in is not +enough for a working Hyprland session. 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`. diff --git a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua index d16a363..565da5b 100644 --- a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua +++ b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua @@ -77,6 +77,17 @@ hl.env("SDL_VIDEODRIVER", "wayland") hl.env("ELECTRON_OZONE_PLATFORM_HINT", "auto") hl.env("_JAVA_AWT_WM_NONREPARENTING", "1") +-- Optional NVIDIA env from bos-nvidia-setup. Mesa machines have no file. +-- bos-nvidia-setup: optional proprietary env; no-op when the file is absent +do + local nvidia = (os.getenv("HOME") or "") .. "/.config/hypr/nvidia.lua" + local f = io.open(nvidia, "r") + if f then + f:close() + pcall(dofile, nvidia) + end +end + -- kitty sets its own background_opacity (see kitty.conf), so the global blur -- above blurs behind the terminal while keeping text fully opaque. diff --git a/iso/airootfs/usr/local/bin/bos-nvidia-setup b/iso/airootfs/usr/local/bin/bos-nvidia-setup new file mode 100755 index 0000000..d287f53 --- /dev/null +++ b/iso/airootfs/usr/local/bin/bos-nvidia-setup @@ -0,0 +1,150 @@ +#!/bin/bash +# bos-nvidia-setup — optional proprietary NVIDIA driver + Hyprland env. +# +# Installs nvidia + nvidia-utils only (never cuda). Writes +# ~/.config/hypr/nvidia.lua, which skel hyprland.lua dofiles only when +# the file exists — Mesa machines stay unchanged. Existing installs get +# the same include patched in if it is missing. +# +# Click-to-install from Settings, or run by hand. Not invoked from +# bos-first-boot. Idempotent. Prints "reboot required". +# +# Must run on an installed system. Elevates via pkexec, then sudo. +set -uo pipefail + +usage() { + cat <<'EOF' +Usage: bos-nvidia-setup [--home DIR] + +Install nvidia + nvidia-utils (not cuda) and write the Hyprland NVIDIA +env drop-in for this user. Reboot after. + + --home DIR user home that owns ~/.config/hypr (required under pkexec + if PKEXEC_UID / SUDO_USER cannot be resolved) +EOF +} + +TARGET_HOME="" +while [[ $# -gt 0 ]]; do + case "$1" in + --home) + TARGET_HOME="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "bos-nvidia-setup: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ "$(id -un)" == "liveuser" || -d /run/archiso ]]; then + echo "bos-nvidia-setup is for an installed system, not the live ISO." >&2 + exit 1 +fi + +if [[ "$(id -u)" -ne 0 ]]; then + home="${TARGET_HOME:-${HOME:-}}" + if [[ -z "$home" ]]; then + echo "bos-nvidia-setup: cannot determine home; pass --home" >&2 + exit 1 + fi + self="$(command -v bos-nvidia-setup 2>/dev/null || true)" + [[ -n "$self" ]] || self="$(readlink -f "$0" 2>/dev/null || printf '%s' "$0")" + if command -v pkexec >/dev/null 2>&1; then + exec pkexec "$self" --home "$home" + fi + if command -v sudo >/dev/null 2>&1; then + exec sudo "$self" --home "$home" + fi + echo "bos-nvidia-setup: need root (pkexec or sudo)" >&2 + exit 1 +fi + +if [[ -z "$TARGET_HOME" ]]; then + if [[ -n "${PKEXEC_UID:-}" ]]; then + TARGET_HOME="$(getent passwd "$PKEXEC_UID" | cut -d: -f6 || true)" + elif [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != root ]]; then + TARGET_HOME="$(getent passwd "$SUDO_USER" | cut -d: -f6 || true)" + fi +fi + +if [[ -z "$TARGET_HOME" || "$TARGET_HOME" == /root || ! -d "$TARGET_HOME" ]]; then + echo "bos-nvidia-setup: cannot determine user home (pass --home)" >&2 + exit 1 +fi + +HYPR_DIR="$TARGET_HOME/.config/hypr" +NVIDIA_LUA="$HYPR_DIR/nvidia.lua" +HYPR_LUA="$HYPR_DIR/hyprland.lua" + +# Hyprland 0.56 (Aquamarine). Wiki (https://wiki.hypr.land/Nvidia/): +# LIBVA_DRIVER_NAME + __GLX_VENDOR_LIBRARY_NAME. NVD_BACKEND is the +# current VA-API hint. No WLR_* (not wlroots). No GBM_BACKEND (not +# required; older docs cargo-culted it and it can break Firefox). +NVIDIA_LUA_BODY='-- Written by bos-nvidia-setup. hyprland.lua dofiles this only when it exists. +-- Hyprland 0.56 (Aquamarine) — no WLR_* variables. +-- https://wiki.hypr.land/Nvidia/ +hl.env("LIBVA_DRIVER_NAME", "nvidia") +hl.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia") +hl.env("NVD_BACKEND", "direct") +' + +# Self-contained so it is safe to append to a hand-edited hyprland.lua. +HYPR_INCLUDE='-- bos-nvidia-setup: optional proprietary env; no-op when the file is absent +do + local nvidia = (os.getenv("HOME") or "") .. "/.config/hypr/nvidia.lua" + local f = io.open(nvidia, "r") + if f then + f:close() + pcall(dofile, nvidia) + end +end +' + +own_as_user() { + local path="$1" + [[ -e "$path" ]] || return 0 + local owner + owner="$(stat -c '%u:%g' "$TARGET_HOME" 2>/dev/null || true)" + [[ -n "$owner" ]] || return 0 + chown "$owner" "$path" 2>/dev/null || true +} + +echo "==> Installing nvidia + nvidia-utils (not cuda)" +if ! command -v pacman >/dev/null 2>&1; then + echo "bos-nvidia-setup: pacman not found" >&2 + exit 1 +fi +if ! pacman -S --needed --noconfirm -- nvidia nvidia-utils; then + echo "bos-nvidia-setup: pacman install failed" >&2 + exit 1 +fi + +echo "==> Writing $NVIDIA_LUA" +mkdir -p "$HYPR_DIR" || { + echo "bos-nvidia-setup: cannot create $HYPR_DIR" >&2 + exit 1 +} +printf '%s' "$NVIDIA_LUA_BODY" >"$NVIDIA_LUA" || { + echo "bos-nvidia-setup: cannot write $NVIDIA_LUA" >&2 + exit 1 +} +own_as_user "$NVIDIA_LUA" + +if [[ -f "$HYPR_LUA" ]] && ! grep -q 'nvidia\.lua' "$HYPR_LUA"; then + echo "==> Including nvidia.lua from $HYPR_LUA" + if [[ -n "$(tail -c1 "$HYPR_LUA" 2>/dev/null || true)" ]]; then + printf '\n' >>"$HYPR_LUA" + fi + printf '%s\n' "$HYPR_INCLUDE" >>"$HYPR_LUA" + own_as_user "$HYPR_LUA" +fi + +echo "reboot required" +exit 0 diff --git a/iso/profiledef.sh b/iso/profiledef.sh index f52295b..462f74e 100644 --- a/iso/profiledef.sh +++ b/iso/profiledef.sh @@ -31,5 +31,6 @@ file_permissions=( ["/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" + ["/usr/local/bin/bos-nvidia-setup"]="0:0:755" ["/usr/local/bin/bos-enable-bakery-user-units"]="0:0:755" ) diff --git a/scripts/ci-verify-bake.sh b/scripts/ci-verify-bake.sh index 68e357d..e55b11e 100755 --- a/scripts/ci-verify-bake.sh +++ b/scripts/ci-verify-bake.sh @@ -47,6 +47,11 @@ PY echo "== lockfile $LOCKFILE ==" echo " ${#REQUIRED_BINS[@]} required, ${#OPTIONAL_BINS[@]} optional" +if grep -qE '^nvidia(-utils|-dkms|-open)?$' "$REPO/iso/packages.x86_64"; then + bad "iso/packages.x86_64 lists an nvidia driver package" +else + ok "iso/packages.x86_64 has no nvidia driver package" +fi echo "== host tools ==" if command -v grub-install >/dev/null 2>&1; then ok "grub-install (uefi.grub bootmode)" @@ -140,6 +145,7 @@ if [[ -n "$AIROOTFS" || -n "$SKEL" ]]; then for b in "${REQUIRED_BINS[@]}"; do check_exec "$AIROOTFS/usr/local/bin/$b" "image required bin $b" done + check_exec "$AIROOTFS/usr/local/bin/bos-nvidia-setup" "image bos-nvidia-setup" check_dir "$AIROOTFS/usr/local/share/breadhelp/content" "image breadhelp content" fi if [[ -n "$SKEL" ]]; then @@ -152,6 +158,11 @@ if [[ -n "$AIROOTFS" || -n "$SKEL" ]]; then fi done check_file "$SKEL/.config/hypr/hyprland.lua" "skel hyprland.lua" + if grep -q 'nvidia.lua' "$SKEL/.config/hypr/hyprland.lua"; then + ok "skel hyprland.lua includes nvidia.lua only if present" + else + bad "skel hyprland.lua does not mention nvidia.lua" + fi fi image_units_json="" if [[ -n "$SKEL" && -f "$SKEL/.local/state/bakery/installed.json" ]]; then diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 81bad6a..ccdc571 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -67,6 +67,13 @@ check "breadhelp content installed" \ 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" +check "bos-nvidia-setup present" "command -v bos-nvidia-setup" +if pacman -Qq nvidia >/dev/null 2>&1; then + note "nvidia installed (optional proprietary path)" + check "nvidia env drop-in present" "[ -f \"\$HOME/.config/hypr/nvidia.lua\" ]" +else + check "nvidia not on the default image" "! pacman -Qq nvidia" +fi echo "== bakery user units (global enable) ==" # A later useradd does not enable --user units unless they were enabled @@ -98,6 +105,8 @@ check "useradd SKEL is /etc/skel" \ echo "== default dotfiles ==" check "hyprland.lua present" "[ -f \"\$HOME/.config/hypr/hyprland.lua\" ]" +check "hyprland.lua includes nvidia.lua only if present" \ + "grep -q 'nvidia.lua' \"\$HOME/.config/hypr/hyprland.lua\"" check "binds.json present" "[ -f \"\$HOME/.config/hypr/binds.json\" ]" check "monitors.json present" "[ -f \"\$HOME/.config/hypr/monitors.json\" ]" check "settings.json present" "[ -f \"\$HOME/.config/hypr/settings.json\" ]" From 8cd5ec9e16fb9ee684f06c3a4edfd4cd9b6221b8 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:57:39 +0800 Subject: [PATCH 08/10] iso: ship restic for Settings home backup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapper remains root (@) only. Home backup is Settings → Backup (restic). Restore goes to ~/bos-restore-, not $HOME. --- README.md | 15 ++++++++++----- iso/packages.x86_64 | 2 ++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d50f3d0..f0987b8 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,11 @@ wiring up dotfiles, no per-tool bakery installs. Mesa only — **NVIDIA proprietary drivers are not included** and NVIDIA is unsupported out of the box (see [docs/hardware.md](docs/hardware.md)). - **Resilience**: btrfs + snapper + snap-pac + grub-btrfs snapshots on every - pacman transaction; zram swap; ufw firewall (deny-incoming, mDNS allowed). - A/B root swapping is **not** implemented. Recovery is a grub-btrfs reboot, - not `snapper rollback` (GRUB pins `rootflags=subvol=@`). See + pacman transaction (**root `@` only** — snapper does not cover `@home`); + home backup is **Settings → Backup** (restic, local path or SFTP); zram + swap; ufw firewall (deny-incoming, mDNS allowed). A/B root swapping is + **not** implemented. Recovery is a grub-btrfs reboot, not + `snapper rollback` (GRUB pins `rootflags=subvol=@`). See [docs/hardware.md](docs/hardware.md). - **Security**: optional full-disk encryption is **LUKS1** (GRUB cannot unlock LUKS2 + Argon2id). Secure Boot is **self-signed Setup Mode only** via @@ -264,7 +266,8 @@ also get live systemd status + Start/Stop/Restart/Logs. | Packages | `bakery` installed list + updates, pacman system update | | AUR | Search via `yay`; installing opens a terminal (AUR build scripts need review) | | Firmware | `fwupd` device list + updates | -| Snapshots | `snapper` list (number / date / description); reboot to pick in GRUB (grub-btrfs); delete | +| Snapshots | `snapper` list (number / date / description); reboot to pick in GRUB (grub-btrfs); delete — **root (`@`) only** | +| Backup | restic of `$HOME` (`@home`) via Settings → Backup; snapper does not cover home | Source and build live in the [bos-settings](https://git.breadway.dev/Breadway/bos-settings) repo, not here. @@ -387,7 +390,9 @@ until `dl.breadway.dev/arch` exists). BOS Settings → Snapshots lists each snapshot’s number, date, and description so you know which GRUB entry to pick. It does not roll the -running root back in place. +running root back in place. Snapper is root only. Home files are +**Settings → Backup** (restic restore into `~/bos-restore-`, not +over `$HOME`). Do **not** run `snapper rollback`. BOS GRUB pins `rootflags=subvol=@`, so a snapper-swapped default subvolume is not what the installed grub.cfg diff --git a/iso/packages.x86_64 b/iso/packages.x86_64 index 498ed39..ff90edf 100644 --- a/iso/packages.x86_64 +++ b/iso/packages.x86_64 @@ -78,6 +78,8 @@ snapper snap-pac grub-btrfs inotify-tools +# Home backup (Settings → Backup). Snapper is root (`@`) only; restic covers $HOME. +restic # Wayland / Hyprland hyprland From 9fe02eeea613c009ce734bb0c253bc5ed15263fd Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:55:59 +0800 Subject: [PATCH 09/10] ci: publish signed [breadway] repo to dl.breadway.dev/arch Host job on hestia (no container) collects breadlock plus the ISO AUR republishes from the Forgejo registry, detach-signs them, repo-add -s, and writes /srv/breadway-dl/arch/x86_64/. ISO SigLevel stays Never. --- .forgejo/workflows/release-iso.yml | 4 +- .forgejo/workflows/signed-repo.yml | 52 +++++ README.md | 5 +- docs/signed-repo.md | 126 +++++++++--- scripts/ci-publish-signed-repo.sh | 298 +++++++++++++++++++++++++++++ 5 files changed, 453 insertions(+), 32 deletions(-) create mode 100644 .forgejo/workflows/signed-repo.yml create mode 100755 scripts/ci-publish-signed-repo.sh diff --git a/.forgejo/workflows/release-iso.yml b/.forgejo/workflows/release-iso.yml index a30e291..162fa16 100644 --- a/.forgejo/workflows/release-iso.yml +++ b/.forgejo/workflows/release-iso.yml @@ -12,8 +12,8 @@ name: Build and release ISO # MIRROR_TOKEN — GitHub personal access token with repo scope # GPG_PRIVATE_KEY — armoured secret key for the dedicated "BOS Release Signing" # identity (releases@breadway.dev); public half is committed -# at KEYS.asc for verifying ISO SHA256SUMS only. That key -# does not sign the [breadway] pacman repo. No passphrase +# at KEYS.asc. Signs ISO SHA256SUMS here; the same secret +# signs the [breadway] repo in signed-repo.yml. No passphrase # (CI-only key, access controlled via the Forgejo secret # store). diff --git a/.forgejo/workflows/signed-repo.yml b/.forgejo/workflows/signed-repo.yml new file mode 100644 index 0000000..3ef8a65 --- /dev/null +++ b/.forgejo/workflows/signed-repo.yml @@ -0,0 +1,52 @@ +name: Publish signed [breadway] repo + +# Host job on hestia (no container:) so it can write /srv/breadway-dl, same +# as bakery releases. breadlock package.yml uses archlinux:latest and cannot +# see host /srv — do not add container: here. +# +# Collects breadlock + the ISO AUR republishes from the Forgejo Arch +# registry, detach-signs each .pkg.tar.zst, repo-add -s, publishes +# https://dl.breadway.dev/arch/x86_64/. Does not PUT to the registry +# (existing packaging workflows keep doing that). Does not flip ISO SigLevel. +# +# Required secret: GPG_PRIVATE_KEY (same BOS release key as release-iso.yml). + +on: + workflow_dispatch: + repository_dispatch: + types: [publish-signed-repo] + workflow_run: + workflows: + - Build and publish calamares + - Build and publish bibata-cursor-theme + - Build and publish powerlevel10k + - Build and publish yay-bin + types: [completed] + +concurrency: + group: signed-repo + cancel-in-progress: false + +jobs: + publish: + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + runs-on: [self-hosted, hestia] + steps: + - name: Clone repository + run: | + set -euo pipefail + REF="${GITHUB_REF_NAME:-main}" + rm -rf src + git clone --depth 1 --branch "$REF" \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: Sign packages and publish repo + env: + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + run: | + set -euo pipefail + if [ -z "${GPG_PRIVATE_KEY:-}" ]; then + echo "GPG_PRIVATE_KEY secret is missing; refusing to publish an unsigned [breadway] repo." >&2 + exit 1 + fi + bash src/scripts/ci-publish-signed-repo.sh diff --git a/README.md b/README.md index f0987b8..1a833b6 100644 --- a/README.md +++ b/README.md @@ -96,11 +96,12 @@ bos/ ├── scripts/ │ ├── ci-stage-bakery.py # CI: minisign-verified index → $LAPTOP_HOME │ ├── ci-verify-bake.sh # CI: read-only checks before mkarchiso +│ ├── ci-publish-signed-repo.sh # CI: signed [breadway] repo → /srv/breadway-dl/arch │ └── smoke-test.sh ├── docs/ │ ├── hardware.md # Mesa only, NVIDIA, grub-btrfs recovery -│ └── signed-repo.md # future dl.breadway.dev/arch signing -├── .forgejo/workflows/ # CI: AUR republish + tagged ISO release +│ └── signed-repo.md # dl.breadway.dev/arch signing +├── .forgejo/workflows/ # CI: AUR republish + signed repo + tagged ISO ├── build-local.sh # native ISO build for this machine ├── README.md └── DESIGN.md # historical plan diff --git a/docs/signed-repo.md b/docs/signed-repo.md index 112e254..02b5340 100644 --- a/docs/signed-repo.md +++ b/docs/signed-repo.md @@ -3,16 +3,24 @@ Today the ISO's `[Breadway.os.git.breadway.dev]` section is `SigLevel = Never`. That is TLS-only integrity: packages come from Forgejo's Arch registry, which does **not** serve pacman-compatible database -signatures. `KEYS.asc` signs **ISO `SHA256SUMS` only** — it is not imported -as a pacman repo key. Do not flip `SigLevel` to `Required` on that section -until a signed repo exists and has been verified; Required without -signatures breaks the ISO and every installed system. +signatures. `KEYS.asc` signs **ISO `SHA256SUMS`** and, once published, the +`dl.breadway.dev/arch` database. It is **not** imported as a pacman repo key +on the ISO yet. Do not flip `SigLevel` to `Required` on that section until +a signed repo exists and has been verified; Required without signatures +breaks the ISO and every installed system. The signed repo belongs at `https://dl.breadway.dev/arch`, not on Forgejo's -registry. +registry. Forgejo publishing stays as it is (`package.yml` / packaging +workflows PUT unsigned `.pkg.tar.zst` so existing Never installs keep +working). ## Stand up `dl.breadway.dev/arch` +CI job: **Publish signed `[breadway]` repo** +(`.forgejo/workflows/signed-repo.yml`), host runner on hestia — **no +container**, so it can write `/srv/breadway-dl` like bakery releases. +breadlock `package.yml` uses `archlinux:latest` and cannot see host `/srv`. + Use the same release-signing key already in CI: - Public half: [`KEYS.asc`](../KEYS.asc) @@ -20,6 +28,7 @@ Use the same release-signing key already in CI: `releases@breadway.dev`) - Private half: the `GPG_PRIVATE_KEY` Forgejo secret (armoured secret key, no passphrase). Same secret `release-iso.yml` uses to sign `SHA256SUMS`. + The workflow **fails** if this secret is missing. Layout (example for `x86_64`): @@ -33,53 +42,114 @@ https://dl.breadway.dev/arch/x86_64/ breadway.files.sig ``` -Build the database **and sign it** with `repo-add -s`: +On disk: `/srv/breadway-dl/arch/x86_64/` (nginx already serves +`/srv/breadway-dl` as `https://dl.breadway.dev/`). + +The job collects the current ISO `[breadway]` set from the Forgejo Arch +registry (breadlock + calamares, zen-browser-bin, bibata-cursor-theme-bin, +zsh-theme-powerlevel10k, yay-bin). Leftover bakery-channel pacman packages +still sitting in that registry are **not** copied. Optional +`BREADWAY_PKG_DIR` on the runner overrides individual files. + +Then it detach-signs each `.pkg.tar.zst` as a **binary** sidecar (pacman +wants `.sig`, not armoured `.asc`) and builds the database with +`repo-add -s`: ```sh export GNUPGHOME=/tmp/gnupg-breadway-repo mkdir -m 700 -p "$GNUPGHOME" printf '%s\n' "$GPG_PRIVATE_KEY" | gpg --batch --import -cd /srv/dl.breadway.dev/arch/x86_64 +gpg --batch --yes --local-user releases@breadway.dev \ + --detach-sign breadlock--1-x86_64.pkg.tar.zst +# → breadlock--1-x86_64.pkg.tar.zst.sig + +cd /srv/breadway-dl/arch/x86_64 repo-add -s -k releases@breadway.dev breadway.db.tar.gz *.pkg.tar.zst ``` `repo-add -s` writes `breadway.db.tar.gz.sig` (and the `.files` pair). Pacman fetches `
.db` + `
.db.sig` from `Server`. -Package signatures are separate from the database signature. Detach-sign -each `.pkg.tar.zst` as a **binary** sidecar (pacman wants `.sig`, not -armoured `.asc`): +## Dispatch the workflow + +Forgejo UI: **Actions → "Publish signed [breadway] repo" → Run workflow**. +Select this branch (`feature/signed-repo`) until it is on `main`. + +API (`workflow_dispatch`): ```sh -gpg --batch --yes --local-user releases@breadway.dev \ - --detach-sign breadlock--1-x86_64.pkg.tar.zst -# → breadlock--1-x86_64.pkg.tar.zst.sig +curl -fsS -X POST \ + -H "Authorization: token ${RELEASE_TOKEN}" \ + -H "Content-Type: application/json" \ + "https://git.breadway.dev/api/v1/repos/Breadway/bos/actions/workflows/signed-repo.yml/dispatches" \ + -d '{"ref":"feature/signed-repo"}' ``` +After merge, use `"ref":"main"`. + +It also runs after the in-repo AUR republish workflows complete +(`calamares` / `bibata` / `powerlevel10k` / `yay-bin`). breadlock lives in +another repo; that job can fire this one with `repository_dispatch` event +`publish-signed-repo` (or dispatch from the UI after a breadlock tag). + +## Verify + +Confirm the signed db is actually served **before** touching ISO +`SigLevel` or `Server`: + +```sh +curl -fsSIL https://dl.breadway.dev/arch/x86_64/breadway.db +curl -fsSIL https://dl.breadway.dev/arch/x86_64/breadway.db.sig +``` + +Both must be HTTP 200. A 404 on `breadway.db.sig` means do **not** flip +`SigLevel` to `Required`. + +Import `KEYS.asc` and check the detached signatures: + +```sh +gpg --import KEYS.asc +curl -fsSL -o /tmp/breadway.db https://dl.breadway.dev/arch/x86_64/breadway.db +curl -fsSL -o /tmp/breadway.db.sig https://dl.breadway.dev/arch/x86_64/breadway.db.sig +gpg --verify /tmp/breadway.db.sig /tmp/breadway.db +``` + +On a throwaway Arch box (not the ISO tree): + +```sh +sudo pacman-key --add KEYS.asc +sudo pacman-key --lsign-key 56203B86A110695AE7F310934AF3323D678EB5E2 + +# Temporary /etc/pacman.conf snippet — do not commit this to the ISO: +# [breadway] +# SigLevel = Required +# Server = https://dl.breadway.dev/arch/$arch + +sudo pacman -Sy +``` + +`pacman -Sy` must fetch `breadway.db` + `breadway.db.sig` without +"missing or invalid signature". Then `pacman -Si breadlock` (and the AUR +republishes) should list the `[breadway]` section. + ## breadlock `package.yml` sidecar [`breadlock` `package.yml`](https://git.breadway.dev/Breadway/breadlock/src/branch/main/.forgejo/workflows/package.yml) -already `makepkg`s and PUTs the archive at Forgejo's registry. When the -signed repo exists, that job can also emit the sidecar and publish both -files to `dl.breadway.dev/arch`: - -```sh -PKG=$(find packaging/arch -name '*.pkg.tar.zst' | head -1) -printf '%s\n' "$GPG_PRIVATE_KEY" | gpg --batch --import -gpg --batch --yes --local-user releases@breadway.dev --detach-sign "$PKG" -# upload "$PKG" and "${PKG}.sig" to dl.breadway.dev/arch/x86_64/ -# then repo-add -s as above -``` - -Keep publishing to Forgejo until installs have been switched. The ISO -section stays `SigLevel = Never` until the signed tree is live. +still `makepkg`s and PUTs the archive at Forgejo's registry. That path +stays; Never installs keep working. The signed tree is rebuilt by the bos +workflow above (registry fetch + sign + `repo-add -s`), not by writing +`/srv` from breadlock's container. ## After the signed repo exists +Only after `https://dl.breadway.dev/arch/x86_64/breadway.db.sig` HEADs 200 +and the verify commands above succeed: + 1. Import `KEYS.asc` into the ISO keyring (`pacman-key --add` + `--lsign-key`). 2. Point `[breadway]` `Server` at `https://dl.breadway.dev/arch/$arch`. 3. Only then flip that section to `SigLevel = Required`. Do not do those three steps against Forgejo's registry. See -`iso/pacman.conf` and `iso/airootfs/etc/pacman.conf`. +`iso/pacman.conf` and `iso/airootfs/etc/pacman.conf`. This branch does +**not** change either file. diff --git a/scripts/ci-publish-signed-repo.sh b/scripts/ci-publish-signed-repo.sh new file mode 100755 index 0000000..08fbb53 --- /dev/null +++ b/scripts/ci-publish-signed-repo.sh @@ -0,0 +1,298 @@ +#!/usr/bin/env bash +# Collect the current [breadway] ISO packages, detach-sign them with the +# BOS release key (releases@breadway.dev), and publish a signed pacman db +# under /srv/breadway-dl/arch/x86_64/ (https://dl.breadway.dev/arch/x86_64/). +# +# Does not change ISO SigLevel and does not write to the Forgejo Arch +# registry — existing package.yml / packaging/*.yml PUTs stay as they are. +# +# Required env: +# GPG_PRIVATE_KEY armoured secret key (same secret as release-iso.yml) +# Optional env: +# BREADWAY_DEST publish dir (default /srv/breadway-dl/arch/x86_64) +# BREADWAY_PKG_DIR extra directory of .pkg.tar.zst to prefer over the registry +# BREADWAY_REGISTRY Forgejo Arch registry base +# BREADWAY_SIGN_ONLY=1 skip collect; sign+index BREADWAY_REPO_DIR only +set -euo pipefail + +PACKAGES=( + breadlock + calamares + zen-browser-bin + bibata-cursor-theme-bin + zsh-theme-powerlevel10k + yay-bin +) + +ARCH="${BREADWAY_ARCH:-x86_64}" +REGISTRY="${BREADWAY_REGISTRY:-https://git.breadway.dev/api/packages/Breadway/arch/os}" +DEST="${BREADWAY_DEST:-/srv/breadway-dl/arch/${ARCH}}" +KEY_ID="${BREADWAY_KEY_ID:-releases@breadway.dev}" +DB_NAME="${BREADWAY_REGISTRY_DB:-Breadway.os.git.breadway.dev.db}" +REPO_DIR="${BREADWAY_REPO_DIR:-}" + +SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")" + +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +need_key() { + if [[ -z "${GPG_PRIVATE_KEY:-}" ]]; then + die "GPG_PRIVATE_KEY is missing; refusing to publish an unsigned [breadway] repo." + fi +} + +urlencode() { + python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe="-._~"))' "$1" +} + +pkginfo_name() { + local pkg="$1" info + info="$(tar -xOf "$pkg" .PKGINFO 2>/dev/null || zstd -dc "$pkg" | tar -xO .PKGINFO)" + awk -F ' = ' '$1=="pkgname" {print $2; exit}' <<<"$info" +} + +import_key() { + export GNUPGHOME="${GNUPGHOME:-$(mktemp -d "${TMPDIR:-/tmp}/gnupg-breadway-repo.XXXXXX")}" + mkdir -m 700 -p "$GNUPGHOME" + printf '%s\n' "$GPG_PRIVATE_KEY" | gpg --batch --import +} + +detach_sign_pkgs() { + local pkg + shopt -s nullglob + for pkg in *.pkg.tar.zst; do + gpg --batch --yes --local-user "$KEY_ID" --detach-sign "$pkg" + done + shopt -u nullglob +} + +repo_add_signed() { + local pkgs=() + shopt -s nullglob + pkgs=(*.pkg.tar.zst) + shopt -u nullglob + (( ${#pkgs[@]} > 0 )) || die "no .pkg.tar.zst files to index" + rm -f breadway.db breadway.db.tar.gz breadway.db.sig breadway.db.tar.gz.sig \ + breadway.files breadway.files.tar.gz breadway.files.sig breadway.files.tar.gz.sig + if repo-add --help 2>&1 | grep -q -- '--include-sigs'; then + repo-add -s -k "$KEY_ID" --include-sigs breadway.db.tar.gz "${pkgs[@]}" + else + repo-add -s -k "$KEY_ID" breadway.db.tar.gz "${pkgs[@]}" + fi + [[ -e breadway.db.tar.gz.sig || -e breadway.db.sig ]] \ + || die "repo-add -s did not write breadway.db*.sig" +} + +ensure_arch_tools() { + if ! command -v gpg >/dev/null 2>&1; then + command -v pacman >/dev/null 2>&1 || die "gpg not on PATH" + pacman -Sy --noconfirm --needed gnupg + fi + command -v repo-add >/dev/null 2>&1 || die "repo-add not on PATH" + command -v gpg >/dev/null 2>&1 || die "gpg not on PATH" +} + +sign_and_index() { + local dir="$1" + [[ -d "$dir" ]] || die "repo dir missing: $dir" + need_key + ensure_arch_tools + import_key + ( + cd "$dir" + detach_sign_pkgs + repo_add_signed + ) +} + +container_runtime() { + if command -v docker >/dev/null 2>&1; then + printf '%s\n' docker + elif command -v podman >/dev/null 2>&1; then + printf '%s\n' podman + else + return 1 + fi +} + +sign_and_index_anywhere() { + local dir="$1" + if command -v repo-add >/dev/null 2>&1 && command -v gpg >/dev/null 2>&1; then + sign_and_index "$dir" + return + fi + local rt + rt="$(container_runtime)" || die \ + "need host gpg+repo-add, or docker/podman to run archlinux:latest (no Forgejo container: — host must see /srv/breadway-dl)" + # Host job + bind-mount, same reason bakery writes /srv without container:. + "$rt" run --rm --network=host \ + -e GPG_PRIVATE_KEY \ + -e BREADWAY_SIGN_ONLY=1 \ + -e BREADWAY_REPO_DIR=/repo \ + -e BREADWAY_KEY_ID="$KEY_ID" \ + -v "$dir:/repo" \ + -v "$SCRIPT_PATH:/ci-publish-signed-repo.sh:ro" \ + archlinux:latest \ + bash /ci-publish-signed-repo.sh +} + +parse_registry_db() { + local db="$1" + python3 - "$db" "${PACKAGES[@]}" <<'PY' +import sys, tarfile + +db = sys.argv[1] +want = set(sys.argv[2:]) +found = {} +with tarfile.open(db, "r:*") as tf: + for member in tf.getmembers(): + if not member.name.endswith("/desc") or not member.isfile(): + continue + fh = tf.extractfile(member) + if fh is None: + continue + text = fh.read().decode() + fields = {} + key = None + buf = [] + def flush(): + if key is not None: + fields[key] = "\n".join(buf).strip() + for line in text.splitlines(): + if line.startswith("%") and line.endswith("%") and len(line) > 2: + flush() + key = line.strip("%") + buf = [] + else: + buf.append(line) + flush() + name = fields.get("NAME", "") + filename = fields.get("FILENAME", "") + if name in want and filename: + found[name] = filename + +missing = sorted(want - set(found)) +if missing: + sys.stderr.write("registry db missing packages: " + " ".join(missing) + "\n") + raise SystemExit(1) +for name in sys.argv[2:]: + print(f"{name}\t{found[name]}") +PY +} + +copy_local_overrides() { + local dir="$1" + [[ -n "$dir" && -d "$dir" ]] || return 0 + local pkg name + shopt -s nullglob + for pkg in "$dir"/*.pkg.tar.zst "$dir"/*/*.pkg.tar.zst; do + [[ -f "$pkg" ]] || continue + name="$(pkginfo_name "$pkg")" + [[ -n "$name" ]] || continue + local wanted=0 p + for p in "${PACKAGES[@]}"; do + if [[ "$p" == "$name" ]]; then + wanted=1 + break + fi + done + if (( wanted )); then + printf 'local override: %s -> %s\n' "$name" "$(basename "$pkg")" + cp -a "$pkg" "$STAGE/$(basename "$pkg")" + fi + done + shopt -u nullglob +} + +has_pkg_named() { + local name="$1" pkg got + shopt -s nullglob + for pkg in "$STAGE"/*.pkg.tar.zst; do + got="$(pkginfo_name "$pkg")" + if [[ "$got" == "$name" ]]; then + shopt -u nullglob + return 0 + fi + done + shopt -u nullglob + return 1 +} + +collect_from_registry() { + local work db name filename enc url + work="$(mktemp -d "${TMPDIR:-/tmp}/breadway-db.XXXXXX")" + db="$work/$DB_NAME" + curl -fL --retry 3 --retry-delay 2 -o "$db" "$REGISTRY/$ARCH/$DB_NAME" \ + || die "failed to fetch $REGISTRY/$ARCH/$DB_NAME" + while IFS=$'\t' read -r name filename; do + if has_pkg_named "$name"; then + printf 'using local %s, skip registry\n' "$name" + continue + fi + enc="$(urlencode "$filename")" + url="$REGISTRY/$ARCH/$enc" + printf 'fetch %s\n' "$filename" + curl -fL --retry 3 --retry-delay 2 -o "$STAGE/$filename" "$url" \ + || die "failed to fetch $url" + done < <(parse_registry_db "$db") + rm -rf "$work" +} + +publish_tree() { + local parent dest_name prev + parent="$(dirname "$DEST")" + dest_name="$(basename "$DEST")" + mkdir -p "$parent" + chmod a+rX "$STAGE" + find "$STAGE" -type f -exec chmod a+r {} + + prev="$parent/${dest_name}.prev" + rm -rf "$prev" + if [[ -e "$DEST" ]]; then + mv "$DEST" "$prev" + fi + mv "$STAGE" "$DEST" + rm -rf "$prev" + STAGE="" +} + +if [[ "${BREADWAY_SIGN_ONLY:-0}" == 1 ]]; then + [[ -n "$REPO_DIR" ]] || die "BREADWAY_SIGN_ONLY requires BREADWAY_REPO_DIR" + sign_and_index "$REPO_DIR" + exit 0 +fi + +need_key + +DEST_PARENT="$(dirname "$DEST")" +mkdir -p "$DEST_PARENT" || die "cannot create $DEST_PARENT (runner must write /srv/breadway-dl)" +STAGE="$(mktemp -d "$DEST_PARENT/.stage-XXXXXX")" +cleanup() { + if [[ -n "${STAGE:-}" && -d "${STAGE:-}" ]]; then + rm -rf "$STAGE" + fi + if [[ -n "${GNUPGHOME:-}" && "$GNUPGHOME" == *gnupg-breadway-repo* ]]; then + rm -rf "$GNUPGHOME" + fi +} +trap cleanup EXIT + +copy_local_overrides "${BREADWAY_PKG_DIR:-}" +collect_from_registry + +missing=() +for name in "${PACKAGES[@]}"; do + has_pkg_named "$name" || missing+=("$name") +done +if (( ${#missing[@]} > 0 )); then + die "missing packages after collect: ${missing[*]}" +fi + +sign_and_index_anywhere "$STAGE" + +# Do not publish helper junk if a container left any. +rm -f "$STAGE/.sign.sh" + +publish_tree + +printf 'published signed [breadway] repo -> %s\n' "$DEST" +ls -lh "$DEST" From 055d92cc5aa84be2eb51ac9804d688a46330a1df Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 01:31:29 +0800 Subject: [PATCH 10/10] iso: pin bakery versions to the published 0.6.1 index bakery 0.7.3, bread-theme 0.7.3, bar/box 0.3.2, crumbs 2.1.8, pad 0.5.2, paper 0.1.13, mon 0.1.4, search 0.3.2, clip 0.2.3, shot 0.1.3, settings 0.8.1, help 0.2.5. bread stays 0.8.0. HEAD-checked on dl.breadway.dev before commit. --- iso/bread-lockfile.toml | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/iso/bread-lockfile.toml b/iso/bread-lockfile.toml index e1ef73a..6154893 100644 --- a/iso/bread-lockfile.toml +++ b/iso/bread-lockfile.toml @@ -48,23 +48,20 @@ optional_bins = [ ] # Package name → version. Must exist at dl.breadway.dev/// and -# should match the signed index so CI can verify sha256. Newer git tags -# that bakery release CI did not publish (breadpad 0.5.1, breadmon 0.1.3, -# breadclip 0.2.2, breadshot 0.1.2, breadhelp 0.2.4, breadcrumbs 2.1.7) -# stay off this list until those artifacts exist. +# should match the signed index so CI can verify sha256. # [[pin]] { package, version } is accepted as well and merged (conflict = bake error). [versions] -bakery = "0.7.2" +bakery = "0.7.3" bread = "0.8.0" -bread-theme = "0.7.2" -breadbar = "0.3.1" -breadbox = "0.3.1" -breadcrumbs = "2.1.6" -breadpad = "0.5.0" -breadpaper = "0.1.12" -breadmon = "0.1.2" -breadsearch = "0.3.1" -breadclip = "0.1.1" -breadshot = "0.1.1" -bos-settings = "0.8.0" -breadhelp = "0.2.3" +bread-theme = "0.7.3" +breadbar = "0.3.2" +breadbox = "0.3.2" +breadcrumbs = "2.1.8" +breadpad = "0.5.2" +breadpaper = "0.1.13" +breadmon = "0.1.4" +breadsearch = "0.3.2" +breadclip = "0.2.3" +breadshot = "0.1.3" +bos-settings = "0.8.1" +breadhelp = "0.2.5"