Compare commits

..

No commits in common. "055d92cc5aa84be2eb51ac9804d688a46330a1df" and "59fa81de980f54b26a5a8ea60c466dc630e59a6f" have entirely different histories.

36 changed files with 201 additions and 2086 deletions

View file

@ -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. Signs ISO SHA256SUMS here; the same secret
# signs the [breadway] repo in signed-repo.yml. No passphrase
# at KEYS.asc for verifying ISO SHA256SUMS only. That key
# does not sign the [breadway] pacman repo. No passphrase
# (CI-only key, access controlled via the Forgejo secret
# store).
@ -41,10 +41,7 @@ jobs:
steps:
- name: Install build dependencies
run: |
# 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
pacman -Syu --noconfirm archiso curl python git minisign
- name: Determine tag and version
id: vars

View file

@ -1,52 +0,0 @@
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

View file

@ -52,7 +52,4 @@ 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. 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).
`rootflags=subvol=@`. Recovery is grub-btrfs reboot.

102
README.md
View file

@ -14,9 +14,8 @@ 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 `/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 ecosystem**, baked into `/etc/skel` from bakery-managed binaries
(no network needed at install time): 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),
@ -46,11 +45,9 @@ 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 (**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
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
[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
@ -61,7 +58,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 `/usr/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 `~/.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` |
@ -96,12 +93,11 @@ 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 # dl.breadway.dev/arch signing
├── .forgejo/workflows/ # CI: AUR republish + signed repo + tagged ISO
│ └── signed-repo.md # future dl.breadway.dev/arch signing
├── .forgejo/workflows/ # CI: AUR republish + tagged ISO release
├── build-local.sh # native ISO build for this machine
├── README.md
└── DESIGN.md # historical plan
@ -127,16 +123,9 @@ 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 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`.
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`.
`build-local.sh` builds the image natively (no container) and bakes this
machine's bakery-installed bread binaries + breadhelp content into
`/etc/skel`:
```sh
sudo ./build-local.sh # release-quality (xz squashfs)
@ -201,38 +190,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 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.
subvolumes, services, bakery bins, and breadhelp content under
`~/.local/share/breadhelp/content`.
## bos-settings
@ -267,8 +226,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 (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 |
| Snapshots | `snapper` list / boot-into (grub-btrfs) / delete |
Source and build live in the [bos-settings](https://git.breadway.dev/Breadway/bos-settings)
repo, not here.
@ -276,7 +234,7 @@ repo, not here.
## The bread ecosystem
Everything below is a separate bakery-distributed project with its own repo
and release cadence, baked into `/usr/local` at ISO build time so a fresh
and release cadence, baked into `/etc/skel` 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
@ -300,7 +258,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 `/usr/local/share/breadhelp/content` (bakery `content.tar.gz`, baked into the image). | `SUPER+/` |
| `breadhelp` | Onboarding + in-session help/cheatsheet. Content lives at `~/.local/share/breadhelp/content` (bakery `content.tar.gz`, baked into skel). | `SUPER+/` |
**System**
@ -386,18 +344,15 @@ until `dl.breadway.dev/arch` exists).
## Recovery
**An update broke something (system still boots):** reboot → **GRUB
“snapshots” submenu** (grub-btrfs), then boot that entry.
**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.
BOS Settings → Snapshots lists each snapshots number, date, and
description so you know which GRUB entry to pick. It does not roll the
running root back in place. Snapper is root only. Home files are
**Settings → Backup** (restic restore into `~/bos-restore-<id>`, 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
will boot next. Details: [docs/hardware.md](docs/hardware.md).
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).
A/B root swapping (SteamOS-style) is a **future** idea in DESIGN.md — it is
not shipped.
@ -405,15 +360,14 @@ not shipped.
**The system won't boot (broken GRUB / lost EFI entry):**
1. Boot the BOS ISO and open a terminal (`SUPER+Return`).
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:
2. Mount the installed root and EFI, then chroot:
```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

View file

@ -41,15 +41,13 @@ if [ "${FAST_BUILD:-0}" = "1" ]; then
fi
grep airootfs_image_tool_options "$STAGE/profiledef.sh"
# --- Bake this machine's bakery-installed bread ecosystem into the image ------
# --- Bake this machine's bakery-installed bread ecosystem into /etc/skel ------
# 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. 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.
# 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.
#
# CI should prefer the stable bakery index when populating the builder home.
# Local builds still snapshot the builder. required_bins fail the bake if
@ -99,14 +97,9 @@ 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"
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"
SKEL="$STAGE/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
@ -131,9 +124,9 @@ for b in "${OPTIONAL_BINS[@]}"; do
fi
done
install -d -m 0755 "$IMAGE_BIN" "$SKEL/.local/state/bakery" "$SKEL/.cache/bakery"
install -d -m 0755 "$SKEL/.local/bin" "$SKEL/.local/state/bakery" "$SKEL/.cache/bakery"
for b in "${BREAD_BINS[@]}"; do
install -m 0755 "$BAKERY_BIN/$b" "$IMAGE_BIN/$b"
install -m 0755 "$BAKERY_BIN/$b" "$SKEL/.local/bin/$b"
done
# Drop packages that are not in the lockfile (breadcast/breadarr must not
@ -171,27 +164,26 @@ 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 "$IMAGE_BIN")"
echo "baked bins: $(ls "$SKEL/.local/bin")"
# --- Bake bakery data dirs the apps need offline ------------------------------
# bakery extracts data_archive (breadhelp's content.tar.gz) to
# $prefix/share/<pkg>/ 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 ==="
# ~/.local/share/<pkg>/ 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 ==="
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 on the builder" >&2
echo "bakery installs this from content.tar.gz into ~/.local/share/breadhelp/content" >&2
echo "A breadhelp binary without content is a hollow ISO." >&2
exit 1
fi
install -d -m 0755 "$IMAGE_SHARE"
cp -a "$BAKERY_SHARE/breadhelp" "$IMAGE_SHARE/breadhelp"
echo " baked $IMAGE_SHARE/breadhelp/content"
install -d -m 0755 "$SKEL/.local/share"
cp -a "$BAKERY_SHARE/breadhelp" "$SKEL/.local/share/breadhelp"
echo " baked $SKEL/.local/share/breadhelp/content"
python3 - "$BAKERY_CACHE/index.json" "$BAKERY_SHARE" "$IMAGE_SHARE" "${BREAD_BINS[@]}" <<'PY'
python3 - "$BAKERY_CACHE/index.json" "$BAKERY_SHARE" "$SKEL/.local/share" "${BREAD_BINS[@]}" <<'PY'
import json, os, shutil, sys
index_path, src_share, dest_share, *bins = sys.argv[1:]
wanted = set(bins)
@ -249,194 +241,58 @@ 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).
# 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), 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 ==="
# 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 ==="
SYSTEMD_USER_DIR="$LAPTOP_HOME/.config/systemd/user"
SKEL_SYSTEMD="$SKEL/.config/systemd/user"
install -d -m 0755 "$IMAGE_UNITS"
# 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)
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)
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'
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
src="$SKEL_SYSTEMD/$unit"
echo " $unit using committed skel unit as source"
else
echo " $unit already committed in skel, leaving as-is"
continue
fi
src="$SYSTEMD_USER_DIR/$unit"
if [[ ! -f "$src" ]]; then
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
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
fi
fi
rewrite_exec_start "$src" "$IMAGE_UNITS/$unit"
if [[ -f "$SKEL_SYSTEMD/$unit" ]]; then
rewrite_exec_start "$src" "$SKEL_SYSTEMD/$unit"
fi
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
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 "$IMAGE_UNITS/$target_name"
ln -sf "../$unit" "$IMAGE_UNITS/$target_name/$unit"
install -d -m 0755 "$SKEL_SYSTEMD/$target_name"
ln -sf "../$unit" "$SKEL_SYSTEMD/$target_name/$unit"
done
echo " baked $unit"
done
# systemctl --global enable equivalent: /etc/systemd/user/<WantedBy>.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
# baked bakery binary right after the array opener (bos-* bins are already
# listed; keeps the bakery list in one place — the lockfile).
# baked binary right after the array opener (keeps the binary list in one place).
perm_file="$(mktemp)"
for b in "${BREAD_BINS[@]}"; do
printf ' ["/usr/local/bin/%s"]="0:0:755"\n' "$b" >>"$perm_file"
printf ' ["/etc/skel/.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 -A40 '^file_permissions=(' "$STAGE/profiledef.sh"
echo "=== file_permissions after injection ==="; grep -A14 '^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

View file

@ -4,43 +4,18 @@
BOS ships the generic **Mesa** stack. AMD and Intel work out of the box.
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.
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`.
A VM without `/dev/dri` gets a notification only.
**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.
## Recovery
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=@`).
An update that breaks the system is recovered from the **GRUB "snapshots"
submenu** (grub-btrfs), not `snapper rollback`.
`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 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.
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
`grub-install` NVRAM + `--removable` sequence as `post-install.sh`.
A/B root swapping is not implemented. See the README Recovery section.
A/B root swapping is not implemented. See the README Recovery section for
the "system will not boot" GRUB/EFI repair path.

View file

@ -3,24 +3,16 @@
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`** 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.
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.
The signed repo belongs at `https://dl.breadway.dev/arch`, not on Forgejo's
registry. Forgejo publishing stays as it is (`package.yml` / packaging
workflows PUT unsigned `.pkg.tar.zst` so existing Never installs keep
working).
registry.
## 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)
@ -28,7 +20,6 @@ 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`):
@ -42,114 +33,53 @@ https://dl.breadway.dev/arch/x86_64/
breadway.files.sig
```
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`:
Build the database **and sign it** 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
gpg --batch --yes --local-user releases@breadway.dev \
--detach-sign breadlock-<ver>-1-x86_64.pkg.tar.zst
# → breadlock-<ver>-1-x86_64.pkg.tar.zst.sig
cd /srv/breadway-dl/arch/x86_64
cd /srv/dl.breadway.dev/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 `<section>.db` + `<section>.db.sig` from `Server`.
## 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`):
Package signatures are separate from the database signature. Detach-sign
each `.pkg.tar.zst` as a **binary** sidecar (pacman wants `.sig`, not
armoured `.asc`):
```sh
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"}'
gpg --batch --yes --local-user releases@breadway.dev \
--detach-sign breadlock-<ver>-1-x86_64.pkg.tar.zst
# → breadlock-<ver>-1-x86_64.pkg.tar.zst.sig
```
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)
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.
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.
## 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`. This branch does
**not** change either file.
`iso/pacman.conf` and `iso/airootfs/etc/pacman.conf`.

View file

@ -1,2 +0,0 @@
# Bakery desktop apps live under /usr/local so they ride snapper @ snapshots.
prefix = "/usr/local"

View file

@ -1,24 +1,10 @@
---
# 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
skip_if_no_internet: true
update_db: true
ignore_update_db_error: true
update_system: false
options:
- update_db: true
pacman:
num_retries: 1
disable_download_timeout: false
needed_only: true
operations: []
operations:
- try_install:
- pipewire-pulse
- pipewire-alsa

View file

@ -425,20 +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:
# 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
# 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
# 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

View file

@ -35,6 +35,12 @@ 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
@ -51,12 +57,6 @@ 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

View file

@ -3,8 +3,5 @@ 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

View file

@ -7,9 +7,8 @@
# 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
# 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
# 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
# (sessions.rs: discover_prefers_configured_default_over_first_entry).
[sessions]

View file

@ -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 /usr/local at ISO build time.
# are NOT here; they are bakery-baked into /etc/skel 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).

View file

@ -1,8 +1,8 @@
# 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).
# 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).
case ":$PATH:" in
*":$HOME/.local/bin:"*) ;;
*) export PATH="$HOME/.local/bin:$PATH" ;;

View file

@ -3,7 +3,6 @@
{ "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 },

View file

@ -77,17 +77,6 @@ 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.
@ -135,12 +124,9 @@ 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 from /usr/lib/systemd/user — see
-- build-local.sh's service bake) rather than an exec-once here.
-- 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'",
-- service (auto-started via skel — 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).
-- Raw `awww img`, NOT `breadpaper set` — breadpaper set also runs real
@ -154,9 +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 (/usr/lib/systemd/user/breadd.service,
-- enabled --global so every account starts it). It autostarts at login
-- but before Hyprland exists, so
-- breadd runs as a systemd user service (~/.config/systemd/user/breadd.service,
-- enabled in skel). 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",
@ -177,8 +162,7 @@ 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 /usr/lib/systemd/user,
-- independent of this list.
-- runs on login via the unit baked into skel, independent of this list.
local ok, extra = pcall(function()
return dofile(script_dir .. "system/autostart.lua")()
end)
@ -190,7 +174,6 @@ 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'",

View file

@ -18,7 +18,6 @@ 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 },

View file

@ -3,8 +3,8 @@ Description=Bread Runtime Daemon
[Service]
Type=simple
# System-prefix bakery install — same path for every account.
ExecStart=/usr/local/bin/breadd
# %h = the user's home — works for any account created from this skel.
ExecStart=%h/.local/bin/breadd
Restart=on-failure
RestartSec=2
UMask=0077

View file

@ -89,7 +89,7 @@ alias alt-install='yay -S'
alias alt-uninstall='yay -R'
alias alt-srchpkg='yay -Ss'
# Per-user tools. Bakery desktop apps live in /usr/local/bin (already on PATH).
# ~/.local/bin holds the bread* binaries baked in at build time.
export PATH="$HOME/.local/bin:$PATH"
# Powerlevel10k prompt configuration.

View file

@ -1 +0,0 @@
/usr/lib/systemd/user/breadd.service

View file

@ -1,12 +0,0 @@
# 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

View file

@ -1,90 +0,0 @@
#!/bin/bash
# Enable bakery systemd --user units for every account (current and future).
#
# `systemctl --global enable` writes /etc/systemd/user/<target>.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

View file

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

View file

@ -7,17 +7,9 @@
# 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
# (hypr + bread config + bakery state) — proper live-media functionality,
# not an installer kiosk. Binaries are /usr/local, not skel.
# (breadd + breadbar + breadbox + keybinds) — proper live-media functionality,
# not an installer kiosk.
if ! id liveuser &>/dev/null; then
useradd -m -s /usr/bin/zsh liveuser
for g in wheel video input audio storage power; do

View file

@ -1,150 +0,0 @@
#!/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

View file

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

View file

@ -2,10 +2,11 @@
# 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.
# 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.
# 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.
#
# Launched via start-hyprland (ships with the hyprland package) rather than the
# raw Hyprland binary — Hyprland upstream no longer recommends exec'ing it

View file

@ -8,12 +8,11 @@
# 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 /usr/local (whatever `bakery list`
# 2. bakery — the bread ecosystem apps in ~/.local/bin (whatever `bakery list`
# reports as installed — bakery, bread, breadbar, breadbox,
# breadcrumbs, breadpad, breadman, bread-theme, breadpaper,
# breadmon, breadsearch, breadclip, breadshot, bos-settings,
# breadhelp, ...). Those bits live on @ and ride snapper
# root snapshots; recover via grub-btrfs, not `snapper rollback`.
# breadhelp, ...).
#
# Best-effort: a failure in one channel doesn't abort the other.
set -uo pipefail
@ -21,11 +20,12 @@ 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 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.
# 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.
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 \
@ -54,7 +54,3 @@ 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=@)."

View file

@ -1,4 +1,4 @@
# Bakery binaries baked into the live/installed image at /usr/local.
# Bakery binaries baked into the live/installed skel.
#
# 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.
@ -48,20 +48,23 @@ optional_bins = [
]
# Package name → version. Must exist at dl.breadway.dev/<pkg>/<ver>/ and
# should match the signed index so CI can verify sha256.
# 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.
# [[pin]] { package, version } is accepted as well and merged (conflict = bake error).
[versions]
bakery = "0.7.3"
bakery = "0.7.2"
bread = "0.8.0"
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"
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"

View file

@ -78,8 +78,6 @@ snapper
snap-pac
grub-btrfs
inotify-tools
# Home backup (Settings → Backup). Snapper is root (`@`) only; restic covers $HOME.
restic
# Wayland / Hyprland
hyprland
@ -208,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 /usr/local at ISO build
# breadhelp — is bakery-managed and baked into /etc/skel/.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
# also committed under iso/airootfs/etc/skel/.local/share/applications/. Runtime
# 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, …).

View file

@ -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 /usr/local at ISO build time.
# are NOT here; they are bakery-baked into /etc/skel 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).

View file

@ -29,8 +29,4 @@ 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"
["/usr/local/bin/bos-nvidia-setup"]="0:0:755"
["/usr/local/bin/bos-enable-bakery-user-units"]="0:0:755"
)

View file

@ -1,298 +0,0 @@
#!/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"

View file

@ -1,20 +1,15 @@
#!/usr/bin/env bash
# Read-only checks that a builder home (and optionally a staged image) has
# Read-only checks that a builder home (and optionally a staged skel) 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
@ -47,17 +42,6 @@ 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)"
else
bad "grub-install missing — mkarchiso uefi.grub will abort (install grub on the builder)"
fi
echo "== builder home $LAPTOP_HOME =="
check_exec() {
@ -122,108 +106,21 @@ else
done
fi
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 "bakery prefix is not /usr/local in $AIROOTFS/etc/bakery/config.toml"
fi
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
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
check_exec "$SKEL/.local/bin/$b" "skel required bin $b"
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
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
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
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
if [[ -f "$SKEL/.config/systemd/user/$unit" ]]; then
ok "skel unit $unit"
else
bad "skel unit missing: $SKEL/.config/systemd/user/$unit"
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
echo

View file

@ -63,55 +63,15 @@ check "bos-settings installed" "command -v bos-settings"
echo "== breadhelp =="
check "breadhelp installed" "command -v breadhelp"
check "breadhelp content installed" \
"[ -d /usr/local/share/breadhelp/content ] || [ -d \"\$HOME/.local/share/breadhelp/content\" ]"
"[ -d \"\$HOME/.local/share/breadhelp/content\" ] || [ -d /etc/skel/.local/share/breadhelp/content ]"
check "bos-netcheck present" "command -v bos-netcheck"
check "bos-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
# --global (or the user enables them). post-install + live-setup + bake
# write /etc/systemd/user/<target>.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 "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\" ]"
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\" ]"