Compare commits

..

No commits in common. "main" and "v0.5.0" have entirely different histories.
main ... v0.5.0

55 changed files with 498 additions and 3986 deletions

View file

@ -0,0 +1,21 @@
name: Mirror to GitHub
on:
push:
branches: ['**']
tags: ['**']
jobs:
mirror:
runs-on: [self-hosted, hestia]
steps:
- name: Mirror to GitHub
run: |
set -euo pipefail
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
cd repo.git
# Mirror only branches and tags (not refs/pull/*, which GitHub rejects);
# --prune deletes GitHub refs that no longer exist on Forgejo.
git push --prune \
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/bos.git" \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'

View file

@ -1,39 +0,0 @@
name: Build and publish python-pywal
# python-pywal was dropped from Arch's [extra] repo (AUR-only now), but the ISO
# needs the `wal` binary (bread-theme extracts the wallpaper palette with it).
# BOS keeps an in-house PKGBUILD and publishes to the [breadway] repo — same
# pattern as calamares / bibata / powerlevel10k / yay-bin.
on:
push:
paths:
- 'packaging/python-pywal/**'
workflow_dispatch:
jobs:
python-pywal:
runs-on: [self-hosted, hestia]
container:
image: archlinux:latest
steps:
- name: Build and publish
env:
PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
pacman -Syu --noconfirm base-devel git \
python python-build python-installer python-wheel python-setuptools imagemagick
useradd -m builder
git config --global --add safe.directory '*'
# Clone the ref that triggered this run (not the default branch) —
# same as the other packaging workflows.
git clone --depth 1 --branch "${GITHUB_REF_NAME}" \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src
chown -R builder:builder /home/builder/src
su builder -c "cd /home/builder/src/packaging/python-pywal && makepkg -f --noconfirm"
PKG=$(find /home/builder/src/packaging/python-pywal -name '*.pkg.tar.zst' | head -1)
curl -fsS -X PUT \
-H "Authorization: token ${PUBLISH_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${PKG}" \
"https://git.breadway.dev/api/packages/Breadway/arch/os"

View file

@ -1,21 +1,19 @@
name: Build and release ISO name: Build and release ISO
# Builds the BOS ISO on the hestia self-hosted runner (native Arch container). # Builds the BOS ISO on the hestia self-hosted runner (native Arch container),
# Stages bakery desktop apps from the *minisign-verified* stable index at # downloads all bakery ecosystem binaries from their GitHub releases, compiles
# https://dl.breadway.dev/index.json (see iso/bread-lockfile.toml), then runs # bread-theme from source, and uploads the resulting ISO to a Forgejo pre-release.
# build-local.sh and uploads the ISO to a Forgejo release. A matching GitHub # A matching GitHub release is created that points to Forgejo for the download
# release is created best-effort and points at Forgejo for the download
# (GitHub releases cannot host files larger than 2 GB). # (GitHub releases cannot host files larger than 2 GB).
# #
# Required secrets: # Required secrets:
# RELEASE_TOKEN — Forgejo API token with write:repository scope # RELEASE_TOKEN — Forgejo API token with write:repository scope
# MIRROR_TOKEN — GitHub personal access token with repo scope # MIRROR_TOKEN — GitHub personal access token with repo scope (already used by mirror.yml)
# GPG_PRIVATE_KEY — armoured secret key for the dedicated "BOS Release Signing" # GPG_PRIVATE_KEY — armoured secret key for the dedicated "BOS Release Signing"
# identity (releases@breadway.dev); public half is committed # identity (releases@breadway.dev); public half is committed
# at KEYS.asc. Signs ISO SHA256SUMS here; the same secret # at KEYS.asc for verification. No passphrase (CI-only key,
# signs the [breadway] repo in signed-repo.yml. No passphrase # access controlled via the Forgejo secret store, not a
# (CI-only key, access controlled via the Forgejo secret # passphrase nobody could type non-interactively anyway).
# store).
on: on:
push: push:
@ -30,8 +28,6 @@ jobs:
release-iso: release-iso:
runs-on: [self-hosted, hestia] runs-on: [self-hosted, hestia]
container: container:
# Floating tag: this environment cannot pin a reproducible digest of
# archlinux:latest. Do not invent one.
image: archlinux:latest image: archlinux:latest
# --privileged: mkarchiso needs CAP_SYS_ADMIN for loop mounts + mknod # --privileged: mkarchiso needs CAP_SYS_ADMIN for loop mounts + mknod
# --network=host: gives localhost:3002 access to Forgejo (avoids the # --network=host: gives localhost:3002 access to Forgejo (avoids the
@ -41,10 +37,7 @@ jobs:
steps: steps:
- name: Install build dependencies - name: Install build dependencies
run: | run: |
# grub is required by profiledef.sh bootmodes=('uefi.grub'): pacman -Syu --noconfirm archiso curl python git rust
# mkarchiso validates grub-install on the *builder*, not the image.
# archiso pulls syslinux/squashfs-tools/libisoburn; it does not pull grub.
pacman -Syu --noconfirm archiso grub curl python git minisign
- name: Determine tag and version - name: Determine tag and version
id: vars id: vars
@ -62,17 +55,82 @@ jobs:
git clone --branch "${{ steps.vars.outputs.tag }}" --depth 1 \ git clone --branch "${{ steps.vars.outputs.tag }}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /bos "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /bos
- name: Stage bakery ecosystem from signed stable index - name: Download bakery ecosystem binaries
run: | run: |
set -euo pipefail set -euo pipefail
cd /bos mkdir -p /build-home/.local/bin \
LAPTOP_HOME=/build-home python3 scripts/ci-stage-bakery.py /build-home/.local/state/bakery \
/build-home/.cache/bakery
- name: Verify staged bakery bake inputs # Fetch the canonical bakery index
curl -fsSL "https://dl.breadway.dev/index.json" \
-o /build-home/.cache/bakery/index.json
# Download each binary from dl.breadway.dev (canonical source; github_url
# is not always published for dev/patch releases) and generate the
# installed.json that bakery expects in ~/.local/state.
python3 << 'PYEOF'
import json, urllib.request, os
with open('/build-home/.cache/bakery/index.json') as f:
idx = json.load(f)
BIN_DIR = '/build-home/.local/bin'
installed = {}
for pkg_name, pkg in idx['packages'].items():
bins = []
for b in pkg['binaries']:
dest_name = b['name'].removesuffix('-x86_64')
dest = os.path.join(BIN_DIR, dest_name)
url = b['dl_url']
print(f' {dest_name} <- {url}', flush=True)
urllib.request.urlretrieve(url, dest)
os.chmod(dest, 0o755)
bins.append(dest_name)
# installed.json services field is a flat list of unit-name strings
services = [
(s['unit'] if isinstance(s, dict) else s)
for s in pkg.get('services', [])
]
installed[pkg_name] = {
'name': pkg_name,
'version': pkg['version'],
'binaries': bins,
'services': services,
'installed_at': '2024-01-01T00:00:00+00:00',
}
with open('/build-home/.local/state/bakery/installed.json', 'w') as f:
json.dump({'packages': installed}, f, indent=2)
print('installed.json written', flush=True)
PYEOF
- name: Build bread-theme from source
run: | run: |
set -euo pipefail set -euo pipefail
cd /bos # bread-theme is not in the bakery index; build it at the tag pinned
LAPTOP_HOME=/build-home bash scripts/ci-verify-bake.sh # in bos-settings' Cargo.toml so the CLI matches the library version
# the bos-settings package (and breadbar/breadbox/breadpad) were
# built against. bos-settings used to live at bos-settings/Cargo.toml
# inside this repo; it's since been split into its own repo
# (git.breadway.dev/Breadway/bos-settings), so fetch its Cargo.toml
# from there instead of a path that no longer exists in this
# checkout. Uses bos-settings' default branch (dev) — the branch its
# own CI actually publishes the `bos-settings` pacman package from.
REPO_OWNER="${GITHUB_REPOSITORY%%/*}"
curl -fsSL "https://git.breadway.dev/${REPO_OWNER}/bos-settings/raw/branch/dev/Cargo.toml" \
-o /tmp/bos-settings-Cargo.toml
THEME_TAG=$(grep 'bread-theme.*tag' /tmp/bos-settings-Cargo.toml \
| grep -oP '"v[^"]+"' | tr -d '"')
echo "Building bread-theme @ $THEME_TAG"
git clone --branch "$THEME_TAG" --depth 1 \
https://github.com/Breadway/bread-ecosystem /bread-ecosystem
cd /bread-ecosystem
cargo build --release -p bread-theme
install -m 755 target/release/bread-theme /build-home/.local/bin/bread-theme
echo "bread-theme built OK"
- name: Build ISO - name: Build ISO
run: | run: |
@ -186,21 +244,6 @@ jobs:
gh release create "${TAG}" \ gh release create "${TAG}" \
--repo "Breadway/bos" \ --repo "Breadway/bos" \
--title "BOS ${TAG}" \ --title "BOS ${TAG}" \
\
--notes-file /tmp/gh-release-notes.md \ --notes-file /tmp/gh-release-notes.md \
|| echo "skip: GitHub release failed (MIRROR_TOKEN historically broken)" 2>/dev/null || echo "GitHub release already exists — skipping"
# `stable` is a marker branch only — CI fast-forwards it to whatever
# commit the latest real (non-RC) release tag points at. Never merged
# into by hand, so unlike the old dev/beta/main model it can't rot:
# nobody has to remember to move it, a bot always does. Lets you
# `git diff stable..main` before a build to see what's new since the
# last release, without a human-maintained promotion step.
- name: Fast-forward stable branch to this tag
if: ${{ !contains(steps.vars.outputs.tag, '-rc.') }}
env:
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
set -euo pipefail
cd /bos
git push "https://oauth2:${FORGEJO_TOKEN}@git.breadway.dev/${GITHUB_REPOSITORY}.git" \
"HEAD:refs/heads/stable" --force

View file

@ -1,53 +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
- Build and publish python-pywal
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

7
.gitignore vendored
View file

@ -42,10 +42,3 @@ logs/
# Wallpaper source drop (baked copy lives in airootfs/usr/share/backgrounds) # Wallpaper source drop (baked copy lives in airootfs/usr/share/backgrounds)
/Bread Background.png /Bread Background.png
# Local hygiene notes (not for commit)
CLAUDE.md
# Python
__pycache__/
*.pyc

View file

@ -1,58 +0,0 @@
# AGENTS.md — Repo hygiene
Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation. Product shape is [README.md](README.md).
## What this repo is
ISO + Calamares + skel. **No Cargo workspace. No `bos-settings/` member.**
bos-settings (Tauri 2 + Svelte) and breadhelp are standalone bakery
products. breadlock is the only bread\* pacman package.
Live skel is `iso/airootfs/etc/skel`. `dotfiles/` is stale.
## Branch model
Single-trunk:
- `main` — integration + release trunk. Land work via short-lived
`feature/*` / `fix/*` branches, then merge.
- `stable` — marker only. CI fast-forwards it to the latest non-RC release
tag. Never merge into it by hand.
There is no `dev` integration branch.
## Remotes
- `origin` — Forgejo (`ssh://git@100.66.238.26:2222/Breadway/bos.git`) — authoritative.
- `github` — GitHub (`https://github.com/Breadway/bos.git`) mirror. Push
origin (and github when mirroring).
`origin` is **not** GitHub.
## CI
- `.forgejo/workflows/*.yml` trigger on `push: tags: ['v*']` (or
path-scoped packaging triggers) — ordinary pushes to `main` run nothing
except those path filters. Tag a release to build the ISO.
- `stable` is moved by the release-iso workflow, not by humans.
- No build/lint/test CI runs on ordinary commits or PRs — test locally
before merging to `main`.
## Cleanup
- Delete feature/fix branches (local + remote) once merged. Check with
`git branch --merged main`.
- Don't let merged branches accumulate.
## Don't
- Don't embed credentials in remote URLs — SSH or a credential helper only.
- Don't leave the default branch pointed at a feature branch on
GitHub/Forgejo.
- 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).

165
DESIGN.md
View file

@ -1,26 +1,4 @@
# BOS — historical design plan # BOS — Bread Operating System Plan
## Current architecture
**Read [README.md](README.md) for how this repo actually ships.** This file
is the original plan. Several sections below are historical and must not be
taken as current:
| Plan said | What the tree does now |
|-----------|------------------------|
| Cargo workspace with a `bos-settings/` member | This repo is ISO + Calamares + skel only. No Cargo workspace. |
| `bos-settings` as an in-tree GTK4 app | Standalone bakery product, **Tauri 2 + Svelte**. |
| bakery install in Calamares post-install | bakery binaries + breadhelp content are **baked into `/etc/skel` at ISO build time** from `iso/bread-lockfile.toml`. Missing bins fail the bake. |
| `dotfiles/` is the live skel | Live defaults are `iso/airootfs/etc/skel`. `dotfiles/` is stale. |
| A/B root swapping | **Future.** Today: btrfs + snapper + **grub-btrfs**. GRUB pins `rootflags=subvol=@`, so `snapper rollback` is not the user-facing recovery path. |
| Work on `dev`; origin = GitHub | Single-trunk `main`; `stable` is a CI marker. `origin` = Forgejo, `github` = GitHub. |
| `[breadway]` provides bakery/breadbar/bos-settings | `[breadway]` is breadlock + AUR republishes. Desktop apps are bakery. **Not shipped:** breadcast, breadarr. |
| NVIDIA / A/B / Secure Boot / LUKS2 | NVIDIA proprietary is **unsupported**. A/B root swapping is **not implemented**. Secure Boot is **Setup Mode only** (self-signed `sbctl`). Disk encryption is **LUKS1** because GRUB cannot unlock LUKS2+Argon2id. |
| `SigLevel = Required` on `[breadway]` | **Yes, as of the signed repo.** `[breadway]` points at `https://dl.breadway.dev/arch` where `scripts/ci-publish-signed-repo.sh` detach-signs every `.pkg.tar.zst` and the db with the BOS release key (`56203B86…`, `KEYS.asc`). That key is trusted in the pacman keyring at build time, on the live medium, and on the installed target. |
---
# Original plan (kept for history)
## Context ## Context
@ -29,25 +7,51 @@ The bread ecosystem (bread, breadbar, breadbox, breadcrumbs, breadpad/breadman,
Goals: Goals:
- **Install and be done**: Calamares GUI installer → reboot → working Hyprland + full bread stack - **Install and be done**: Calamares GUI installer → reboot → working Hyprland + full bread stack
- **Rollback safety**: Btrfs subvolumes + snapper + snap-pac; every pacman transaction is snapshotted - **Rollback safety**: Btrfs subvolumes + snapper + snap-pac; every pacman transaction is snapshotted
- **Unified config**: `bos-settings` surfaces all app configs + snapshot management + bakery updates - **Unified config**: `bos-settings` GTK4 app surfaces all app configs + snapshot management + bakery updates
- **Future-compatible**: Btrfs layout is designed to allow A/B partition migration later (SteamOS model) - **Future-compatible**: Btrfs layout is designed to allow A/B partition migration later (SteamOS model)
--- ---
## Repo Structure ## Repo Structure
Single new repo: `Breadway/bos`*planned as* a Cargo workspace. **That is Single new repo: `Breadway/bos` — a Cargo workspace.
not what landed**; see Current architecture.
``` ```
bos/ bos/
├── Cargo.toml # Workspace (members: [bos-settings]) — NOT in tree ├── Cargo.toml # Workspace (members: [bos-settings])
├── bos-settings/ # planned GTK4 app — now its own bakery repo ├── bos-settings/ # GTK4 unified settings app
├── iso/ # archiso profile (this is the repo) │ ├── Cargo.toml
│ └── src/
│ ├── main.rs
│ ├── state.rs
│ ├── theme.rs
│ ├── ui/
│ │ ├── window.rs # Sidebar + content shell (port breadman pattern)
│ │ ├── sidebar.rs
│ │ └── views/
│ │ ├── bread.rs
│ │ ├── breadbar.rs
│ │ ├── breadbox.rs
│ │ ├── breadcrumbs.rs
│ │ ├── breadpad.rs
│ │ ├── snapshots.rs
│ │ ├── packages.rs
│ │ └── hyprland.rs
│ └── config/
│ └── mod.rs # Per-app config loaders
├── iso/ # archiso profile
│ ├── profiledef.sh │ ├── profiledef.sh
│ ├── packages.x86_64 │ ├── packages.x86_64 # Live ISO + installed system package list
│ └── airootfs/ │ ├── airootfs/ # Files overlaid onto live ISO root
└── dotfiles/ # planned install-time configs — NOT the live skel │ │ └── etc/
│ │ ├── calamares/ # Calamares YAML configuration
│ │ └── skel/ # Default user dotfiles
└── dotfiles/ # Default configs deployed at install time
├── hyprland/ # hyprland.conf, keybinds, autostart
├── bread/ # breadd.toml, init.lua, devices.lua
├── breadbar/ # (no config needed; zero-config by default)
├── breadbox/ # config.toml with default context priorities
└── breadcrumbs/ # breadcrumbs.toml with default home profile
``` ```
--- ---
@ -66,11 +70,7 @@ bos/
Mount options: `noatime,compress=zstd,space_cache=v2` on all subvolumes. Mount options: `noatime,compress=zstd,space_cache=v2` on all subvolumes.
**A/B compatibility note (future):** The `@` subvolume is self-contained and **A/B compatibility note:** The `@` subvolume is self-contained and can be swapped atomically — this is the design property needed for a future A/B upgrade path. The layout does not need to change to adopt it.
could be swapped atomically. This is a design property for a later upgrade
path. It is **not** implemented. Recovery today is reboot into a grub-btrfs
snapshot; GRUB's `rootflags=subvol=@` means a raw `snapper rollback` is the
wrong instruction to give users.
### Snapshot tooling (installed + configured during post-install) ### Snapshot tooling (installed + configured during post-install)
@ -96,79 +96,100 @@ No user-facing CLI needed for this component — `bos-settings` is the interface
### archiso profile (`iso/`) ### archiso profile (`iso/`)
- Derives from `/usr/share/archiso/configs/releng/` (the standard baseline) - Derives from `/usr/share/archiso/configs/releng/` (the standard baseline)
- `packages.x86_64` is the live + installed pacman set (Hyprland, Calamares, - `packages.x86_64` includes: base, linux, grub, btrfs-progs, snapper, snap-pac, grub-btrfs, hyprland, pipewire, wireplumber, networkmanager, gtk4, gtk4-layer-shell, iw, librsvg, libpulse, bluez, bluez-utils, calamares, calamares-qt6
breadlock, WebKitGTK 4.1 for Tauri bos-settings, …). bakery apps are not - `airootfs/etc/skel/` contains the default dotfiles (symlinked from `dotfiles/`)
listed here.
- `airootfs/etc/skel/` contains the default user configs (this is the live
skel — not `dotfiles/`).
- Live session autologs into a `liveuser` and launches Calamares automatically - Live session autologs into a `liveuser` and launches Calamares automatically
### Calamares modules (in order) ### Calamares modules (in order)
The historical list below included a post-install `bakery install` and
Calamares `bootloader`/`grubcfg` installing GRUB. What shipped instead:
binaries are already in skel; `post-install.sh` runs `grub-install` +
`grub-mkconfig` (Calamares' bootloader modules leave the ESP empty here).
1. **welcome** — system checks (RAM ≥ 2GB, internet, disk space) 1. **welcome** — system checks (RAM ≥ 2GB, internet, disk space)
2. **locale** — timezone + locale selection 2. **locale** — timezone + locale selection
3. **keyboard** — layout selection 3. **keyboard** — layout selection
4. **partition** — custom `btrfs` mode: creates EFI partition + single btrfs pool with the subvolume layout above 4. **partition** — custom `btrfs` mode: creates EFI partition + single btrfs pool with the subvolume layout above
5. **users** — create main user, set password 5. **users** — create main user, set password
6. **packages** — install package list (reuses `packages.x86_64`) 6. **packages** — install package list (reuses `packages.x86_64`)
7. **bootloader***planned*; actual GRUB install is in `post-install.sh` 7. **bootloader** — install GRUB to EFI, `grub-mkconfig` with grub-btrfs hook
8. **shellprocess (post-install)** — snapper, services, copy skel; does **not** run bakery 8. **shellprocess (post-install)** — runs `iso/post-install.sh`:
- Configures snapper root config
- Enables services: `NetworkManager`, `bluetooth`, `breadd` (user), `breadbox-sync` (user)
- Runs `bakery install bread breadbar breadbox breadcrumbs breadpad` (or `bakery install --all`)
- Copies `dotfiles/` into `/home/$USER/.config/` (skips any file that already exists)
9. **finished** — reboot prompt 9. **finished** — reboot prompt
--- ---
## Component 3: `bos-settings` (planned as GTK4) ## Component 3: `bos-settings` GTK4 App
### Tech choices (original)
### Tech choices
- **gtk4-rs** (v0.11, v4_12 feature), no relm4 — plain GTK4 following breadman's pattern - **gtk4-rs** (v0.11, v4_12 feature), no relm4 — plain GTK4 following breadman's pattern
- **bread-theme** for palette + CSS (git dep: `github.com/Breadway/bread-ecosystem`)
**What shipped:** Tauri 2 + Svelte in its own repo - Reads/writes each tool's own config file directly (no unified intermediate config)
(`git.breadway.dev/Breadway/bos-settings`), distributed by bakery. This - Window: 960×640, sidebar 190px, `gtk4::Stack` for view switching — identical structure to breadman
repo does not build it.
### Sidebar sections + views ### Sidebar sections + views
The panel list is still roughly accurate; see README. Snapshots recovery | Section | View | What it does |
should send users through **grub-btrfs reboot**, not `snapper rollback N`. |---------|------|--------------|
| **Apps** | bread | Edit `~/.config/bread/breadd.toml` |
| | breadbar | Edit `~/.config/breadbar/` (style.css override, no TOML needed) |
| | breadbox | Edit `~/.config/breadbox/config.toml` (context priority lists) |
| | breadcrumbs | Edit `~/.config/breadcrumbs/breadcrumbs.toml` (profiles, networks) |
| | breadpad | Edit `~/.config/breadpad/breadpad.toml` (model, reminders, calendar) |
| **System** | Snapshots | `snapper list` output; rollback button calls `snapper rollback N` |
| | Packages | `bakery list --installed`; update buttons call `bakery update <pkg>` |
| | Hyprland | "Open config in editor" + monitor list from `bread.state.monitors()` |
### Config loading pattern
Each view has a dedicated `load_config(path) -> Result<T>` and `save_config(path, T) -> Result<()>` using `toml` crate. Config structs mirror each app's existing types (no duplication — import the `*-shared` crate where it exists, e.g. `breadpad-shared`). For apps without a shared crate (breadbox, breadcrumbs), define minimal local structs.
### Snapshots view specifics
- On open: runs `snapper list --output-cols number,date,description,pre-post` via `std::process::Command`, parses into table rows
- Rollback: confirmation dialog → `snapper rollback <N>` → notify user to reboot
- Delete: `snapper delete <N>`
- No write access to `/` needed for list/rollback since snapper is configured with `ALLOW_USERS` for the main user
### Packages view specifics
- On open: reads `~/.local/state/bakery/installed.json` directly (no network)
- "Check for updates": runs `bakery list` (triggers index refresh), compares versions
- "Update all": runs `bakery update --all` in a subprocess, streams stdout to a log TextView
### Distribution ### Distribution
`bos-settings` has its own `bakery.toml` and is installable via `bos-settings` gets a `bakery.toml` and is added to the `bread-ecosystem` registry — installable standalone on any Arch/Hyprland system via `bakery install bos-settings`, not only as part of a BOS install.
`bakery install bos-settings` on any Arch/Hyprland system, not only as part
of a BOS install.
--- ---
## Component 4: Default Dotfiles ## Component 4: Default Dotfiles
Minimal but functional defaults. These live in `iso/airootfs/etc/skel` Minimal but functional defaults deployed at install time. These are opinionated starting points, not locked configs — users edit freely after install.
(`hyprland.lua` + JSON binds, not `dotfiles/hyprland/*.conf`).
Zero-config bakery apps survive with no extra skel files. breadcrumbs | File | Key content |
networks are user-filled after install — do not invent a full |------|-------------|
`breadcrumbs.toml` in-tree. | `dotfiles/hyprland/hyprland.conf` | Monitor auto-detect, default keybinds, `exec-once` for breadd/breadbar/breadbox-sync |
| `dotfiles/hyprland/keybinds.conf` | `$mod+Space` → breadbox, `$mod+N` → breadpad, `$mod+M` → breadman, `$mod+S` → bos-settings |
| `dotfiles/bread/breadd.toml` | All adapters enabled, log_level=info |
| `dotfiles/bread/init.lua` | Minimal: activates "default" profile on startup |
| `dotfiles/breadbox/config.toml` | Single default context with common apps |
| `dotfiles/breadcrumbs/breadcrumbs.toml` | Placeholder home profile (user fills in SSIDs) |
--- ---
## Build Order ## Build Order
Historical. The ISO profile + skel + Calamares path is what this repo 1. **Dotfiles** — write default configs; these unblock installer testing immediately
iterates on. bos-settings is developed in its own repo. 2. **Btrfs + snapper config** — write `post-install.sh`; test in a VM with `archiso` livecdbase
3. **ISO profile** — archiso profiledef + package list + Calamares YAML; iterate in a VM
4. **bos-settings** — start with Snapshots and Packages views (highest value, no app-specific config parsing needed), then add per-app views one at a time
--- ---
## Verification ## Verification
- **ISO**: `sudo ./build-local.sh` (not a raw `mkarchiso iso/` — the bake - **ISO**: Build with `mkarchiso -v -w /tmp/bos-work -o /tmp/bos-out iso/`; boot in QEMU (`qemu-system-x86_64 -cdrom bos.iso -m 4G -enable-kvm`); complete install; reboot into installed system; confirm all services running and bakery packages present
step is required). Boot in QEMU; complete install; confirm bakery bins and
`~/.local/share/breadhelp/content`.
- **btrfs layout**: `btrfs subvolume list /` after install; confirm `@`, `@home`, `@snapshots`, `@log`, `@cache` exist - **btrfs layout**: `btrfs subvolume list /` after install; confirm `@`, `@home`, `@snapshots`, `@log`, `@cache` exist
- **snapper**: `snapper list`; run `pacman -Syu` and confirm two new snapshots appear - **snapper**: `snapper list`; run `pacman -Syu` and confirm two new snapshots appear
- **grub-btrfs**: Reboot and confirm snapshot submenu in GRUB - **grub-btrfs**: Reboot and confirm snapshot submenu in GRUB
- **bos-settings**: built and tested in the bos-settings repo, not here - **bos-settings**: `cargo build --release`; launch; confirm each view loads its config file; edit a value, save, re-open and confirm persistence; test rollback button in Snapshots view

282
README.md
View file

@ -5,138 +5,74 @@ ecosystem](https://git.breadway.dev/Breadway) preconfigured. One Calamares insta
produces a themed, bootable Wayland desktop — no manual Arch bootstrap, no produces a themed, bootable Wayland desktop — no manual Arch bootstrap, no
wiring up dotfiles, no per-tool bakery installs. wiring up dotfiles, no per-tool bakery installs.
> This file is the product as the tree ships it. [DESIGN.md](DESIGN.md) is the > Design rationale and the btrfs/A-B roadmap live in [DESIGN.md](DESIGN.md).
> original plan, kept as history — several of its sections (in-tree GTK > This file is the practical overview: what's in the image, how to build it,
> bos-settings, bakery-at-post-install, A/B as if it were current) are not > and how to test it.
> how the ISO works today.
## What you get ## What you get
- **Compositor**: Hyprland with a native-Lua config (`hyprland.lua`), curated - **Compositor**: Hyprland with a native-Lua config (`hyprland.lua`), curated
keybinds, snappy animations, blur, and pywal-driven colours on a black base. keybinds, snappy animations, blur, and pywal-driven colours on a black base.
- **bread ecosystem**, baked into `/usr/local` from bakery-managed binaries - **bread ecosystem**, baked into `/etc/skel` from bakery-managed binaries
(no network needed at install time; per-user bakery state is seeded in (no network needed at install time): the `bread`/`breadd` automation daemon,
`/etc/skel`): the `bread`/`breadd` automation daemon `breadbar` (status bar + notifications), `breadbox` (launcher), `breadclip`
(`bread-emit` / `bread-module-host` when the stable bread release publishes (clipboard history), `breadcrumbs` (Wi-Fi profiles), `breadpad`/`breadman`
them), `breadbar` (status bar + notifications), `breadbox` (launcher), (notes), `breadpaper` (wallpaper + theme), `breadsearch` (system search),
`breadclip` (clipboard history), `breadcrumbs` (Wi-Fi profiles), `breadmon` (monitor layout TUI), `breadshot` (screenshots), `bread-theme`
`breadpad`/`breadman` (notes), `breadpaper` (wallpaper + theme), (the shared palette engine), and the `bakery` package manager. `breadlock`
`breadsearch` (system search), `breadmon` (monitor layout TUI), (lock screen + greeter) ships as its own pacman package alongside
`breadshot` (screenshots), `bread-theme` (the shared palette engine), `bos-settings`, not through bakery. See [below](#the-bread-ecosystem) for
`breadhelp` (onboarding + cheatsheet), `bos-settings` (control panel), what each one actually does.
and the `bakery` package manager. Most of those apps are zero-config on - **bos-settings**: a GTK4 control panel that configures every bread\* app's
first boot; breadcrumbs networks are user-filled after install. See config from a GUI (non-destructively), plus snapshot rollback and bakery
[below](#the-bread-ecosystem). updates. See below.
- **breadlock** (lock screen + greeter) is the one bread\* app that ships as - **Login**: greetd + breadgreet (bread-ecosystem's own greeter, under `cage`)
**pacman**, not bakery — it needs a root-owned PAM service. → Hyprland session.
- **bos-settings**: a **Tauri 2 + Svelte** control panel (standalone bakery
product, not a member of this repo). Configures every bread\* app
non-destructively, plus snapshots, bakery/pacman updates, and day-to-day
machine administration.
- **Login**: greetd + breadgreet (under `cage`) → Hyprland session.
- **Boot splash**: Plymouth `bos` theme (logo + spinner, black background). - **Boot splash**: Plymouth `bos` theme (logo + spinner, black background).
- **Theming**: global dark across GTK3 (Adwaita-dark), GTK4/libadwaita - **Theming**: global dark across GTK3 (Adwaita-dark), GTK4/libadwaita
(`color-scheme: prefer-dark`), and Qt (qt5ct/qt6ct Fusion dark); Papirus-Dark (`color-scheme: prefer-dark`), and Qt (qt5ct/qt6ct Fusion dark); Papirus-Dark
icons; Bibata cursor. icons; Bibata cursor.
- **Apps**: kitty, nautilus (+ gvfs), Zen browser, VLC, loupe, gnome-text-editor, - **Apps**: kitty, nautilus (+ gvfs), Zen browser, VLC, loupe, gnome-text-editor,
gnome-calculator, file-roller, with file associations wired in `mimeapps.list`. gnome-calculator, file-roller, with file associations wired in `mimeapps.list`.
`yay` ships for AUR access beyond bakery + `[breadway]`. `yay` ships for AUR access beyond bakery's bread ecosystem + `[breadway]`.
- **Hardware**: pipewire audio, NetworkManager, BlueZ + blueman, CUPS printing - **Hardware**: pipewire audio, NetworkManager, BlueZ + blueman, CUPS printing
with avahi mDNS discovery, TLP power management, fwupd firmware updates. with avahi mDNS discovery, TLP power management, fwupd firmware updates.
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 - **Resilience**: btrfs + snapper + snap-pac + grub-btrfs snapshots on every
pacman transaction (**root `@` only** — snapper does not cover `@home`); pacman transaction; zram swap; ufw firewall (deny-incoming, mDNS allowed).
home backup is **Settings → Backup** (restic, local path or SFTP); zram - **Security**: full-disk encryption (LUKS, via Calamares' built-in support —
swap; ufw firewall (deny-incoming, mDNS allowed). A/B root swapping is cryptsetup + the matching mkinitcpio/GRUB wiring ship so an encrypted
**not** implemented. Recovery is a grub-btrfs reboot, not install actually boots) and self-signed Secure Boot (via `sbctl`, enrolled
`snapper rollback` (GRUB pins `rootflags=subvol=@`). See automatically at install time when the firmware is in Setup Mode).
[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
`sbctl` — not a Microsoft-signed shim; enrollment is skipped unless the
firmware is already in Setup Mode.
## What ships vs what does not
| 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, 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` |
The baked name list is [`iso/bread-lockfile.toml`](iso/bread-lockfile.toml)
(plus optional `[versions]` / `[[pin]]` so CI fetches
`https://dl.breadway.dev/<pkg>/<ver>/...`). `build-local.sh` fails if any
**required** binary is missing on the builder.
## Repo layout ## Repo layout
This is an **ISO + Calamares + skel** repo. There is no Cargo workspace and
no `bos-settings/` member — bos-settings and breadhelp live in their own
repos and arrive via bakery.
``` ```
bos/ bos/
├── Cargo.toml # workspace (members: bos-settings)
├── bos-settings/ # GTK4 unified settings app (Rust)
│ └── src/
│ ├── config/mod.rs # non-destructive toml_edit config layer
│ └── ui/{widgets,window,sidebar}.rs, ui/views/*.rs
├── iso/ # archiso profile ├── iso/ # archiso profile
│ ├── bread-lockfile.toml # bakery bins + optional version pins
│ ├── profiledef.sh │ ├── profiledef.sh
│ ├── packages.x86_64 # live + installed pacman set │ ├── packages.x86_64 # live + installed package set
│ └── airootfs/ # files overlaid onto the image │ └── airootfs/ # files overlaid onto the image
│ └── etc/ │ └── etc/
│ ├── skel/ # live user defaults (hypr, kitty, gtk, …) │ ├── skel/ # default user dotfiles (hypr, kitty, gtk, …)
│ └── calamares/ # installer config + post-install.sh │ └── calamares/ # installer config + post-install.sh
├── packaging/ # in-house PKGBUILDs for AUR-only deps ├── packaging/ # in-house PKGBUILDs for AUR-only deps
│ ├── arch/ # bos-settings
│ ├── calamares/ │ ├── calamares/
│ ├── bibata/ │ └── bibata/
│ ├── powerlevel10k/ ├── .forgejo/workflows/ # CI: build + publish packages to [breadway]
│ └── yay-bin/
├── dotfiles/ # STALE — not the live skel; see its README
├── 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
├── build-local.sh # native ISO build for this machine ├── build-local.sh # native ISO build for this machine
├── README.md └── DESIGN.md
└── DESIGN.md # historical plan
``` ```
Live binds are `iso/airootfs/etc/skel/.config/hypr/binds.json` (`Super+L`
`loginctl lock-session`, breadshot on `Super+Shift+S/C/P`, `Super+U`
breadpad). Do not treat `dotfiles/hypr/keybinds.conf` as current.
## Branches and remotes
Single-trunk: work on **`main`** via short-lived `feature/*` / `fix/*`
branches. **`stable`** is a marker branch CI fast-forwards to the latest
non-RC release tag — do not land work there by hand.
Dual remotes:
- **`origin`** — Forgejo (`ssh://git@100.66.238.26:2222/Breadway/bos.git`),
authoritative
- **`github`** — GitHub (`https://github.com/Breadway/bos.git`) mirror
Push `origin` (and `github` when mirroring). Do not treat origin as GitHub.
## Building the ISO ## Building the ISO
`build-local.sh` builds the image natively (no container) and copies this `build-local.sh` builds the image natively (no container) and bakes this
machine's bakery-installed bread binaries + breadhelp content from the machine's bakery-installed bread binaries into `/etc/skel`:
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`.
```sh ```sh
sudo ./build-local.sh # release-quality (xz squashfs) sudo ./build-local.sh # release-quality (xz squashfs)
@ -144,24 +80,16 @@ sudo FAST_BUILD=1 ./build-local.sh # fast dev iteration (zstd squashfs)
``` ```
The ISO lands in `out/bos-<date>-x86_64.iso`. The script pins The ISO lands in `out/bos-<date>-x86_64.iso`. The script pins
`SOURCE_DATE_EPOCH` (reproducible UUIDs), rewrites the `[breadway]` repo URL `SOURCE_DATE_EPOCH` (reproducible UUIDs) and rewrites the `[breadway]` repo URL
to the Tailscale-reachable Forgejo registry for the build, and **exits to the Tailscale-reachable Forgejo registry for the build.
non-zero** if any **required** lockfile binary (or breadhelp content) is
missing. Optional bins are skipped with a warning.
CI stages the builder from the **minisign-verified** stable bakery index
(`index.json` + `index.json.minisig`) and prefers lockfile `[versions]`
URLs (`https://dl.breadway.dev/<pkg>/<ver>/...`) when set, so two bakes
of the same commit fetch the same bits. Local builds still snapshot the
builder.
### Why some packages are in-house ### Why some packages are in-house
`calamares`, `zen-browser-bin`, `bibata-cursor-theme`, and `yay-bin` are `calamares`, `zen-browser-bin`, `bibata-cursor-theme`, and `yay-bin` are
AUR-only. BOS keeps a PKGBUILD for each under `packaging/` and republishes AUR-only. BOS keeps a PKGBUILD for each under `packaging/` and republishes the
the built package to the `[breadway]` repo via a Forgejo Actions workflow built package to the `[breadway]` repo via a Forgejo Actions workflow (built
(built on the hestia self-hosted runner, published with a scoped registry on the hestia self-hosted runner, published with a scoped registry token).
token). `[breadway]` is **not** where bakery/breadbar/bos-settings live. `bos-settings` itself publishes the same way on a `v*` tag.
### Verifying a release ### Verifying a release
@ -174,11 +102,7 @@ dedicated release-signing key (not reused from anything else):
5620 3B86 A110 695A E7F3 1093 4AF3 323D 678E B5E2 5620 3B86 A110 695A E7F3 1093 4AF3 323D 678E B5E2
``` ```
The public half is committed at [`KEYS.asc`](KEYS.asc). The same key signs The public half is committed at [`KEYS.asc`](KEYS.asc). To verify a download:
the ISO checksums **and** the `[breadway]` pacman repo — every package and
the db at `https://dl.breadway.dev/arch` carry a `.sig` from it, and that
section is `SigLevel = Required` (see
[docs/signed-repo.md](docs/signed-repo.md)). To verify a download:
```sh ```sh
gpg --import KEYS.asc gpg --import KEYS.asc
@ -200,51 +124,19 @@ It uses KVM + `-cpu host`, 8 GiB / 8 vCPU, and `virtio-vga-gl` with
Hyprland session in QEMU. The disk lives on NVMe (not the tmpfs `/tmp`) to Hyprland session in QEMU. The disk lives on NVMe (not the tmpfs `/tmp`) to
avoid memory pressure. 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.
## bos-settings ## bos-settings
Standalone bakery product: **Tauri 2 + Svelte**, not GTK4, and not built A GTK4 settings app aiming for GNOME-Settings-style parity: not just editing
from this repo. Install/update with `bakery`; the ISO just bakes whatever config files, but live system state and control, so day-to-day machine
binary the builder has. administration doesn't require a terminal.
It aims for GNOME-Settings-style parity: live system state and control, so Bread-ecosystem TOML configs are edited **non-destructively**: `toml_edit`
day-to-day administration doesn't require a terminal. Bread-ecosystem parses the file, changes only the keys a view exposes, and writes it back —
configs are edited **non-destructively** (comments and unmodeled keys stay). preserving comments and any keys the UI doesn't model (calendar passwords,
Panels with a daemon (bread, breadbox, breadcrumbs, breadsearch, breadclip) saved-network passwords, model paths). Panels with a daemon behind them
also get live systemd status + Start/Stop/Restart/Logs. (bread, breadbox, breadcrumbs, breadsearch, breadclip) also get live
systemd status + Start/Stop/Restart/Logs via a shared `service_control`
widget, not just the config file.
| Panel | What it does | | Panel | What it does |
|-------|--------------| |-------|--------------|
@ -267,29 +159,32 @@ also get live systemd status + Start/Stop/Restart/Logs.
| Packages | `bakery` installed list + updates, pacman system update | | Packages | `bakery` installed list + updates, pacman system update |
| AUR | Search via `yay`; installing opens a terminal (AUR build scripts need review) | | AUR | Search via `yay`; installing opens a terminal (AUR build scripts need review) |
| Firmware | `fwupd` device list + updates | | Firmware | `fwupd` device list + updates |
| Snapshots | `snapper` list (number / date / description); reboot to pick in GRUB (grub-btrfs); delete — **root (`@`) only** | | Snapshots | `snapper` list / boot-into (grub-btrfs) / delete |
| Backup | restic of `$HOME` (`@home`) via Settings → Backup; snapper does not cover home |
Source and build live in the [bos-settings](https://git.breadway.dev/Breadway/bos-settings) Build standalone:
repo, not here.
```sh
cargo build --release -p bos-settings
cargo test -p bos-settings # includes config round-trip tests
```
## The bread ecosystem ## The bread ecosystem
Everything below is a separate bakery-distributed project with its own repo 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 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 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 corresponding **bos-settings** panel for configuration; this table is about
directly. *using* the app directly.
**Desktop shell** **Desktop shell**
| Tool | Role | Launch | | Tool | Role | Launch |
|------|------|--------| |------|------|--------|
| `bread` / `breadd` | Reactive automation daemon — normalises hardware/compositor/power/network signals into events dispatched to Lua modules (`~/.config/bread/`). `bread-emit` is the fire-and-forget helper hooks/CLIs use; `bread-module-host` is the sandboxed out-of-process module runtime breadd spawns. Both extra bins are **optional** on the ISO until a stable bread release publishes them. | runs at login (`breadd.service`) | | `bread` / `breadd` | Reactive automation daemon — normalises hardware/compositor/power/network signals into events dispatched to Lua modules (`~/.config/bread/`). Everything else in the ecosystem can subscribe to its events. | runs at login (`breadd.service`) |
| `breadbar` | Top status bar: workspaces, clock, system stats, tray, **and** the notification daemon — one process, not two | runs at login | | `breadbar` | Top status bar: workspaces, clock, system stats, tray, **and** the notification daemon — one process, not two | runs at login |
| `breadbox` | Application launcher (fuzzy search, per-context results via `breadbox-sync`) | `SUPER+Space` | | `breadbox` | Application launcher (fuzzy search, per-context results via `breadbox-sync`) | `SUPER+Space` |
| `breadlock` | Idle lock screen. Also provides `breadgreet`, the login greeter hosted under `cage` via greetd — same project, two binaries, one visual identity from login to lock. **pacman**, not bakery. | `SUPER+L` (via `loginctl lock-session`, picked up by `hypridle`); `breadgreet` runs automatically at boot | | `breadlock` | Idle lock screen. Also provides `breadgreet`, the login greeter hosted under `cage` via greetd — same project, two binaries, one visual identity from login to lock | `SUPER+L` (via `loginctl lock-session`, picked up by `hypridle`); `breadgreet` runs automatically at boot |
| `bread-theme` | The shared palette engine every bread app renders through: fixed dark base colors, with only the accent slots following the current wallpaper's pywal palette. `bread-theme generate` regenerates the stylesheet; hyprland.lua calls it automatically on wallpaper change. | invoked automatically, rarely run by hand | | `bread-theme` | The shared palette engine every bread app renders through: fixed dark base colors, with only the accent slots following the current wallpaper's pywal palette. `bread-theme generate` regenerates the stylesheet; hyprland.lua calls it automatically on wallpaper change. | invoked automatically, rarely run by hand |
**Productivity** **Productivity**
@ -300,7 +195,6 @@ directly.
| `breadman` | The fuller notes manager view (browse/organize) — ships from the same `breadpad` package as a second binary | `SUPER+M` | | `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` | | `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 | | `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+/` |
**System** **System**
@ -316,7 +210,7 @@ directly.
| Tool | Role | Launch | | Tool | Role | Launch |
|------|------|--------| |------|------|--------|
| `bakery` | CLI package manager for the whole ecosystem — install/update/list, tracks installed binaries + versions independently of pacman | `bakery` | | `bakery` | CLI package manager for the whole ecosystem — install/update/list, tracks installed binaries + versions independently of pacman | `bakery` |
| `bos-settings` | Unified Tauri 2 + Svelte control panel: live system state + control (network, power, firewall, users, packages, firmware, AUR, snapshots) plus non-destructive config editing for every app above | `SUPER+,` | | `bos-settings` | Unified GTK4 control panel: live system state + control (network, power, firewall, users, packages, firmware, AUR, snapshots) plus non-destructive config editing for every app above | `SUPER+,` |
## Keyboard shortcuts ## Keyboard shortcuts
@ -354,13 +248,9 @@ cheatsheet in-session; first boot shows a short welcome (once).
## Known limitations ## Known limitations
See [docs/hardware.md](docs/hardware.md) (GPUs, NVIDIA, recovery) and
[docs/signed-repo.md](docs/signed-repo.md) (`[breadway]` stays unsigned
until `dl.breadway.dev/arch` exists).
- **GPUs**: ships the generic Mesa stack — AMD and Intel work out of the box. - **GPUs**: ships the generic Mesa stack — AMD and Intel work out of the box.
NVIDIA is **unsupported** (no proprietary driver, no NVIDIA firmware). See The **NVIDIA proprietary driver is not included**; NVIDIA users must install
[docs/hardware.md](docs/hardware.md). `nvidia`/`nvidia-utils` and set the usual Hyprland env vars after install.
- **Virtual machines**: Hyprland needs GPU acceleration to be smooth. Use - **Virtual machines**: Hyprland needs GPU acceleration to be smooth. Use
`virtio-vga-gl` + `-display gtk,gl=on` (virgl); plain software rendering is `virtio-vga-gl` + `-display gtk,gl=on` (virgl); plain software rendering is
noticeably laggy. noticeably laggy.
@ -379,42 +269,26 @@ until `dl.breadway.dev/arch` exists).
BOS ships the matching `cryptsetup`/mkinitcpio/GRUB wiring so an encrypted BOS ships the matching `cryptsetup`/mkinitcpio/GRUB wiring so an encrypted
install actually boots (LUKS1, since GRUB doesn't support LUKS2 + Argon2id). install actually boots (LUKS1, since GRUB doesn't support LUKS2 + Argon2id).
- **Snapshots assume btrfs**: the snapper/grub-btrfs tooling expects the default - **Snapshots assume btrfs**: the snapper/grub-btrfs tooling expects the default
btrfs subvolume layout the installer creates. Recovery is the GRUB btrfs subvolume layout the installer creates.
snapshots submenu, not `snapper rollback` — [docs/hardware.md](docs/hardware.md).
- **`[breadway]` signatures**: `SigLevel = Required` — the signed repo at
`dl.breadway.dev/arch` is live (db + every package `.sig`ned with the BOS
release key). See [docs/signed-repo.md](docs/signed-repo.md).
## Recovery ## Recovery
**An update broke something (system still boots):** reboot → **GRUB **An update broke something (system still boots):** open BOS Settings →
“snapshots” submenu** (grub-btrfs), then boot that entry. Snapshots and roll back, or pick a pre-update snapshot from the **GRUB
“snapshots” submenu** at boot, then run `snapper rollback` from the booted
BOS Settings → Snapshots lists each snapshots number, date, and snapshot.
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).
A/B root swapping (SteamOS-style) is a **future** idea in DESIGN.md — it is
not shipped.
**The system won't boot (broken GRUB / lost EFI entry):** **The system won't boot (broken GRUB / lost EFI entry):**
1. Boot the BOS ISO and open a terminal (`SUPER+Return`). 1. Boot the BOS ISO and open a terminal (`SUPER+Return`).
2. Run `sudo bos-rescue`. It finds the installed btrfs `@` and the ESP, 2. Mount the installed root and EFI, then chroot:
prints the devices it will use, and asks `YES` before writing. It can
`arch-chroot` and/or reinstall GRUB with the same sequence the
installer uses (NVRAM + `--removable` + `grub-mkconfig`).
3. Manual equivalent, if you would rather type it:
```sh ```sh
mount -o subvol=@ /dev/sdXN /mnt mount -o subvol=@ /dev/sdXN /mnt
mount /dev/sdXP /mnt/boot/efi # the EFI partition mount /dev/sdXP /mnt/boot/efi # the EFI partition
arch-chroot /mnt arch-chroot /mnt
```
3. Reinstall the bootloader (the same sequence the installer uses):
```sh
grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=BOS --recheck grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=BOS --recheck
grub-install --target=x86_64-efi --efi-directory=/boot/efi --removable --recheck grub-install --target=x86_64-efi --efi-directory=/boot/efi --removable --recheck
grub-mkconfig -o /boot/grub/grub.cfg grub-mkconfig -o /boot/grub/grub.cfg

View file

@ -25,19 +25,14 @@ OUT="${OUT:-$REPO/out}"
STAGE=/tmp/bos-iso-stage STAGE=/tmp/bos-iso-stage
rm -rf "$STAGE" && cp -a "$REPO/iso" "$STAGE" rm -rf "$STAGE" && cp -a "$REPO/iso" "$STAGE"
# [breadway] now points at the signed public repo https://dl.breadway.dev/arch # Rewrite the [breadway] pacman repo URL to the fastest reachable address.
# (SigLevel = Required) — no Forgejo-registry URL rewrite needed anymore. # CI_BUILD=1 — container runs on hestia with --network=host; localhost:3002 is direct
# # default — building on hermes; git.breadway.dev is flaky from there, use Tailscale
# Trust the [breadway] repo key in *this* build host's pacman keyring so # Only ever rewrites the staged copy, never the committed pacman.conf.
# `pacstrap` can verify [breadway] packages while assembling the airootfs. if [ "${CI_BUILD:-0}" = "1" ]; then
# The same key is baked into the image at etc/pacman.d/breadway-repo.asc and sed -i 's#https://git.breadway.dev/api/packages/Breadway/arch/os#http://localhost:3002/api/packages/Breadway/arch/os#' "$STAGE/pacman.conf"
# re-trusted on the live medium / installed target (calamares/post-install.sh). else
BREADWAY_KEY_FPR="56203B86A110695AE7F310934AF3323D678EB5E2" sed -i 's#https://git.breadway.dev/api/packages/Breadway/arch/os#http://100.66.238.26:3002/api/packages/Breadway/arch/os#' "$STAGE/pacman.conf"
BREADWAY_KEY_SRC="$REPO/iso/airootfs/etc/pacman.d/breadway-repo.asc"
if ! pacman-key --list-keys "$BREADWAY_KEY_FPR" &>/dev/null; then
echo "=== trusting [breadway] repo key ($BREADWAY_KEY_FPR) in the host pacman keyring ==="
pacman-key --add "$BREADWAY_KEY_SRC"
pacman-key --lsign-key "$BREADWAY_KEY_FPR"
fi fi
if [ "${FAST_BUILD:-0}" = "1" ]; then if [ "${FAST_BUILD:-0}" = "1" ]; then
@ -46,402 +41,89 @@ if [ "${FAST_BUILD:-0}" = "1" ]; then
fi fi
grep airootfs_image_tool_options "$STAGE/profiledef.sh" grep airootfs_image_tool_options "$STAGE/profiledef.sh"
# --- Bake this machine's bakery-installed bread ecosystem into the image ------ # --- Bake this laptop's bakery-installed bread ecosystem into /etc/skel -------
# The bread desktop apps are bakery-managed (release binaries from # The bread apps are managed by bakery (which fetches release binaries from
# dl.breadway.dev / GitHub), not pacman. bakery needs DNS at install time, # GitHub), not pacman. bakery needs DNS at install time, which the live/installed
# which the live/installed image doesn't have — so instead of running bakery # image doesn't have — so instead of running bakery on the target, we copy the
# on the target, we copy the binaries + bakery manifest this builder already # exact binaries + bakery manifest this laptop already has into skel. Every user
# has. Builder home stays user-layout (~/.local); the *image* is system-prefix # created from skel (the live user and the installed user) then gets the same
# /usr/local so apps live on @ and ride snapper/grub-btrfs snapshots. # versions `bakery list` reports here, fully offline. Copied at build time so the
# installed.json + index cache stay per-user in skel. Copied at build time # binaries never bloat the git repo and always track the current bakery state.
# so the binaries never bloat the git repo. BREAD_BINS=(bakery bread breadd breadman breadbar breadbox breadbox-sync breadcrumbs breadpad breadpaper bread-theme breadmon breadsearch breadmill breadclip breadclipd breadshot)
#
# CI should prefer the stable bakery index when populating the builder home.
# Local builds still snapshot the builder. required_bins fail the bake if
# missing; optional_bins are skipped with a warning (a hollow ISO is worse
# than a failed build). A flat `bins` list is treated as all-required.
LOCKFILE="$REPO/iso/bread-lockfile.toml"
if [[ ! -f "$LOCKFILE" ]]; then
echo "ERROR: bakery lockfile missing: $LOCKFILE" >&2
exit 1
fi
eval "$(python3 - "$LOCKFILE" <<'PY'
import sys, tomllib
path = sys.argv[1]
with open(path, "rb") as f:
data = tomllib.load(f)
required = data.get("required_bins")
optional = data.get("optional_bins") or []
if required is None:
required = data.get("bins") or data.get("binaries")
if not isinstance(required, list) or not required:
sys.exit(f"{path}: missing non-empty required_bins (or bins) list")
if not isinstance(optional, list):
sys.exit(f"{path}: optional_bins must be a list")
blocked = {"breadcast", "breadarr"}
for label, names in (("required_bins", required), ("optional_bins", optional)):
for b in names:
if not isinstance(b, str) or not b or "/" in b or b in (".", ".."):
sys.exit(f"{path}: invalid {label} name {b!r}")
if b in blocked:
sys.exit(f"{path}: {b} is not shipped on the ISO")
def emit(name, values):
print(f"{name}=(")
for v in values:
print(f" {v!r}")
print(")")
emit("REQUIRED_BINS", required)
emit("OPTIONAL_BINS", optional)
PY
)"
if [[ ${#REQUIRED_BINS[@]} -eq 0 ]]; then
echo "ERROR: $LOCKFILE produced an empty required bins list" >&2
exit 1
fi
LAPTOP_HOME="${LAPTOP_HOME:-$(getent passwd "${SUDO_USER:-$USER}" | cut -d: -f6)}" LAPTOP_HOME="${LAPTOP_HOME:-$(getent passwd "${SUDO_USER:-$USER}" | cut -d: -f6)}"
BAKERY_BIN="$LAPTOP_HOME/.local/bin" BAKERY_BIN="$LAPTOP_HOME/.local/bin"
BAKERY_STATE="$LAPTOP_HOME/.local/state/bakery" BAKERY_STATE="$LAPTOP_HOME/.local/state/bakery"
BAKERY_CACHE="$LAPTOP_HOME/.cache/bakery" BAKERY_CACHE="$LAPTOP_HOME/.cache/bakery"
BAKERY_SHARE="$LAPTOP_HOME/.local/share" SKEL="$STAGE/airootfs/etc/skel"
AIROOTFS="$STAGE/airootfs"
IMAGE_BIN="$AIROOTFS/usr/local/bin"
IMAGE_SHARE="$AIROOTFS/usr/local/share"
IMAGE_UNITS="$AIROOTFS/usr/lib/systemd/user"
SKEL="$AIROOTFS/etc/skel"
echo "=== baking bakery bread ecosystem from $LAPTOP_HOME ===" echo "=== baking bakery bread ecosystem from $LAPTOP_HOME ==="
echo "lockfile: $LOCKFILE (${#REQUIRED_BINS[@]} required, ${#OPTIONAL_BINS[@]} optional)" install -d -m 0755 "$SKEL/.local/bin" "$SKEL/.local/state/bakery" "$SKEL/.cache/bakery"
echo "image prefix: /usr/local (bins $IMAGE_BIN, share $IMAGE_SHARE, units $IMAGE_UNITS)"
missing=()
for b in "${REQUIRED_BINS[@]}"; do
if [[ ! -x "$BAKERY_BIN/$b" ]]; then
missing+=("$BAKERY_BIN/$b")
fi
done
if [[ ${#missing[@]} -gt 0 ]]; then
echo "ERROR: bakery lockfile requires binaries that are missing on the builder:" >&2
printf ' %s\n' "${missing[@]}" >&2
echo "Install them with bakery (or stage them under $BAKERY_BIN) before baking." >&2
echo "A hollow ISO is worse than a failed build." >&2
exit 1
fi
BREAD_BINS=("${REQUIRED_BINS[@]}")
for b in "${OPTIONAL_BINS[@]}"; do
if [[ -x "$BAKERY_BIN/$b" ]]; then
BREAD_BINS+=("$b")
else
echo "WARN: optional lockfile bin missing, skipping: $BAKERY_BIN/$b" >&2
fi
done
install -d -m 0755 "$IMAGE_BIN" "$SKEL/.local/state/bakery" "$SKEL/.cache/bakery"
for b in "${BREAD_BINS[@]}"; do 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 done
install -m 0644 "$BAKERY_STATE/installed.json" "$SKEL/.local/state/bakery/installed.json"
# Drop packages that are not in the lockfile (breadcast/breadarr must not
# appear installed when their binaries were deliberately left out).
python3 - "$BAKERY_STATE/installed.json" "$SKEL/.local/state/bakery/installed.json" "${BREAD_BINS[@]}" <<'PY'
import json, sys
src, dest, *bins = sys.argv[1:]
wanted = set(bins)
with open(src) as f:
data = json.load(f)
pkgs = data.get("packages", data)
if not isinstance(pkgs, dict):
sys.exit(f"{src}: expected packages object")
kept = {}
for name, pkg in pkgs.items():
pbins = pkg.get("binaries") or []
if name in wanted or any(b in wanted for b in pbins):
kept[name] = pkg
out = {"packages": kept}
if "track" in data:
out["track"] = data["track"]
with open(dest, "w") as f:
json.dump(out, f, indent=2)
f.write("\n")
print("installed.json packages:", ", ".join(sorted(kept)) or "(none)")
PY
# bakery fetches its package index from dl.breadway.dev (then a GitHub fallback), # bakery fetches its package index from dl.breadway.dev (then a GitHub fallback),
# but falls back to a cached index when both are unreachable. With no network/DNS # but falls back to a cached index when both are unreachable. With no network/DNS
# in the live/installed image, even `bakery list` errors unless that cache exists, # in the live/installed image, even `bakery list` errors unless that cache exists,
# so bake it in too — then bakery works fully offline (list/info from cache; # so bake it in too — then bakery works fully offline (list/info from cache;
# install/update still need network, as expected). # install/update still need network, as expected).
if [[ ! -f "$BAKERY_CACHE/index.json" ]]; then
echo "ERROR: bakery index cache missing: $BAKERY_CACHE/index.json" >&2
exit 1
fi
install -m 0644 "$BAKERY_CACHE/index.json" "$SKEL/.cache/bakery/index.json" install -m 0644 "$BAKERY_CACHE/index.json" "$SKEL/.cache/bakery/index.json"
echo "baked bins: $(ls "$IMAGE_BIN")" echo "baked: $(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 ==="
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 "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"
python3 - "$BAKERY_CACHE/index.json" "$BAKERY_SHARE" "$IMAGE_SHARE" "${BREAD_BINS[@]}" <<'PY'
import json, os, shutil, sys
index_path, src_share, dest_share, *bins = sys.argv[1:]
wanted = set(bins)
try:
with open(index_path) as f:
idx = json.load(f)
packages = idx.get("packages", {})
except (OSError, json.JSONDecodeError):
packages = {}
# Package names we ship: lockfile bin names plus index packages that
# publish at least one of those bins.
pkg_names = set(wanted)
for name, pkg in packages.items():
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):
pkg_names.add(name)
os.makedirs(os.path.join(dest_share, "applications"), exist_ok=True)
os.makedirs(os.path.join(dest_share, "licenses"), exist_ok=True)
for name in sorted(pkg_names):
pkg = packages.get(name) or {}
if pkg.get("data_archive"):
src = os.path.join(src_share, name)
dest = os.path.join(dest_share, name)
if name == "breadhelp":
continue # already copied above, required
if os.path.isdir(src):
if os.path.exists(dest):
shutil.rmtree(dest)
shutil.copytree(src, dest, symlinks=True)
print(f" baked data dir {dest}")
else:
sys.exit(f"ERROR: bakery data_archive for {name} missing at {src}")
desktop_src = os.path.join(src_share, "applications", f"{name}.desktop")
desktop_dest = os.path.join(dest_share, "applications", f"{name}.desktop")
if os.path.isfile(desktop_src) and not os.path.isfile(desktop_dest):
shutil.copy2(desktop_src, desktop_dest)
print(f" baked desktop {desktop_dest}")
lic_src = os.path.join(src_share, "licenses", name)
lic_dest = os.path.join(dest_share, "licenses", name)
if os.path.isdir(lic_src) and not os.path.isdir(lic_dest):
shutil.copytree(lic_src, lic_dest, symlinks=True)
print(f" baked license {lic_dest}")
PY
# --- Bake systemd user services for bakery-managed bread packages ----------- # --- Bake systemd user services for bakery-managed bread packages -----------
# Historically only breadd.service was hand-committed to skel; every other # Historically only breadd.service was hand-committed to skel; every other
# bakery package's service (breadbox-sync, breadmill, breadclipd, ...) was # bakery package's service (breadbox-sync, breadmill, breadclipd, ...) was
# silently left out, so those daemons never start on a fresh install/live # silently left out, so those daemons never start on a fresh install/live
# boot until the user re-runs `bakery install` (which needs network). # boot until the user re-runs `bakery install` (which needs network).
# Units come from installed.json + the bakery index + local unit files # Generalize from the same source of truth as the binary bake above: read
# whose ExecStart is a lockfile binary (installed.json has omitted # the services this laptop's bakery actually installed, copy each unit file
# breadcrumbs.service before). Units go to /usr/lib/systemd/user with # into skel with ExecStart rewritten from this laptop's literal home path to
# ExecStart rewritten to /usr/local/bin. Recreate whichever # the portable `%h` specifier, and recreate whichever *.target.wants enable
# *.target.wants enable symlink bakery created locally (or that skel # symlink bakery created locally. Units already committed by hand (breadd.service
# already ships), and write /etc/systemd/user/*.wants/ (--global). # carries a RuntimeDirectoryPreserve=yes fix not yet upstreamed — see bread-release-build
# Hand-committed skel units (breadd.service carries a # notes) are left alone rather than overwritten.
# RuntimeDirectoryPreserve=yes fix not yet upstreamed) are the source echo "=== baking bakery service units into skel ==="
# for that unit and also get their ExecStart rewritten in skel.
echo "=== baking bakery service units into /usr/lib/systemd/user ==="
SYSTEMD_USER_DIR="$LAPTOP_HOME/.config/systemd/user" SYSTEMD_USER_DIR="$LAPTOP_HOME/.config/systemd/user"
SKEL_SYSTEMD="$SKEL/.config/systemd/user" SKEL_SYSTEMD="$SKEL/.config/systemd/user"
install -d -m 0755 "$IMAGE_UNITS" mapfile -t SERVICE_UNITS < <(python3 -c "
# installed.json on the builder can omit a service even when the index and import json
# the local unit file exist (breadcrumbs has done this). Merge all three with open('$BAKERY_STATE/installed.json') as f:
# so every lockfile daemon is baked and can be --global enabled. d = json.load(f)
mapfile -t SERVICE_UNITS < <(python3 - \ for pkg in d.get('packages', d).values():
"$SKEL/.local/state/bakery/installed.json" \ for s in pkg.get('services', []):
"$BAKERY_CACHE/index.json" \ print(s)
"$SYSTEMD_USER_DIR" \ ")
"${BREAD_BINS[@]}" <<'PY'
import json, os, sys
installed_path, index_path, user_dir, *bins = sys.argv[1:]
wanted = set(bins)
units = set()
def add_svc(svc):
name = svc["unit"] if isinstance(svc, dict) else svc
if not name or str(name).startswith(("breadcast", "breadarr")):
return
units.add(str(name))
if os.path.isfile(installed_path):
with open(installed_path) as f:
data = json.load(f)
for pkg in data.get("packages", data).values():
if isinstance(pkg, dict):
for svc in pkg.get("services") or []:
add_svc(svc)
if os.path.isfile(index_path):
with open(index_path) as f:
idx = json.load(f)
for name, pkg in (idx.get("packages") or {}).items():
if not isinstance(pkg, dict):
continue
pbins = []
for b in pkg.get("binaries") or []:
n = b["name"] if isinstance(b, dict) else b
pbins.append(str(n).removesuffix("-x86_64"))
if name in wanted or any(b in wanted for b in pbins):
for svc in pkg.get("services") or []:
add_svc(svc)
if os.path.isdir(user_dir):
for fn in os.listdir(user_dir):
if not fn.endswith(".service"):
continue
path = os.path.join(user_dir, fn)
if not os.path.isfile(path):
continue
try:
text = open(path).read()
except OSError:
continue
for line in text.splitlines():
if line.lstrip().startswith("ExecStart="):
argv0 = line.split("=", 1)[1].split()
if argv0 and os.path.basename(argv0[0]) in wanted:
add_svc(fn)
break
for unit in sorted(units):
print(unit)
PY
)
if [[ ! " ${SERVICE_UNITS[*]} " =~ " breadd.service " ]]; then
echo "ERROR: breadd.service not in the bakery unit list — refusing to bake" >&2
exit 1
fi
rewrite_exec_start() {
local src="$1" dest="$2"
python3 - "$src" "$dest" <<'PY'
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 for unit in "${SERVICE_UNITS[@]}"; do
[[ -n "$unit" ]] || continue
if [[ -f "$SKEL_SYSTEMD/$unit" ]]; then if [[ -f "$SKEL_SYSTEMD/$unit" ]]; then
src="$SKEL_SYSTEMD/$unit" echo " $unit already committed in skel, leaving as-is"
echo " $unit using committed skel unit as source" continue
else
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
exit 1
fi
fi fi
rewrite_exec_start "$src" "$IMAGE_UNITS/$unit" src="$SYSTEMD_USER_DIR/$unit"
if [[ -f "$SKEL_SYSTEMD/$unit" ]]; then if [[ ! -f "$src" ]]; then
rewrite_exec_start "$src" "$SKEL_SYSTEMD/$unit" echo " warning: $unit not found at $src, skipping"
continue
fi fi
for base in "$SYSTEMD_USER_DIR" "$SKEL_SYSTEMD"; do install -d -m 0755 "$SKEL_SYSTEMD"
[[ -d "$base" ]] || continue sed "s#ExecStart=$LAPTOP_HOME/.local/bin/#ExecStart=%h/.local/bin/#" "$src" > "$SKEL_SYSTEMD/$unit"
for wants_dir in "$base"/*.target.wants; do for wants_dir in "$SYSTEMD_USER_DIR"/*.target.wants; do
[[ -e "$wants_dir" || -L "$wants_dir" ]] || continue [[ -L "$wants_dir/$unit" ]] || continue
[[ -L "$wants_dir/$unit" ]] || continue target_name="$(basename "$wants_dir")"
target_name="$(basename "$wants_dir")" install -d -m 0755 "$SKEL_SYSTEMD/$target_name"
install -d -m 0755 "$IMAGE_UNITS/$target_name" ln -sf "../$unit" "$SKEL_SYSTEMD/$target_name/$unit"
ln -sf "../$unit" "$IMAGE_UNITS/$target_name/$unit"
done
done done
# systemctl --global enable equivalent: /etc/systemd/user/<WantedBy>.wants/ echo " baked $unit"
# 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 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 # 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 # 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 # 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 # baked binary right after the array opener (keeps the binary list in one place).
# listed; keeps the bakery list in one place — the lockfile).
perm_file="$(mktemp)" perm_file="$(mktemp)"
for b in "${BREAD_BINS[@]}"; do 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 done
sed -i "/^file_permissions=(/r $perm_file" "$STAGE/profiledef.sh" sed -i "/^file_permissions=(/r $perm_file" "$STAGE/profiledef.sh"
rm -f "$perm_file" 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 # 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 # boot-config UUID (%ARCHISO_UUID%) when it starts and the iso9660 volume UUID

View file

@ -1,46 +0,0 @@
# Hardware and recovery
## GPUs
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.
## 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=@`).
`snapper rollback` swaps the default subvolume; the installed `grub.cfg`
still boots `@`. Pick the grub-btrfs entry so the kernel command line
matches the snapshot you want.
BOS Settings → Snapshots lists snapshot number, date, and description so
you know which GRUB entry to pick. It does not roll the running root back
in place. Bakery desktop apps live under `/usr/local` on `@`, so those
same snapshots include them.
If the system will not boot (lost EFI entry / broken GRUB), boot the live
ISO and run `sudo bos-rescue`. It mounts `@` + the ESP and offers the same
`grub-install` NVRAM + `--removable` sequence as `post-install.sh`.
A/B root swapping is not implemented. See the README Recovery section.

View file

@ -1,169 +0,0 @@
# Signed `[breadway]` repo
**Status: live.** The ISO's `[breadway]` section is `SigLevel = Required`
and points at `https://dl.breadway.dev/arch/$arch`, where every
`.pkg.tar.zst` and the db carry a detached `.sig` from the BOS release key
(`56203B86…`, `KEYS.asc`, `releases@breadway.dev`). That key is trusted in
the pacman keyring at build time (`build-local.sh`), on the live medium
(`iso/airootfs/root/customize_airootfs.sh`), and on the installed target
(`iso/airootfs/etc/calamares/post-install.sh`).
Forgejo publishing is unchanged: `package.yml` / packaging workflows still
PUT unsigned `.pkg.tar.zst` to Forgejo's Arch registry. The signed tree at
`dl.breadway.dev/arch` is rebuilt from that registry by
`.forgejo/workflows/signed-repo.yml` + `scripts/ci-publish-signed-repo.sh`.
The rest of this doc is the original stand-up / verification procedure,
kept for reference and for re-verifying after key rotation.
## 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)
(`5620 3B86 A110 695A E7F3 1093 4AF3 323D 678E B5E2`,
`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`):
```
https://dl.breadway.dev/arch/x86_64/
breadlock-<ver>-1-x86_64.pkg.tar.zst
breadlock-<ver>-1-x86_64.pkg.tar.zst.sig
breadway.db
breadway.db.sig
breadway.files
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, python-pywal). Leftover bakery-channel pacman packages
still sitting in that registry are **not** copied. Optional
`BREADWAY_PKG_DIR` on the runner overrides individual files.
Then it detach-signs each `.pkg.tar.zst` as a **binary** sidecar (pacman
wants `.sig`, not armoured `.asc`) and builds the database with
`repo-add -s`:
```sh
export GNUPGHOME=/tmp/gnupg-breadway-repo
mkdir -m 700 -p "$GNUPGHOME"
printf '%s\n' "$GPG_PRIVATE_KEY" | gpg --batch --import
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
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 `main`.
API (`workflow_dispatch`):
```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":"main"}'
```
It also runs after the in-repo AUR republish workflows complete
(`calamares` / `bibata` / `powerlevel10k` / `yay-bin` / `python-pywal`). 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.
## The ISO flip (done)
All three steps have landed:
1. **Key trusted.** The public key is committed at
`iso/airootfs/etc/pacman.d/breadway-repo.asc`. `build-local.sh`
`pacman-key --add` + `--lsign-key`s it into the build host keyring;
`customize_airootfs.sh` does the same in the airootfs;
`calamares/post-install.sh` re-does it in the target chroot.
2. **`Server`** in `iso/pacman.conf` and `iso/airootfs/etc/pacman.conf`
points at `https://dl.breadway.dev/arch/$arch`, section renamed to
`[breadway]` (matching `breadway.db`).
3. **`SigLevel = Required`** on that section.
### Re-verify after any build
In a VM booted from a fresh ISO:
```sh
sudo pacman -Sy # must fetch breadway.db + .sig, no signature error
sudo pacman -Si breadlock # lists the [breadway] section
sudo pacman -S --noconfirm yay-bin # installs with no key prompt
```
Then run the installer and, on the installed system, `sudo pacman -Sy`
again — the target keyring must already trust `56203B86…`.

View file

@ -1,13 +0,0 @@
# `dotfiles/` is not the live skel
These files are a leftover from an earlier design (Hyprland `.conf` binds).
They are **not** copied into the ISO or the installed system.
`dotfiles/hypr/keybinds.conf` still mentions `grimblast`. That is not the
screenshot tool BOS ships — live binds use **breadshot**
(`iso/airootfs/etc/skel/.config/hypr/binds.json`). Do not treat grimblast
as current.
Live user defaults live in [`iso/airootfs/etc/skel`](../iso/airootfs/etc/skel)
(`hyprland.lua` + `binds.json`, breadlock/`loginctl lock-session`, breadshot,
breadpad, …). Edit that tree.

View file

@ -1,7 +1,3 @@
# STALE — not the live Hyprland binds. Not copied into the ISO.
# Screenshots are breadshot (see iso/airootfs/etc/skel/.config/hypr/binds.json),
# not grimblast. Do not copy from this file.
$mod = SUPER $mod = SUPER
# App launchers # App launchers

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

View file

@ -3,19 +3,9 @@ showSupportUrl: false
showKnownIssuesUrl: false showKnownIssuesUrl: false
showReleaseNotesUrl: false showReleaseNotesUrl: false
# 3.4.2 schema: `check` is shown; only `required` blocks Next. Internet is
# informational so offline installs proceed. Do not probe archlinux.org.
requirements: requirements:
requiredStorage: 20 requiredStorage: 20
requiredRam: 2.0 requiredRam: 2.0
internetCheckUrl: "https://breadway.dev" checkInternet: true
check: checkPower: true
- storage internetCheckUrl: "https://archlinux.org"
- ram
- power
- internet
- root
required:
- storage
- ram
- root

View file

@ -8,6 +8,8 @@
# Best-effort: do NOT use `set -e`; a single failure here must not abort the rest. # Best-effort: do NOT use `set -e`; a single failure here must not abort the rest.
set -uo pipefail set -uo pipefail
MAIN_USER="$(getent passwd 1000 | cut -d: -f1 || true)"
# Whether Calamares encrypted the root partition (LUKS) — checked once here, # Whether Calamares encrypted the root partition (LUKS) — checked once here,
# used below to conditionally wire mkinitcpio's encrypt hook and GRUB's # used below to conditionally wire mkinitcpio's encrypt hook and GRUB's
# cryptodisk support. `lsblk TYPE` reports "crypt" for a cryptsetup-opened # cryptodisk support. `lsblk TYPE` reports "crypt" for a cryptsetup-opened
@ -31,40 +33,6 @@ rm -f /usr/local/bin/bos-live-setup /usr/local/bin/bos-launch-calamares
rm -f /etc/sudoers.d/99-bos-live rm -f /etc/sudoers.d/99-bos-live
userdel -r liveuser 2>/dev/null || true userdel -r liveuser 2>/dev/null || true
# Live ISO creates liveuser as UID 1000; Calamares then creates the real
# account as 1001. Capture AFTER userdel so Snapper ALLOW_USERS and skel
# copy the installed user, not the deleted live account.
MAIN_USER="$(getent passwd 1000 | cut -d: -f1 || true)"
if [[ -z "$MAIN_USER" || "$MAIN_USER" == "liveuser" ]]; then
MAIN_USER="$(getent passwd | awk -F: '$3 >= 1000 && $3 < 60000 && $1 != "liveuser" { print $1; exit }')"
fi
# unpackfs copies the entire live squashfs onto the target. Remove live-only
# packages (Calamares + archiso boot chain + memtest/EFI-shell payloads) so
# they do not stay on disk forever. pacman -Rs (not -Rns) keeps /etc configs
# and reaps newly-orphaned KF6/Qt6 deps. Each name is independent so one
# missing package cannot abort the rest. Offline-safe: never touches the
# network. qt6-declarative is NOT reaped — qt6-wayland still needs it.
LIVE_ONLY_PKGS=(
calamares
squashfs-tools
mkinitcpio-archiso
mkinitcpio-nfs-utils
memtest86+
memtest86+-efi
edk2-shell
syslinux
)
for pkg in "${LIVE_ONLY_PKGS[@]}"; do
pacman -Qq "$pkg" &>/dev/null || continue
pacman -Rs --noconfirm "$pkg" &>/dev/null \
|| echo "WARN: could not remove live-only package $pkg"
done
while orphans="$(pacman -Qtdq 2>/dev/null)" && [[ -n "$orphans" ]]; do
# shellcheck disable=SC2086
pacman -Rs --noconfirm $orphans &>/dev/null || break
done
# Root used a passwordless entry on the live medium; lock it (sudo model). # Root used a passwordless entry on the live medium; lock it (sudo model).
passwd -l root || true passwd -l root || true
@ -73,23 +41,12 @@ passwd -l root || true
# over to the target (unpackfs may skip it / perms differ), leaving the installed # over to the target (unpackfs may skip it / perms differ), leaving the installed
# system unable to verify package signatures — the first `pacman -Syu` then dies # system unable to verify package signatures — the first `pacman -Syu` then dies
# with "keyring is not writable / required key missing". Initialise it here so a # with "keyring is not writable / required key missing". Initialise it here so a
# fresh install can update out of the box. archlinux-keyring verifies official # fresh install can update out of the box. archlinux-keyring is already present;
# Arch packages; the BOS release key (56203B86…, shipped at # [breadway] is SigLevel=Never so it needs no key.
# /etc/pacman.d/breadway-repo.asc) verifies the signed [breadway] repo at
# dl.breadway.dev/arch — SigLevel = Required there, every package and the db
# carry a .sig from it.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
BREADWAY_KEY_FPR="56203B86A110695AE7F310934AF3323D678EB5E2"
if command -v pacman-key &>/dev/null; then if command -v pacman-key &>/dev/null; then
pacman-key --init || echo "WARN: pacman-key --init failed" pacman-key --init || echo "WARN: pacman-key --init failed"
pacman-key --populate archlinux || echo "WARN: pacman-key --populate failed" pacman-key --populate archlinux || echo "WARN: pacman-key --populate failed"
if [[ -f /etc/pacman.d/breadway-repo.asc ]]; then
pacman-key --add /etc/pacman.d/breadway-repo.asc \
&& pacman-key --lsign-key "$BREADWAY_KEY_FPR" \
|| echo "WARN: could not trust the [breadway] repo key — pacman -Sy will fail on [breadway]"
else
echo "WARN: /etc/pacman.d/breadway-repo.asc missing — [breadway] (SigLevel=Required) will not verify"
fi
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -384,39 +341,15 @@ fi
# greetd — graphical login (shipped disabled; live uses tty autologin) # greetd — graphical login (shipped disabled; live uses tty autologin)
# grub-btrfsd — regenerates GRUB snapshot entries (the unit is grub-btrfsd.service, # grub-btrfsd — regenerates GRUB snapshot entries (the unit is grub-btrfsd.service,
# NOT grub-btrfs.path, which no longer exists) # NOT grub-btrfs.path, which no longer exists)
# avahi-daemon.service → avahi-daemon.socket: the package ships both; socket
# activation still answers nss-mdns and CUPS discovery but stays off the idle
# RSS until something asks. The host stops announcing itself over mDNS until
# the socket is first touched.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
for unit in NetworkManager.service bluetooth.service systemd-timesyncd.service \ for unit in NetworkManager.service bluetooth.service systemd-timesyncd.service \
tlp.service greetd.service snapper-cleanup.timer grub-btrfsd.service \ tlp.service greetd.service snapper-cleanup.timer grub-btrfsd.service \
fstrim.timer cups.socket avahi-daemon.socket ufw.service \ fstrim.timer cups.socket avahi-daemon.service ufw.service \
fwupd-refresh.timer reflector.timer; do fwupd-refresh.timer reflector.timer; do
systemctl enable "$unit" || echo "WARN: failed to enable $unit" systemctl enable "$unit" || echo "WARN: failed to enable $unit"
done done
systemctl set-default graphical.target || echo "WARN: set-default graphical failed" systemctl set-default graphical.target || echo "WARN: set-default graphical failed"
# Arch's 90-systemd.preset enables systemd-homed / userdbd / nsresourced.
# BOS creates classic /etc/passwd accounts and never calls homectl. Mask
# (not disable) so preset-all or a systemd upgrade cannot re-enable them.
for unit in systemd-homed.service systemd-homed-activate.service \
systemd-userdbd.service systemd-userdbd.socket \
systemd-nsresourced.service systemd-nsresourced.socket; do
systemctl mask "$unit" || echo "WARN: failed to mask $unit"
done
# journald defaults SystemMaxUse to 10% of the filesystem holding /var/log.
# /var/log is the @log subvolume of the root pool, so that ceiling is tens
# of GB. Cap it; less history for postmortems.
install -d -m 0755 /etc/systemd/journald.conf.d
cat >/etc/systemd/journald.conf.d/90-bos-journal.conf <<'JOURNALEOF'
[Journal]
SystemMaxUse=256M
SystemMaxFileSize=32M
RuntimeMaxUse=32M
JOURNALEOF
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# mDNS resolution (nss-mdns): insert mdns_minimal into the hosts: line so the # mDNS resolution (nss-mdns): insert mdns_minimal into the hosts: line so the
# resolver answers *.local (network printers, other hosts) via avahi. Idempotent. # resolver answers *.local (network printers, other hosts) via avahi. Idempotent.
@ -438,21 +371,12 @@ if command -v ufw &>/dev/null; then
ufw --force enable || echo "WARN: ufw enable failed" ufw --force enable || echo "WARN: ufw enable failed"
fi fi
# The whole bread ecosystem (bakery, bread, breadbar, breadbox, breadcrumbs, # The bread ecosystem (bakery + bread, breadbar, breadbox, breadcrumbs, breadpad)
# breadpad, bos-settings, breadhelp, ...) is bakery-managed, not pacman: # is bakery-managed, not pacman: the binaries and bakery manifest live in
# binaries, share/data, and user units are baked into /usr/local and # /etc/skel/.local (baked in at ISO build time) and are copied into the user's
# /usr/lib/systemd/user (system prefix). Per-user bakery state (installed.json # home below, so the install works fully offline with no DNS for bakery/GitHub.
# + index cache) is seeded from /etc/skel/.local and copied into the user's # bos-settings and breadhelp are the only pacman bread packages and were
# home below, so the install works fully offline with no DNS for bakery. # installed by unpackfs.
#
# 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 # Deploy dotfiles + the bakery bread ecosystem into the user's home (Calamares

View file

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

View file

@ -3,8 +3,5 @@ GROUP=users
HOME=/home HOME=/home
INACTIVE=-1 INACTIVE=-1
EXPIRE= 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 SKEL=/etc/skel
CREATE_MAIL_SPOOL=no CREATE_MAIL_SPOOL=no

View file

@ -7,9 +7,8 @@
# alongside BOS's own bos.desktop, and breadgreet's session picker matches by # alongside BOS's own bos.desktop, and breadgreet's session picker matches by
# .desktop file stem — with no override it picks "hyprland.desktop" over # .desktop file stem — with no override it picks "hyprland.desktop" over
# "bos.desktop", which skips bos-session's PATH fixup (adds ~/.local/bin for # "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 # the bakery bread apps; greetd starts no login shell, so /etc/profile.d is
# shell, so /etc/profile.d is never sourced any other way. Confirmed via # never sourced any other way). Confirmed via breadgreet's own test suite
# breadgreet's own test suite
# (sessions.rs: discover_prefers_configured_default_over_first_entry). # (sessions.rs: discover_prefers_configured_default_over_first_entry).
[sessions] [sessions]

View file

@ -5,7 +5,7 @@ ID_LIKE=arch
BUILD_ID=rolling BUILD_ID=rolling
ANSI_COLOR="38;2;23;147;209" ANSI_COLOR="38;2;23;147;209"
HOME_URL="https://breadway.dev" HOME_URL="https://breadway.dev"
DOCUMENTATION_URL="https://git.breadway.dev/Breadway/bos" DOCUMENTATION_URL="https://wiki.archlinux.org/"
SUPPORT_URL="https://git.breadway.dev/Breadway/bos/issues" SUPPORT_URL="https://bbs.archlinux.org/"
BUG_REPORT_URL="https://git.breadway.dev/Breadway/bos/issues" BUG_REPORT_URL="https://git.breadway.dev/Breadway/bos/issues"
PRIVACY_POLICY_URL="https://breadway.dev" PRIVACY_POLICY_URL="https://terms.archlinux.org/docs/privacy-policy/"

View file

@ -26,23 +26,20 @@ Include = /etc/pacman.d/mirrorlist
Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Breadway custom repo — breadlock plus AUR republishes the ISO needs # Breadway custom repo — provides: bakery and the bread ecosystem packages
# (calamares, zen-browser-bin, bibata-cursor-theme-bin, yay-bin, # (bread, breadbar, breadbox, breadcrumbs, breadpad, bos-settings).
# zsh-theme-powerlevel10k). bakery / breadbar / bos-settings / breadhelp # (calamares comes from the official extra repo, not here.)
# are NOT here; they are bakery-baked into /usr/local at ISO build time.
# #
# Packages are published to the Forgejo Arch registry (group "os") by the # Packages are published to the Forgejo Arch registry (group "os") by the
# .forgejo/workflows/*.yml workflows; scripts/ci-publish-signed-repo.sh then # .forgejo/workflows/package.yml workflow in each repo, on tag push.
# collects them, detach-signs each .pkg.tar.zst with the BOS release key
# (releases@breadway.dev), runs `repo-add -s`, and publishes the signed db
# at https://dl.breadway.dev/arch/$arch (signed-repo.yml).
# #
# SigLevel = Required: every package AND the db carry a .sig from key # Forgejo signs the repo db with a key pacman can't look up, so TrustAll
# 56203B86A110695AE7F310934AF3323D678EB5E2 — the same key committed as # fails. SigLevel = Never skips verification (acceptable for this private
# KEYS.asc / etc/pacman.d/breadway-repo.asc, imported into the pacman # repo over TLS). Future improvement: import Forgejo's signing key and
# keyring at build time (build-local.sh), on the live medium, and on the # switch to SigLevel = Required for full package verification.
# installed target (calamares/post-install.sh).
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
[breadway] # The section name must match Forgejo's served db filename
SigLevel = Required # ({owner}.{group}.{domain}.db) — pacman fetches "<section>.db" from Server.
Server = https://dl.breadway.dev/arch/$arch [Breadway.os.git.breadway.dev]
SigLevel = Never
Server = https://git.breadway.dev/api/packages/Breadway/arch/os/$arch

View file

@ -1,15 +0,0 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEakhwGhYJKwYBBAHaRw8BAQdA/sZ/GYec5M2MD+w20mVF5tMUhGji210Dg7zL
TAhNsg60WUJPUyBSZWxlYXNlIFNpZ25pbmcgKGdpdC5icmVhZHdheS5kZXYvQnJl
YWR3YXkvYm9zIHJlbGVhc2VzIG9ubHkpIDxyZWxlYXNlc0BicmVhZHdheS5kZXY+
iJYEExYKAD4WIQRWIDuGoRBpWufzEJNK8zI9Z4614gUCakhwGgIbIwUJA8JnAAUL
CQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRBK8zI9Z4614ggYAQDP8FTZ14i9YPKD
ARvZuP5QaYOUFhQ8uyG0CowXKy9O0AEAqYfjnvyJI3N651pVFSNUXyP16w1kMPSs
K0g3CLsztQ+4OARqSHAaEgorBgEEAZdVAQUBAQdAuJFuy2GHz5m9wXTm/PdSpLE9
gERwHOLyM1OFuttrJW4DAQgHiH4EGBYKACYWIQRWIDuGoRBpWufzEJNK8zI9Z461
4gUCakhwGgIbDAUJA8JnAAAKCRBK8zI9Z4614nzLAP9grcIFsAAeCyVKhziHmpXq
E0Hm6FfIr4sdEf63HZkyfwD/XeKeWfb3EWvVsloJrZZ9tDmR67iK52Hwl82wfFAU
cAo=
=Mrh1
-----END PGP PUBLIC KEY BLOCK-----

View file

@ -1,8 +1,8 @@
# Keep ~/.local/bin on PATH for per-user tools. Arch already includes # Put the per-user bakery bin dir on PATH. The bread ecosystem (breadd, breadbar,
# /usr/local/bin (where bakery desktop apps live on BOS). The Hyprland # breadbox, …) is installed there by bakery, and the Hyprland session launches
# session resolves exec-once against the PATH it inherits from the login # them via `exec-once`, which resolves against the PATH it inherits from the
# shell; Arch's stock /etc/profile does not add ~/.local/bin, so do it # login shell. Arch's stock /etc/profile does not add ~/.local/bin, so do it here
# here for every login shell (live user and installed user alike). # for every login shell (live user and installed user alike).
case ":$PATH:" in case ":$PATH:" in
*":$HOME/.local/bin:"*) ;; *":$HOME/.local/bin:"*) ;;
*) export PATH="$HOME/.local/bin:$PATH" ;; *) export PATH="$HOME/.local/bin:$PATH" ;;

View file

@ -1,228 +0,0 @@
-- external-monitors — behave like a normal laptop desktop
--
-- Plug in any display (HDMI, DisplayPort, USB-C dock, a random TV) and
-- the session just works. No output names to edit.
--
-- • the laptop panel stays at its preferred (native) mode
-- • each external uses its preferred mode and refresh
-- • new screens clone the laptop (set ARRANGE = "extend" to sit to the right)
-- • closing the lid does not sleep while an external is on
-- • unplug everything and the laptop is the only display again
--
-- Drop-in: copy to ~/.config/bread/modules/ and `bread reload`.
local M = bread.module({
name = "external-monitors",
version = "1.0.0",
after = { "bread.monitors" },
})
-- "mirror" = every external clones the laptop (presentations, TVs)
-- "extend" = extra desktop to the right
local ARRANGE = "mirror"
local SCALE = "auto"
local INTERNAL_RE = "^eDP"
local INHIBITOR = "/tmp/bread-lid-inhibitor.pid"
local function inhibit_lid()
if bread.fs.exists(INHIBITOR) then return end
bread.exec(
"bash -c 'systemd-inhibit --what=handle-lid-switch --who=bread "
.. "--why=external-display sleep infinity & echo $! > "
.. INHIBITOR
.. "'"
)
end
local function release_lid()
bread.exec(
"bash -c 'kill $(cat " .. INHIBITOR .. " 2>/dev/null) 2>/dev/null; rm -f " .. INHIBITOR .. "'"
)
end
local function is_internal(name)
return type(name) == "string" and name:match(INTERNAL_RE) ~= nil
end
local function drm_status(name)
for card = 0, 5 do
local raw = bread.fs.read(string.format("/sys/class/drm/card%d-%s/status", card, name))
if raw then
return raw:match("^%s*(%S+)")
end
end
return nil
end
local function drm_first_mode(name)
for card = 0, 5 do
local raw = bread.fs.read(string.format("/sys/class/drm/card%d-%s/modes", card, name))
if raw then
local w, h = raw:match("(%d+)x(%d+)")
if w then
return tonumber(w), tonumber(h)
end
end
end
return 1920, 1080
end
local function list_connectors()
local names = {}
local ok, out = bread.exec_capture("ls /sys/class/drm", { timeout_ms = 500 })
if not ok or not out then
return names
end
for ent in out:gmatch("[^%s]+") do
local name = ent:match("^card%d+%-(.+)$")
if name and not name:match("^Writeback") then
names[#names + 1] = name
end
end
table.sort(names)
return names
end
local function connected()
local internal, externals = nil, {}
for _, name in ipairs(list_connectors()) do
if drm_status(name) == "connected" then
if is_internal(name) then
internal = internal or name
else
externals[#externals + 1] = name
end
end
end
return internal or "eDP-1", externals
end
-- BOS Hyprland talks Lua (`hl.monitor`). Stock Hyprland uses the
-- `monitor=` keyword. Try eval first, then keyword.
local function apply_monitor(opts)
local extra = ""
if opts.mirror and opts.mirror ~= "" then
extra = string.format(", mirror = %q", opts.mirror)
end
local expr = string.format(
"hl.monitor({ output = %q, mode = %q, position = %q, scale = %q%s })",
opts.output,
opts.mode or "preferred",
opts.position or "0x0",
opts.scale or SCALE,
extra
)
local resp = bread.hyprland.eval(expr)
if type(resp) == "string" and resp:match("error") then
local spec = string.format(
"%s, %s, %s, %s",
opts.output,
opts.mode or "preferred",
opts.position or "0x0",
opts.scale or SCALE
)
if opts.mirror and opts.mirror ~= "" then
spec = spec .. ", mirror, " .. opts.mirror
end
bread.hyprland.keyword("monitor", spec)
end
end
local function apply(internal, externals)
apply_monitor({
output = internal,
mode = "preferred",
position = "0x0",
scale = SCALE,
})
if ARRANGE == "mirror" then
for _, name in ipairs(externals) do
apply_monitor({
output = name,
mode = "preferred",
position = "0x0",
scale = SCALE,
mirror = internal,
})
end
return
end
local x = select(1, drm_first_mode(internal)) or 1920
for _, name in ipairs(externals) do
apply_monitor({
output = name,
mode = "preferred",
position = x .. "x0",
scale = SCALE,
})
local w = select(1, drm_first_mode(name)) or 1920
x = x + w
end
end
function M.on_load()
local last = nil
local applied = false
local function evaluate()
local internal, externals = connected()
local sig = internal .. "|" .. table.concat(externals, ",")
if sig == last then
return
end
last = sig
if #externals == 0 then
if applied then
apply_monitor({
output = internal,
mode = "preferred",
position = "0x0",
scale = SCALE,
})
release_lid()
applied = false
end
return
end
apply(internal, externals)
inhibit_lid()
applied = true
bread.log("[external-monitors] " .. internal .. " + " .. table.concat(externals, ", "))
end
local settle = bread.debounce(1500, evaluate)
bread.on("bread.hyprland.monitor.connected", function(event)
local name = event.data and event.data.name
if name and not is_internal(name) then
bread.notify("Display connected: " .. name, { urgency = "low" })
end
settle()
end)
bread.on("bread.hyprland.monitor.disconnected", function()
settle()
end)
bread.on("bread.device.**", function(event)
local sub = event.data and event.data.subsystem
if sub == "drm" then
settle()
end
end)
bread.hyprland.on_raw("configreloaded", function()
last = nil
evaluate()
end)
bread.every(3000, evaluate)
settle()
end
return M

View file

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

View file

@ -18,7 +18,6 @@
{ "action": "exec", "command": "breadclip", "key": "V", "label": "Clipboard history (breadclip)", "category": "apps", "demo_cmd": "breadclip" }, { "action": "exec", "command": "breadclip", "key": "V", "label": "Clipboard history (breadclip)", "category": "apps", "demo_cmd": "breadclip" },
{ "action": "exec", "command": "breadclip", "key": "V", "mods": ["SUPER", "SHIFT"], "label": "Clipboard history (breadclip) — same as SUPER + V", "category": "apps" }, { "action": "exec", "command": "breadclip", "key": "V", "mods": ["SUPER", "SHIFT"], "label": "Clipboard history (breadclip) — same as SUPER + V", "category": "apps" },
{ "action": "exec", "command": "breadbar --history", "key": "N", "mods": ["SUPER", "SHIFT"], "label": "Notification history (breadbar)", "category": "apps", "demo_cmd": "breadbar --history" },
{ "action": "layout", "layout": "togglesplit", "key": "T", "label": "Toggle split direction", "category": "windows" }, { "action": "layout", "layout": "togglesplit", "key": "T", "label": "Toggle split direction", "category": "windows" },
{ "action": "focus_last", "key": "Tab", "label": "Focus last window", "category": "windows" }, { "action": "focus_last", "key": "Tab", "label": "Focus last window", "category": "windows" },

View file

@ -77,17 +77,6 @@ hl.env("SDL_VIDEODRIVER", "wayland")
hl.env("ELECTRON_OZONE_PLATFORM_HINT", "auto") hl.env("ELECTRON_OZONE_PLATFORM_HINT", "auto")
hl.env("_JAVA_AWT_WM_NONREPARENTING", "1") 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 -- kitty sets its own background_opacity (see kitty.conf), so the global blur
-- above blurs behind the terminal while keeping text fully opaque. -- above blurs behind the terminal while keeping text fully opaque.
@ -105,22 +94,14 @@ pcall(function()
bindings = binds.bindings, bindings = binds.bindings,
}) })
end) end)
-- 3-finger horizontal trackpad swipe → workspace switch (1:1 gesture)
hl.gesture({
fingers = 3,
direction = "horizontal",
action = "workspace",
})
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Autostart. Core bootstrap sequence (polkit agent, dark theme, wallpaper -- Autostart. Core bootstrap sequence (polkit agent, dark theme, wallpaper
-- daemon, breadd's Wayland-env fix, breadclipd) stays hardcoded here — it's -- daemon, breadd's Wayland-env fix, breadclipd) stays hardcoded here — it's
-- timing/order-sensitive infrastructure, not something a settings UI should -- timing/order-sensitive infrastructure, not something a settings UI should
-- expose for a user to disable or reorder. The extra, genuinely toggleable -- expose for a user to disable or reorder. The extra, genuinely toggleable
-- apps (breadbar, hypridle, bos-netcheck, breadhelp, breadpaper/breadshot -- apps (breadbar, hypridle, bos-netcheck, breadhelp) come from
-- listen) come from autostart.json via scripts/system/autostart.lua, -- autostart.json via scripts/system/autostart.lua, appended after.
-- appended after. listen is wrapped with `command -v` so a missing
-- binary does not brick login (Hyprland exec is already fire-and-forget).
-- (bos-live-setup appends the live-installer launch below this on the ISO.) -- (bos-live-setup appends the live-installer launch below this on the ISO.)
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
hl.on("hyprland.start", function() hl.on("hyprland.start", function()
@ -135,12 +116,9 @@ hl.on("hyprland.start", function()
"gsettings set org.gnome.desktop.interface cursor-theme Bibata-Modern-Ice", "gsettings set org.gnome.desktop.interface cursor-theme Bibata-Modern-Ice",
"gsettings set org.gnome.desktop.interface cursor-size 24", "gsettings set org.gnome.desktop.interface cursor-size 24",
-- Clipboard history is breadclipd, a bakery-managed systemd --user -- Clipboard history is breadclipd, a bakery-managed systemd --user
-- service (auto-started from /usr/lib/systemd/user — see -- service (auto-started via skel — see build-local.sh's service bake)
-- build-local.sh's service bake) rather than an exec-once here. -- rather than an exec-once here.
-- Prefer bread-polkit if it is on PATH (not baked; lockfile does not "/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1",
-- ship it). Otherwise the ISO's polkit-gnome agent. command -v so a
-- missing binary does not leave the session without an auth agent.
"sh -c 'if command -v bread-polkit >/dev/null; then exec bread-polkit; else exec /usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1; fi'",
"awww-daemon", "awww-daemon",
-- Set the default wallpaper once the daemon is up (retry until ready). -- Set the default wallpaper once the daemon is up (retry until ready).
-- Raw `awww img`, NOT `breadpaper set` — breadpaper set also runs real -- Raw `awww img`, NOT `breadpaper set` — breadpaper set also runs real
@ -154,9 +132,8 @@ hl.on("hyprland.start", function()
-- breadpaper reads) is baked into skel too, right beside colors.json. -- breadpaper reads) is baked into skel too, right beside colors.json.
-- pywal only runs for real once the user picks a wallpaper themselves. -- 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']], [[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, -- breadd runs as a systemd user service (~/.config/systemd/user/breadd.service,
-- enabled --global so every account starts it). It autostarts at login -- enabled in skel). It autostarts at login but before Hyprland exists, so
-- but before Hyprland exists, so
-- push the compositor's Wayland env into the user manager and restart breadd -- 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. -- 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", "dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE",
@ -177,8 +154,7 @@ hl.on("hyprland.start", function()
-- breadbox-sync is a Type=oneshot systemd --user service -- breadbox-sync is a Type=oneshot systemd --user service
-- (WantedBy=default.target, no Hyprland IPC dependency) — it already -- (WantedBy=default.target, no Hyprland IPC dependency) — it already
-- runs on login via the unit baked into /usr/lib/systemd/user, -- runs on login via the unit baked into skel, independent of this list.
-- independent of this list.
local ok, extra = pcall(function() local ok, extra = pcall(function()
return dofile(script_dir .. "system/autostart.lua")() return dofile(script_dir .. "system/autostart.lua")()
end) end)
@ -186,20 +162,7 @@ hl.on("hyprland.start", function()
-- autostart.json/its loader broke — fall back to the same apps BOS -- autostart.json/its loader broke — fall back to the same apps BOS
-- has always started, so a bad JSON edit degrades to "normal -- has always started, so a bad JSON edit degrades to "normal
-- desktop" rather than "no bar, no idle lock, no onboarding". -- desktop" rather than "no bar, no idle lock, no onboarding".
extra = { extra = { "breadbar", "hypridle", "bos-netcheck", "breadhelp --autostart" }
"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'",
"bash -c 'command -v breadlock >/dev/null && exec breadlock listen'",
"bash -c 'command -v breadbox >/dev/null && exec breadbox listen'",
"bash -c 'command -v breadhelp >/dev/null && exec breadhelp listen'",
"bash -c 'command -v breadsearch >/dev/null && exec breadsearch listen'",
"bash -c 'command -v breadpad >/dev/null && exec breadpad listen'",
}
end end
for _, cmd in ipairs(extra) do for _, cmd in ipairs(extra) do
hl.dispatch(hl.dsp.exec_cmd(cmd)) hl.dispatch(hl.dsp.exec_cmd(cmd))

View file

@ -12,21 +12,11 @@
-- because the valid result happens to be empty. -- because the valid result happens to be empty.
local json = dofile(os.getenv("HOME") .. "/.config/hypr/scripts/lib/json.lua") local json = dofile(os.getenv("HOME") .. "/.config/hypr/scripts/lib/json.lua")
-- breadpaper/breadshot `listen` is wrapped so a missing binary (stable
-- does not ship the command-bus verb yet) cannot take down the session.
local DEFAULT_EXTRA = { local DEFAULT_EXTRA = {
{ command = "breadbar", enabled = true }, { command = "breadbar", enabled = true },
{ command = "hypridle", enabled = true }, { command = "hypridle", enabled = true },
{ command = "bos-netcheck", enabled = true }, { command = "bos-netcheck", enabled = true },
{ command = "bash -c 'command -v bos-first-boot >/dev/null && exec bos-first-boot'", enabled = true },
{ command = "breadhelp --autostart", enabled = true }, { command = "breadhelp --autostart", enabled = true },
{ command = "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", enabled = true },
{ command = "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", enabled = true },
{ command = "bash -c 'command -v breadlock >/dev/null && exec breadlock listen'", enabled = true },
{ command = "bash -c 'command -v breadbox >/dev/null && exec breadbox listen'", enabled = true },
{ command = "bash -c 'command -v breadhelp >/dev/null && exec breadhelp listen'", enabled = true },
{ command = "bash -c 'command -v breadsearch >/dev/null && exec breadsearch listen'", enabled = true },
{ command = "bash -c 'command -v breadpad >/dev/null && exec breadpad listen'", enabled = true },
} }
return function() return function()

View file

@ -112,6 +112,19 @@ local function build_hl_config(v)
dwindle = { preserve_split = true }, dwindle = { preserve_split = true },
animations = { enabled = true }, animations = { enabled = true },
misc = { disable_hyprland_logo = true, disable_splash_rendering = true }, misc = { disable_hyprland_logo = true, disable_splash_rendering = true },
-- 3-finger touchpad swipe switches workspaces (touchscreen/touchpad
-- gesture, native to Hyprland — no plugin needed).
gestures = {
workspace_swipe = true,
workspace_swipe_fingers = 3,
workspace_swipe_distance = 300,
workspace_swipe_invert = true,
workspace_swipe_min_speed_to_force = 30,
workspace_swipe_cancel_ratio = 0.5,
workspace_swipe_create_new = true,
workspace_swipe_direction_lock = true,
workspace_swipe_forever = false,
},
} }
end end

View file

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

View file

@ -1,9 +0,0 @@
[Desktop Entry]
Name=BOS Settings
Comment=System settings for Bread OS
Exec=bos-settings
Icon=preferences-system
Terminal=false
Type=Application
Categories=Settings;System;
StartupWMClass=bos-settings

View file

@ -1,9 +0,0 @@
[Desktop Entry]
Name=BOS Help
Comment=Your personal guide to the Bread desktop
Exec=breadhelp
Icon=help-browser
Terminal=false
Type=Application
Categories=Help;System;
StartupWMClass=com.breadway.breadhelp

View file

@ -89,7 +89,7 @@ alias alt-install='yay -S'
alias alt-uninstall='yay -R' alias alt-uninstall='yay -R'
alias alt-srchpkg='yay -Ss' 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" export PATH="$HOME/.local/bin:$PATH"
# Powerlevel10k prompt configuration. # Powerlevel10k prompt configuration.

View file

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

View file

@ -1,27 +0,0 @@
#!/usr/bin/env bash
# Run by mkarchiso inside the airootfs chroot, after packages are installed
# and before the squashfs is built. (archiso prints a deprecation warning for
# this hook, but there is no non-deprecated replacement for "trust an extra
# pacman repo key in the image keyring", and BOS ships no pacman-init.service.)
#
# Purpose: trust the BOS release key (56203B86…) in the image's pacman
# keyring so the signed [breadway] repo (SigLevel = Required,
# https://dl.breadway.dev/arch) verifies both on the live medium and — via
# calamares' unpackfs, which copies this squashfs to the target — on the
# installed system. calamares/post-install.sh re-does this in the target
# chroot as a fallback (unpackfs can skip /etc/pacman.d/gnupg).
set -euo pipefail
BREADWAY_KEY_FPR="56203B86A110695AE7F310934AF3323D678EB5E2"
KEY_FILE="/etc/pacman.d/breadway-repo.asc"
pacman-key --init
pacman-key --populate archlinux
if [[ -f "$KEY_FILE" ]]; then
pacman-key --add "$KEY_FILE"
pacman-key --lsign-key "$BREADWAY_KEY_FPR"
echo "customize_airootfs: trusted [breadway] repo key $BREADWAY_KEY_FPR"
else
echo "customize_airootfs: WARNING $KEY_FILE missing; [breadway] will not verify" >&2
fi

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. # bos-launch-calamares). Runs once at boot, before the tty1 autologin getty.
set -e 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 # useradd -m copies /etc/skel, so the live user gets the real BOS desktop
# (hypr + bread config + bakery state) — proper live-media functionality, # (breadd + breadbar + breadbox + keybinds) — proper live-media functionality,
# not an installer kiosk. Binaries are /usr/local, not skel. # not an installer kiosk.
if ! id liveuser &>/dev/null; then if ! id liveuser &>/dev/null; then
useradd -m -s /usr/bin/zsh liveuser useradd -m -s /usr/bin/zsh liveuser
for g in wheel video input audio storage power; do 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 # BOS graphical session launcher, run by greetd on the INSTALLED system after
# the user authenticates (see /etc/greetd/config.toml). # the user authenticates (see /etc/greetd/config.toml).
# #
# greetd does not start a login shell, so /etc/profile.d is never sourced. # greetd does not start a login shell, so /etc/profile.d is never sourced — which
# Bakery desktop apps live in /usr/local/bin (already on Arch PATH). Source # means ~/.local/bin (where bakery installs the bread ecosystem: breadd, breadbar,
# the login profile here so ~/.local/bin (per-user tools) is also on PATH, # breadbox-sync, …) would be missing from PATH and the Hyprland `exec-once`
# set the Wayland session hints, then hand off to Hyprland. # 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 # Launched via start-hyprland (ships with the hyprland package) rather than the
# raw Hyprland binary — Hyprland upstream no longer recommends exec'ing it # raw Hyprland binary — Hyprland upstream no longer recommends exec'ing it

View file

@ -2,41 +2,19 @@
# bos-update — update all of BOS in one go. # bos-update — update all of BOS in one go.
# #
# BOS packages come from two channels, so a full update touches both: # BOS packages come from two channels, so a full update touches both:
# 1. pacman — Arch base/desktop + the [breadway] repo (breadlock + AUR # 1. pacman — Arch base/desktop + the [breadway] repo (bos-settings, etc.).
# republishes: calamares, zen-browser-bin, bibata, yay-bin, # Every transaction is snapshotted by snap-pac, so you can roll
# powerlevel10k). [breadway] does NOT provide bos-settings # back from the GRUB "snapshots" submenu or BOS Settings.
# or other bakery desktop apps. Every transaction is # 2. bakery — the bread ecosystem apps in ~/.local/bin (whatever `bakery list`
# snapshotted by snap-pac; recover via the GRUB "snapshots" # reports as installed — bread, breadbar, breadbox, breadcrumbs,
# submenu (grub-btrfs), not `snapper rollback`. # breadpad, breadman, bread-theme, breadpaper, breadmon,
# 2. bakery — the bread ecosystem apps in /usr/local (whatever `bakery list` # breadsearch, breadclip, breadshot, ...).
# 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`.
# #
# Best-effort: a failure in one channel doesn't abort the other. # Best-effort: a failure in one channel doesn't abort the other.
set -uo pipefail set -uo pipefail
bold() { printf '\033[1m%s\033[0m\n' "$1"; } 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.
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 \
-d "bos-update (pre bakery)" \
|| echo "WARN: snapper home pre snapshot failed"
fi
snapper -c root create -t pre -c number \
-d "bos-update (pre bakery)" \
|| echo "WARN: snapper pre snapshot failed"
fi
bold "==> System packages (pacman -Syu)" bold "==> System packages (pacman -Syu)"
if command -v pacman >/dev/null; then if command -v pacman >/dev/null; then
sudo pacman -Syu || echo "WARN: pacman update failed" sudo pacman -Syu || echo "WARN: pacman update failed"
@ -47,22 +25,10 @@ fi
echo echo
bold "==> Bread ecosystem (bakery update --all)" bold "==> Bread ecosystem (bakery update --all)"
if command -v bakery >/dev/null; then if command -v bakery >/dev/null; then
# /usr/local is root-owned. Never run bakery as the user against it; bakery update --all || echo "WARN: bakery update failed"
# bakery itself also tries sudo -n then pkexec for privileged writes.
if sudo -n true >/dev/null 2>&1; then
sudo -n bakery update --all || echo "WARN: bakery update failed"
elif command -v pkexec >/dev/null; then
pkexec bakery update --all || echo "WARN: bakery update failed"
else
echo "WARN: bakery update needs sudo -n or pkexec for /usr/local"
fi
else else
echo "bakery not found; skipping" echo "bakery not found; skipping"
fi fi
echo echo
bold "==> BOS is up to date." 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,63 +0,0 @@
# Bakery binaries baked into the live/installed image at /usr/local.
#
# build-local.sh and CI (scripts/ci-stage-bakery.py) read this file. A missing
# *required* binary fails the bake: a hollow ISO is worse than a failed build.
# optional_bins are baked when the verified stable index publishes them, and
# skipped with a warning when it does not. bread 0.8.0 ships bread-emit and
# bread-module-host, so those are required_bins.
#
# A flat `bins` list is still accepted and treated as required_bins.
#
# CI populates the builder from the minisign-verified stable bakery index
# (https://dl.breadway.dev/index.json). Optional [versions] (or [[pin]]
# tables with package + version) pin bakery package versions so two ISO
# bakes of the same git commit fetch the same bits:
# https://dl.breadway.dev/<pkg>/<ver>/...
# Bump pins after new bakery stables land. Local builds still snapshot
# whatever is installed on the builder.
#
# Not shipped (even if they appear in the index): breadcast, breadarr.
# breadlock is pacman (see packages.x86_64), not bakery.
required_bins = [
"bakery",
"bread",
"breadd",
"bread-emit",
"bread-module-host",
"breadman",
"breadbar",
"breadbox",
"breadbox-sync",
"breadcrumbs",
"breadpad",
"breadpaper",
"bread-theme",
"breadmon",
"breadsearch",
"breadmill",
"breadclip",
"breadclipd",
"breadshot",
"bos-settings",
"breadhelp",
]
# Package name → version. Must exist at dl.breadway.dev/<pkg>/<ver>/ and
# should match the signed index so CI can verify sha256.
# [[pin]] { package, version } is accepted as well and merged (conflict = bake error).
[versions]
bakery = "0.7.4"
bread = "0.8.0"
bread-theme = "0.7.4"
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"

View file

@ -1,26 +1,9 @@
# Base system # Base system
base base
base-devel
linux linux
# linux-firmware metapackage pulls every mandatory vendor blob (incl. nvidia). linux-firmware
# List the subpackages we actually need so nvidia (~103 MiB, nouveau-only — linux-headers
# BOS ships no NVIDIA driver) can stay off the image. Turing+ nouveau needs
# the GSP blobs; reinstall linux-firmware-nvidia when lspci sees NVIDIA.
# linux-firmware
linux-firmware-amdgpu
linux-firmware-atheros
linux-firmware-broadcom
linux-firmware-cirrus
linux-firmware-intel
linux-firmware-mediatek
linux-firmware-realtek
linux-firmware-radeon
linux-firmware-other
# linux-firmware-nvidia
# base-devel + linux-headers are for AUR/DKMS builds. yay needs base-devel,
# but those builds need network anyway — pacman -S base-devel at that point.
# linux-headers is DKMS-only and BOS ships no DKMS packages.
# base-devel
# linux-headers
# CPU microcode — applied early by GRUB on the installed system (picked up by # CPU microcode — applied early by GRUB on the installed system (picked up by
# the bootloader module). amd-ucode for the dev laptop's Ryzen; intel-ucode for # the bootloader module). amd-ucode for the dev laptop's Ryzen; intel-ucode for
# Intel targets. bos-copy-kernel also stages these into the live target /boot. # Intel targets. bos-copy-kernel also stages these into the live target /boot.
@ -61,7 +44,7 @@ sbctl
squashfs-tools squashfs-tools
# rsync: unpackfs copies the unpacked rootfs onto the target with rsync. # rsync: unpackfs copies the unpacked rootfs onto the target with rsync.
rsync rsync
# Live-ISO boot (archiso bootmodes: bios.syslinux + uefi.grub) # Live-ISO boot (archiso bootmodes: bios.syslinux + uefi.systemd-boot)
# mkinitcpio-archiso provides the initramfs hooks that find and mount # mkinitcpio-archiso provides the initramfs hooks that find and mount
# airootfs.sfs and switch root into it — without it the live ISO drops # airootfs.sfs and switch root into it — without it the live ISO drops
# to emergency mode on boot. # to emergency mode on boot.
@ -78,8 +61,6 @@ snapper
snap-pac snap-pac
grub-btrfs grub-btrfs
inotify-tools inotify-tools
# Home backup (Settings → Backup). Snapper is root (`@`) only; restic covers $HOME.
restic
# Wayland / Hyprland # Wayland / Hyprland
hyprland hyprland
@ -118,17 +99,11 @@ bluez-utils
# blueman: GUI Bluetooth manager (pair/connect devices; breadbar shows status only). # blueman: GUI Bluetooth manager (pair/connect devices; breadbar shows status only).
blueman blueman
# GTK4 runtime (breadbar, breadbox, breadclip, breadhelp, and other bakery apps) # GTK4 runtime
gtk4 gtk4
gtk4-layer-shell gtk4-layer-shell
librsvg librsvg
libpulse libpulse
hicolor-icon-theme
# Tauri 2 runtime for bakery-baked bos-settings. Arch's WebKitGTK 4.1 package
# is webkit2gtk-4.1 (libwebkit2gtk-4.1.so); libsoup3 and JavaScriptCore 4.1
# are pulled in as its dependencies. xdg-desktop-portal comes from the
# Hyprland/GTK portal packages listed above.
webkit2gtk-4.1
# GTK3 dark theme (Adwaita-dark); without this package the gtk-theme-name in # GTK3 dark theme (Adwaita-dark); without this package the gtk-theme-name in
# skel settings.ini silently falls back to the light theme for GTK3 apps. # skel settings.ini silently falls back to the light theme for GTK3 apps.
gnome-themes-extra gnome-themes-extra
@ -150,9 +125,7 @@ wayland-protocols
# Fonts # Fonts
noto-fonts noto-fonts
# noto-fonts-cjk is ~299 MiB installed / ~196 MiB on the ISO and only useful noto-fonts-cjk
# to CJK-locale users. Install on first run for zh/ja/ko.
# noto-fonts-cjk
noto-fonts-emoji noto-fonts-emoji
ttf-jetbrains-mono ttf-jetbrains-mono
# Nerd font variant — icons in terminal tools (eza --icons, fastfetch, yazi) # Nerd font variant — icons in terminal tools (eza --icons, fastfetch, yazi)
@ -178,12 +151,13 @@ file-roller
# GUI applications a general desktop is expected to have out of the box. # GUI applications a general desktop is expected to have out of the box.
# gnome-text-editor: graphical editor (terminal editors aside); gnome-calculator: # gnome-text-editor: graphical editor (terminal editors aside); gnome-calculator:
# calculator; loupe: Wayland-native image viewer (default for image files). # calculator; loupe: Wayland-native image viewer (default for image files);
# PDF is handled by Zen (skel mimeapps.list maps application/pdf to zen.desktop); # zathura(+pdf-mupdf): lightweight Wayland PDF viewer (BOS had no PDF reader).
# zathura+zathura-pdf-mupdf would pull libmupdf (~56 MiB) as a never-default viewer.
gnome-text-editor gnome-text-editor
gnome-calculator gnome-calculator
loupe loupe
zathura
zathura-pdf-mupdf
# Media player — BOS ships gstreamer codecs but otherwise has no player app. # Media player — BOS ships gstreamer codecs but otherwise has no player app.
vlc vlc
# Web browser (served from the [Breadway] repo; AUR zen-browser-bin republished # Web browser (served from the [Breadway] repo; AUR zen-browser-bin republished
@ -204,16 +178,19 @@ yay-bin
# Bread ecosystem. # Bread ecosystem.
# #
# breadlock is the only bread* pacman package here (it needs a root-owned # The bread apps themselves (bakery, bread, breadbar, breadbox, breadcrumbs,
# /etc/pam.d/breadlock). Everything else — bakery, bread/breadd/bread-emit/ # breadpad) are NOT pacman packages here — they are bakery-managed binaries
# bread-module-host, breadbar, breadbox, breadcrumbs, breadpad, breadpaper, # baked into /etc/skel/.local/bin at build time (see build-local.sh), so every
# bread-theme, breadmon, breadsearch, breadclip, breadshot, bos-settings, # user gets the exact versions from this laptop's bakery install with no
# breadhelp — is bakery-managed and baked into /usr/local at ISO build # network/DNS needed at install or runtime. Their runtime system deps are pulled
# time from iso/bread-lockfile.toml (see build-local.sh). breadcast and # in elsewhere in this list (gtk4, gtk4-layer-shell, iw, libpulse, librsvg,
# breadarr are not shipped. bos-settings/breadhelp desktop entries are # networkmanager, openssl, zlib, systemd-libs) — keep those even though no bread
# also committed under iso/airootfs/etc/skel/.local/share/applications/. Runtime # package depends on them.
# deps stay listed even though no bread package depends on them via pacman #
# (gtk4, gtk4-layer-shell, webkit2gtk-4.1, iw, libpulse, librsvg, …). # bos-settings and breadhelp are BOS-specific pacman packages (not part of the
# bakery index), so they stay here, served from the [breadway] repo.
bos-settings
breadhelp
# Input / screen utilities # Input / screen utilities
brightnessctl brightnessctl
@ -225,8 +202,6 @@ slurp
wl-clipboard wl-clipboard
playerctl playerctl
# Wallpaper daemon + pywal (drives the bread* colour palette from the wallpaper). # Wallpaper daemon + pywal (drives the bread* colour palette from the wallpaper).
# python-pywal was dropped from Arch [extra] (AUR-only now) — republished to
# [breadway], see packaging/python-pywal.
awww awww
python-pywal python-pywal
# Boot splash (BOS logo + spinner instead of kernel text). # Boot splash (BOS logo + spinner instead of kernel text).
@ -348,3 +323,6 @@ qt6ct
# hyprland.lua) needs these or Qt apps fall back to (blurry) XWayland. # hyprland.lua) needs these or Qt apps fall back to (blurry) XWayland.
qt5-wayland qt5-wayland
qt6-wayland qt6-wayland
# Dev tools (for bos-settings standalone install)
rustup

View file

@ -9,23 +9,6 @@ Architecture = auto
CheckSpace CheckSpace
ParallelDownloads = 5 ParallelDownloads = 5
# Optional NoExtract size levers — left disabled. This file is both the ISO
# build config AND the installed system's pacman.conf, so enabling any line
# also stops future pacman -Syu from restoring those files.
# Measured against the 844-package closure (xz squashfs, profiledef.sh opts):
# usr/share/locale (non-en) 405.0 MiB raw -> 92.28 MiB ISO
# usr/share/doc 130.5 MiB raw -> 26.54 MiB ISO
# usr/share/man 41.5 MiB raw -> 38.77 MiB ISO
# usr/share/info 12.9 MiB raw -> 11.12 MiB ISO
# usr/share/gtk-doc 16.0 MiB raw -> 1.18 MiB ISO
# usr/include 193.6 MiB raw -> 25.26 MiB ISO
# Non-en locales make every GUI English-only until the package is reinstalled
# without this NoExtract; dropping man/info means `man` returns nothing.
#NoExtract = usr/share/locale/* !usr/share/locale/en* !usr/share/locale/locale.alias
#NoExtract = usr/share/doc/* usr/share/gtk-doc/* usr/share/info/*
#NoExtract = usr/share/man/*
#NoExtract = usr/include/*
Color Color
VerbosePkgLists VerbosePkgLists
ILoveCandy ILoveCandy
@ -43,23 +26,20 @@ Include = /etc/pacman.d/mirrorlist
Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Breadway custom repo — breadlock plus AUR republishes the ISO needs # Breadway custom repo — provides: bakery and the bread ecosystem packages
# (calamares, zen-browser-bin, bibata-cursor-theme-bin, yay-bin, # (bread, breadbar, breadbox, breadcrumbs, breadpad, bos-settings).
# zsh-theme-powerlevel10k). bakery / breadbar / bos-settings / breadhelp # (calamares comes from the official extra repo, not here.)
# are NOT here; they are bakery-baked into /usr/local at ISO build time.
# #
# Packages are published to the Forgejo Arch registry (group "os") by the # Packages are published to the Forgejo Arch registry (group "os") by the
# .forgejo/workflows/*.yml workflows; scripts/ci-publish-signed-repo.sh then # .forgejo/workflows/package.yml workflow in each repo, on tag push.
# collects them, detach-signs each .pkg.tar.zst with the BOS release key
# (releases@breadway.dev), runs `repo-add -s`, and publishes the signed db
# at https://dl.breadway.dev/arch/$arch (signed-repo.yml).
# #
# SigLevel = Required: every package AND the db carry a .sig from key # Forgejo signs the repo db with a key pacman can't look up, so TrustAll
# 56203B86A110695AE7F310934AF3323D678EB5E2 — the same key committed as # fails. SigLevel = Never skips verification (acceptable for this private
# KEYS.asc / airootfs/etc/pacman.d/breadway-repo.asc, imported into the # repo over TLS). Future improvement: import Forgejo's signing key and
# pacman keyring at build time (build-local.sh), on the live medium, and # switch to SigLevel = Required for full package verification.
# on the installed target (calamares/post-install.sh).
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
[breadway] # The section name must match Forgejo's served db filename
SigLevel = Required # ({owner}.{group}.{domain}.db) — pacman fetches "<section>.db" from Server.
Server = https://dl.breadway.dev/arch/$arch [Breadway.os.git.breadway.dev]
SigLevel = Never
Server = https://git.breadway.dev/api/packages/Breadway/arch/os/$arch

View file

@ -8,12 +8,7 @@ iso_application="Bread Operating System"
iso_version="$(date +%Y.%m.%d)" iso_version="$(date +%Y.%m.%d)"
install_dir="arch" install_dir="arch"
buildmodes=('iso') buildmodes=('iso')
# systemd-boot can only read files from the ESP it was launched from, so bootmodes=('bios.syslinux' 'uefi.systemd-boot')
# mkarchiso's _make_bootmode_uefi.systemd-boot copies vmlinuz + initramfs
# INTO the FAT efiboot.img on top of the copy already on ISO9660 (~244 MiB
# duplicate). uefi.grub's ESP is only EFI + shell*.efi — GRUB reads ISO9660
# directly. iso/grub/{grub,loopback}.cfg are already BOS-branded.
bootmodes=('bios.syslinux' 'uefi.grub')
arch="x86_64" arch="x86_64"
pacman_conf="pacman.conf" pacman_conf="pacman.conf"
airootfs_image_type="squashfs" airootfs_image_type="squashfs"
@ -29,8 +24,4 @@ file_permissions=(
["/usr/local/bin/bos-session"]="0:0:755" ["/usr/local/bin/bos-session"]="0:0:755"
["/usr/local/bin/bos-netcheck"]="0:0:755" ["/usr/local/bin/bos-netcheck"]="0:0:755"
["/usr/local/bin/bos-update"]="0:0:755" ["/usr/local/bin/bos-update"]="0:0:755"
["/usr/local/bin/bos-rescue"]="0:0:755"
["/usr/local/bin/bos-first-boot"]="0:0:755"
["/usr/local/bin/bos-nvidia-setup"]="0:0:755"
["/usr/local/bin/bos-enable-bakery-user-units"]="0:0:755"
) )

View file

@ -1,19 +1,39 @@
Arch packaging Arch packaging
============== ==============
This directory only holds `PKGBUILD`s for third-party AUR packages BOS `breadhelp/PKGBUILD` builds and installs `breadhelp` from source — BOS's
republishes to the `[breadway]` pacman repo (`calamares`, `bibata`, onboarding + help center, and the only first-party pacman package still built
`powerlevel10k`, `yay-bin`, `python-pywal`) — not the user's own code. See each from this repo.
subdirectory's `.forgejo/workflows/<name>.yml` (in this repo) for how each
one publishes on a push to `packaging/<name>/**`.
Every bread-ecosystem app (bakery, bread, breadbar, breadbox, breadcrumbs, `bos-settings` is also pacman-packaged and served from the same [breadway]
breadpad, breadpaper, breadmon, breadsearch, breadclip, breadshot, repo, but its source lives in its own repo now (`~/Projects/bos-settings`,
bos-settings, breadhelp, ...) is bakery-managed, not pacman-packaged — see `git.breadway.dev/Breadway/bos-settings`) so a bos-settings release doesn't require
`iso/bread-lockfile.toml` (`required_bins` + `optional_bins`), which a BOS ISO release.
`build-local.sh` uses as the name list when baking this machine's bakery
install into the ISO's `/etc/skel`. Everything else the bread ecosystem ships (breadbar, breadbox, breadpad, ...)
`breadlock` is the sole deliberate exception (it needs a root-owned is bakery-managed, not pacman-packaged — see `build-local.sh`.
`/etc/pam.d/breadlock` PAM service file, which bakery — by design — has no
privileged-install path for) and stays on pacman only; see ## Local build
`bread-ecosystem/docs/release-channels.md` for the full policy.
```bash
cd breadhelp && makepkg -si
```
## Before publishing to [breadway] repo
CI (`.forgejo/workflows/package.yml`) handles this on tag push (`vX.Y.Z`): it
bumps `pkgver`, archives the repo into the expected tarball name, and runs
`makepkg`. To do it by hand instead:
1. Tag a release on GitHub (`vX.Y.Z`).
2. Update `pkgver` in `breadhelp/PKGBUILD` to match the tag.
3. Update `source` to the release tarball URL.
4. Run `updpkgsums` (or manually set `sha256sums`).
## Runtime dependencies
| Package | Required | Notes |
|---------|----------|-------|
| `gtk4` | yes | UI toolkit |
| `glib2` | yes | always |
| `snapper` | optional | create-backup one-click fix |

View file

@ -16,13 +16,7 @@ options=('!strip')
source=("${pkgname%-bin}-$pkgver.tar.xz::$url/releases/download/v$pkgver/Bibata.tar.xz") source=("${pkgname%-bin}-$pkgver.tar.xz::$url/releases/download/v$pkgver/Bibata.tar.xz")
sha256sums=('172e33c4ae415278384dcecc7d1a9b7a024266bc944bc751fd86532be1cc6251') sha256sums=('172e33c4ae415278384dcecc7d1a9b7a024266bc944bc751fd86532be1cc6251')
# Upstream tarball has all 12 variants (~322 MiB). BOS only ever selects
# Bibata-Modern-Ice (hyprland.lua XCURSOR_THEME, gsettings, gtk settings.ini).
# Ship that plus its -Right sibling.
_variants=(Bibata-Modern-Ice Bibata-Modern-Ice-Right)
package() { package() {
install -d "$pkgdir/usr/share/icons" install -d "$pkgdir/usr/share/icons"
for v in "${_variants[@]}"; do cp -r Bibata* "$pkgdir/usr/share/icons"
cp -r "$v" "$pkgdir/usr/share/icons/"
done
} }

View file

@ -1,44 +0,0 @@
# BOS in-house rebuild of python-pywal.
#
# python-pywal was dropped from Arch's [extra] repo (it is now AUR-only), but
# BOS needs the `wal` binary: bread-theme shells out to it to extract a colour
# palette from the user's wallpaper. Republished to [breadway] so the ISO can
# pull it via pacman, same pattern as calamares / bibata / powerlevel10k /
# yay-bin. Source of truth: https://aur.archlinux.org/packages/python-pywal
#
# Maintainer: Breadway <plasticbread849@gmail.com>
# Upstream maintainer: Morten Linderud <foxboron@archlinux.org>
# Contributor: Sean Haugh <seanphaugh@gmail.com>
pkgname=python-pywal
pkgver=3.3.0
pkgrel=11
pkgdesc="Generate and change colorschemes on the fly"
arch=('any')
url="https://github.com/dylanaraps/pywal/"
license=('MIT')
depends=('python' 'imagemagick')
makedepends=('python-build' 'python-installer' 'python-wheel' 'python-setuptools')
optdepends=('feh: set wallpaper'
'nitrogen: set wallpaper')
# BOS PKGBUILDs verify sources by sha256 only (no source PGP), matching
# calamares / powerlevel10k here.
source=("$pkgname-$pkgver.tar.gz::https://github.com/dylanaraps/pywal/archive/${pkgver}.tar.gz")
sha256sums=('fe8fc1c29d1cad1a1a8580293dcfe32e1fac259f9dbfd5c8877439fa5948d189')
build() {
cd "pywal-${pkgver}"
# setup.py-only project: python-build injects the setuptools backend.
python -m build --wheel --no-isolation
}
check() {
cd "pywal-${pkgver}"
python -m unittest discover -vs tests
}
package() {
cd "pywal-${pkgver}"
python -m installer --destdir="$pkgdir" dist/*.whl
install -Dm644 LICENSE.md "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}

View file

@ -1,308 +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
python-pywal
)
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"
# gpg writes 0600; nginx and the next publish need world-readable files.
find . -maxdepth 1 -type f -exec chmod a+r {} + || true
}
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:.
# Run as the runner user: root-owned 0600 .sig files made chmod/nginx fail
# (run 1050) and would block the next `rm -rf` of a previous tree.
"$rt" run --rm --network=host \
--user "$(id -u):$(id -g)" \
-e HOME=/tmp \
-e TMPDIR=/tmp \
-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" || true
# gpg --detach-sign often writes 0600 files the runner cannot chmod;
# do not fail the publish after repo-add -s already succeeded.
find "$STAGE" -type f -exec chmod a+r {} + || true
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,497 +0,0 @@
#!/usr/bin/env python3
"""Stage bakery artifacts from the verified stable index into $LAPTOP_HOME.
Used by .forgejo/workflows/release-iso.yml so the ISO bake does not invent
binaries, fake installed.json, or cargo-build bread-theme. Never downloads
breadcast or breadarr.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tarfile
import tempfile
import tomllib
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urljoin, urlparse
INDEX_URL = "https://dl.breadway.dev/index.json"
DL_ORIGIN = "https://dl.breadway.dev"
# Same key as bread-ecosystem/scripts/get.sh and bakery/src/manifest.rs.
MINISIGN_PUBKEY = "RWTBR8w/IJ+jaylOv80b52DzekKbSR2CvOVGvzB0ipGBaMhJPAOiEWq8"
BLOCKED = frozenset({"breadcast", "breadarr"})
ARCH_SUFFIXES = ("-x86_64", "-aarch64", "-arm64", "-armv7")
def die(msg: str) -> None:
print(f"ERROR: {msg}", file=sys.stderr)
raise SystemExit(1)
def dest_name(name: str) -> str:
for suf in ARCH_SUFFIXES:
if name.endswith(suf):
return name[: -len(suf)]
return name
def valid_name(name: str) -> bool:
return bool(name) and "/" not in name and name not in (".", "..")
def load_versions(data: dict, path: Path) -> dict[str, str]:
"""Optional [versions] map and/or [[pin]] tables → package → version."""
versions: dict[str, str] = {}
raw_map = data.get("versions")
if raw_map is not None:
if not isinstance(raw_map, dict):
die(f"{path}: [versions] must be a table of package = \"version\"")
for pkg, ver in raw_map.items():
if not isinstance(pkg, str) or not valid_name(pkg):
die(f"{path}: invalid [versions] package {pkg!r}")
if not isinstance(ver, str) or not valid_name(ver):
die(f"{path}: invalid [versions] version for {pkg}: {ver!r}")
versions[pkg] = ver
pins = data.get("pin")
if pins is not None:
if not isinstance(pins, list):
die(f"{path}: [[pin]] must be an array of tables")
for i, entry in enumerate(pins):
if not isinstance(entry, dict):
die(f"{path}: [[pin]] #{i} must be a table")
pkg = entry.get("package", entry.get("pkg"))
ver = entry.get("version")
if not isinstance(pkg, str) or not valid_name(pkg):
die(f"{path}: [[pin]] #{i}: missing valid package")
if not isinstance(ver, str) or not valid_name(ver):
die(f"{path}: [[pin]] #{i}: missing valid version")
if pkg in versions and versions[pkg] != ver:
die(f"{path}: conflicting pin for {pkg}: {versions[pkg]} vs {ver}")
versions[pkg] = ver
return versions
def load_lockfile(path: Path) -> tuple[list[str], list[str], dict[str, str]]:
with path.open("rb") as f:
data = tomllib.load(f)
required = data.get("required_bins")
optional = data.get("optional_bins") or []
if required is None:
required = data.get("bins") or data.get("binaries")
if not isinstance(required, list) or not required:
die(f"{path}: missing non-empty required_bins (or bins) list")
if not isinstance(optional, list):
die(f"{path}: optional_bins must be a list")
for label, names in (("required_bins", required), ("optional_bins", optional)):
for b in names:
if not isinstance(b, str) or not valid_name(b):
die(f"{path}: invalid {label} name {b!r}")
if b in BLOCKED:
die(f"{path}: {b} is not shipped on the ISO")
overlap = set(required) & set(optional)
if overlap:
die(f"{path}: bins in both required and optional: {sorted(overlap)}")
return list(required), list(optional), load_versions(data, path)
def pinned_artifact_url(pkg: str, version: str, filename: str) -> str:
if not valid_name(pkg) or not valid_name(version) or not valid_name(filename):
die(f"refusing pinned URL with unsafe path {pkg}/{version}/{filename}")
return f"{DL_ORIGIN}/{pkg}/{version}/{filename}"
def package_base_url(pkg_name: str, versions: dict[str, str], first_url: str) -> str:
pin = versions.get(pkg_name)
if pin:
if not valid_name(pkg_name) or not valid_name(pin):
die(f"refusing pinned version dir {pkg_name}/{pin}")
return f"{DL_ORIGIN}/{pkg_name}/{pin}/"
return version_dir(first_url)
def fetch(url: str, dest: Path, *, required: bool = True) -> bool:
dest.parent.mkdir(parents=True, exist_ok=True)
try:
urllib.request.urlretrieve(url, dest)
return True
except (urllib.error.URLError, OSError) as e:
if required:
die(f"download failed: {url}: {e}")
print(f"WARN: download failed: {url}: {e}", file=sys.stderr)
if dest.exists():
dest.unlink()
return False
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def require_sha256(value: object, what: str) -> str:
if not isinstance(value, str) or not value.strip():
die(f"{what}: index sha256 is required and must be non-empty")
return value.strip().lower()
def verify_sha256(path: Path, expected: str, what: str) -> None:
actual = sha256_file(path)
if actual != expected:
die(f"{what}: sha256 mismatch (expected {expected}, got {actual})")
def version_dir(first_dl_url: str) -> str:
parsed = urlparse(first_dl_url)
parent = parsed.path.rsplit("/", 1)[0]
return f"{parsed.scheme}://{parsed.netloc}{parent}/"
def verify_index(index_path: Path, sig_path: Path) -> None:
if shutil.which("minisign") is None:
die("minisign is not installed — refuse to trust an unsigned index")
cmd = [
"minisign",
"-V",
"-q",
"-m",
str(index_path),
"-x",
str(sig_path),
"-P",
MINISIGN_PUBKEY,
]
result = subprocess.run(cmd, check=False)
if result.returncode != 0:
die("index.json minisign verification FAILED — refusing to proceed")
print("index.json minisign OK")
def bin_index(packages: dict) -> dict[str, tuple[str, dict, dict]]:
out: dict[str, tuple[str, dict, dict]] = {}
for pkg_name, pkg in packages.items():
if pkg_name in BLOCKED:
continue
for b in pkg.get("binaries") or []:
if not isinstance(b, dict):
continue
raw = b.get("name")
if not isinstance(raw, str):
continue
dest = dest_name(raw)
if dest in BLOCKED or pkg_name in BLOCKED:
continue
if dest in out and out[dest][0] != pkg_name:
die(f"index publishes {dest} from both {out[dest][0]} and {pkg_name}")
out[dest] = (pkg_name, pkg, b)
return out
def patch_exec_start(text: str, bin_dir: Path) -> str:
lines = []
for line in text.splitlines():
if line.lstrip().startswith("ExecStart="):
rest = line.split("=", 1)[1]
argv = rest.split()
if argv:
name = os.path.basename(argv[0])
new_path = bin_dir / name
if len(argv) == 1:
line = f"ExecStart={new_path}"
else:
line = f"ExecStart={new_path} {' '.join(argv[1:])}"
lines.append(line)
out = "\n".join(lines)
if text.endswith("\n"):
out += "\n"
return out
def wanted_by(text: str) -> list[str]:
targets: list[str] = []
for line in text.splitlines():
if line.startswith("WantedBy="):
targets.extend(line.split("=", 1)[1].split())
return targets or ["default.target"]
def assert_safe_archive(path: Path) -> None:
with tarfile.open(path, "r:gz") as tf:
for info in tf.getmembers():
name = info.name
if info.issym() or info.islnk():
die(f"refusing archive with symlink entry {name!r}")
if name.startswith("/") or any(p in ("..", "") for p in Path(name).parts if p == ".."):
die(f"refusing archive with unsafe path {name!r}")
if Path(name).is_absolute() or ".." in Path(name).parts:
die(f"refusing archive with unsafe path {name!r}")
def stage_file(
url: str,
dest: Path,
sha256: str | None,
what: str,
mode: int | None = None,
*,
required: bool = True,
) -> bool:
if not fetch(url, dest, required=required):
return False
if sha256 is not None:
verify_sha256(dest, sha256, what)
if mode is not None:
dest.chmod(mode)
return True
def main() -> int:
# CI logs mix stdout/stderr; keep them in source order.
try:
sys.stdout.reconfigure(line_buffering=True)
sys.stderr.reconfigure(line_buffering=True)
except (AttributeError, OSError):
pass
repo = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--home",
default=os.environ.get("LAPTOP_HOME", "/build-home"),
help="builder home to populate (default: $LAPTOP_HOME or /build-home)",
)
parser.add_argument(
"--lockfile",
default=str(repo / "iso" / "bread-lockfile.toml"),
)
parser.add_argument("--index-url", default=INDEX_URL)
args = parser.parse_args()
home = Path(args.home)
lockfile = Path(args.lockfile)
if not lockfile.is_file():
die(f"lockfile missing: {lockfile}")
required, optional, versions = load_lockfile(lockfile)
print(
f"lockfile {lockfile}: {len(required)} required, {len(optional)} optional"
+ (f", {len(versions)} pinned" if versions else "")
)
bin_dir = home / ".local" / "bin"
state_dir = home / ".local" / "state" / "bakery"
cache_dir = home / ".cache" / "bakery"
share_dir = home / ".local" / "share"
unit_dir = home / ".config" / "systemd" / "user"
for d in (bin_dir, state_dir, cache_dir, share_dir, unit_dir):
d.mkdir(parents=True, exist_ok=True)
index_path = cache_dir / "index.json"
sig_path = cache_dir / "index.json.minisig"
print(f"fetch {args.index_url}")
fetch(args.index_url, index_path)
print(f"fetch {args.index_url}.minisig")
fetch(args.index_url + ".minisig", sig_path)
verify_index(index_path, sig_path)
with index_path.open() as f:
idx = json.load(f)
packages = idx.get("packages")
if not isinstance(packages, dict):
die("index.json: missing packages object")
published = bin_index(packages)
selected: dict[str, dict] = {}
installed_bins: dict[str, list[str]] = {}
installed_sha: dict[str, dict[str, str]] = {}
fetched_url: dict[str, str] = {}
pin_warned: set[str] = set()
def pin_digest(pkg_name: str, pkg: dict, value: object, what: str) -> str | None:
"""Index sha256 is only valid when it describes the pinned version."""
digest = require_sha256(value, what)
pin = versions.get(pkg_name)
if pin and str(pkg.get("version")) != pin:
if pkg_name not in pin_warned:
print(
f"WARN: {pkg_name} pin {pin} != index {pkg.get('version')}; "
f"fetching pinned URL without index sha256",
file=sys.stderr,
)
pin_warned.add(pkg_name)
return None
return digest
def take_bin(name: str, *, required_bin: bool) -> bool:
hit = published.get(name)
if hit is None:
if required_bin:
die(f"required bin {name!r} is not in the verified stable index")
print(f"WARN: optional bin {name} not in index — skipping", file=sys.stderr)
return False
pkg_name, pkg, binary = hit
if pkg_name in BLOCKED or name in BLOCKED:
die(f"refusing blocked package/bin {pkg_name}/{name}")
raw = binary.get("name")
index_url = binary.get("dl_url")
pin = versions.get(pkg_name)
if pin:
if not isinstance(raw, str) or not valid_name(raw):
die(f"{name}: missing binary filename for pinned URL")
url = pinned_artifact_url(pkg_name, pin, raw)
else:
url = index_url
if not isinstance(url, str) or not url:
die(f"{name}: missing dl_url")
digest = pin_digest(pkg_name, pkg, binary.get("sha256"), f"binary {name}")
dest = bin_dir / name
note = f" (pin {pkg_name}={pin})" if pin else ""
print(f" {name} <- {url}{note}")
if not stage_file(
url, dest, digest, f"binary {name}", mode=0o755, required=required_bin
):
return False
selected[pkg_name] = pkg
installed_bins.setdefault(pkg_name, []).append(name)
if digest is not None:
installed_sha.setdefault(pkg_name, {})[name] = digest
fetched_url.setdefault(pkg_name, url)
return True
for name in required:
take_bin(name, required_bin=True)
for name in optional:
take_bin(name, required_bin=False)
if not selected:
die("no packages selected from lockfile ∩ index")
now = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
installed: dict[str, dict] = {}
for pkg_name, pkg in sorted(selected.items()):
bins = pkg.get("binaries") or []
first_url = fetched_url.get(pkg_name)
if not first_url:
for b in bins:
if isinstance(b, dict) and b.get("dl_url"):
first_url = b["dl_url"]
break
if not first_url:
die(f"{pkg_name}: no binary dl_url to derive version dir")
base = package_base_url(pkg_name, versions, first_url)
service_names: list[str] = []
for svc in pkg.get("services") or []:
if not isinstance(svc, dict):
die(f"{pkg_name}: service entry must be an object with unit + sha256")
unit = svc.get("unit")
if not isinstance(unit, str) or not valid_name(unit):
die(f"{pkg_name}: invalid service unit {unit!r}")
digest = pin_digest(pkg_name, pkg, svc.get("sha256"), f"{pkg_name} {unit}")
dest = unit_dir / unit
url = urljoin(base, unit)
print(f" {unit} <- {url}")
fetch(url, dest)
if digest is not None:
verify_sha256(dest, digest, f"unit {unit}")
dest.write_text(patch_exec_start(dest.read_text(), bin_dir))
dest.chmod(0o644)
if svc.get("enable"):
for target in wanted_by(dest.read_text()):
if not valid_name(target):
die(f"{unit}: invalid WantedBy {target!r}")
wants = unit_dir / f"{target}.wants"
wants.mkdir(parents=True, exist_ok=True)
link = wants / unit
if link.exists() or link.is_symlink():
link.unlink()
link.symlink_to(Path("..") / unit)
print(f" enabled {target}.wants/{unit}")
service_names.append(unit)
archive = pkg.get("data_archive")
if archive:
if not isinstance(archive, str) or not valid_name(archive):
die(f"{pkg_name}: invalid data_archive {archive!r}")
digest = pin_digest(
pkg_name, pkg, pkg.get("data_archive_sha256"), f"{pkg_name} {archive}"
)
url = urljoin(base, archive)
data_dir = share_dir / pkg_name
data_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix=f"bos-{pkg_name}-") as tmp:
tmp_path = Path(tmp) / archive
print(f" {archive} <- {url}")
stage_file(url, tmp_path, digest, f"{pkg_name} {archive}")
assert_safe_archive(tmp_path)
subprocess.run(
[
"tar",
"xzf",
str(tmp_path),
"--no-same-owner",
"--no-same-permissions",
"-C",
str(data_dir),
],
check=True,
)
print(f" extracted to {data_dir}")
desktop = pkg.get("desktop_file")
if desktop:
if not isinstance(desktop, str) or not valid_name(desktop):
die(f"{pkg_name}: invalid desktop_file {desktop!r}")
digest = pin_digest(
pkg_name, pkg, pkg.get("desktop_file_sha256"), f"{pkg_name} {desktop}"
)
dest = share_dir / "applications" / f"{pkg_name}.desktop"
stage_file(urljoin(base, desktop), dest, digest, f"{pkg_name} {desktop}")
license_file = pkg.get("license_file")
if license_file:
if not isinstance(license_file, str) or not valid_name(license_file):
die(f"{pkg_name}: invalid license_file {license_file!r}")
digest = pin_digest(
pkg_name, pkg, pkg.get("license_file_sha256"), f"{pkg_name} {license_file}"
)
dest = share_dir / "licenses" / pkg_name / "LICENSE"
stage_file(urljoin(base, license_file), dest, digest, f"{pkg_name} {license_file}")
installed[pkg_name] = {
"name": pkg_name,
"version": versions.get(pkg_name, pkg.get("version")),
"binaries": installed_bins.get(pkg_name, []),
"services": service_names,
"installed_at": now,
"track": "stable",
"binary_sha256": installed_sha.get(pkg_name, {}),
}
if "breadhelp" in installed:
content = share_dir / "breadhelp" / "content"
if not content.is_dir():
die(f"breadhelp data_archive did not produce {content}")
state_path = state_dir / "installed.json"
state_path.write_text(json.dumps({"track": "stable", "packages": installed}, indent=2) + "\n")
print(f"installed.json written ({len(installed)} packages): {', '.join(sorted(installed))}")
print(f"staged bins: {', '.join(sorted(p.name for p in bin_dir.iterdir() if p.is_file()))}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,231 +0,0 @@
#!/usr/bin/env bash
# Read-only checks that a builder home (and optionally a staged image) has
# everything build-local.sh needs before mkarchiso. Exit non-zero on failure.
#
# Builder home stays user-layout (~/.local). The image is system-prefix
# /usr/local; pass SKEL and/or AIROOTFS to check those destinations.
#
# LAPTOP_HOME=/build-home ./scripts/ci-verify-bake.sh
# SKEL=/tmp/bos-iso-stage/airootfs/etc/skel ./scripts/ci-verify-bake.sh
# AIROOTFS=/tmp/bos-iso-stage/airootfs ./scripts/ci-verify-bake.sh
set -euo pipefail
REPO="$(cd "$(dirname "$0")/.." && pwd)"
LOCKFILE="${LOCKFILE:-$REPO/iso/bread-lockfile.toml}"
LAPTOP_HOME="${LAPTOP_HOME:-/build-home}"
SKEL="${SKEL:-}"
AIROOTFS="${AIROOTFS:-}"
pass=0
fail=0
ok() { printf ' PASS %s\n' "$1"; pass=$((pass + 1)); }
bad() { printf ' FAIL %s\n' "$1" >&2; fail=$((fail + 1)); }
if [[ ! -f "$LOCKFILE" ]]; then
echo "ERROR: lockfile missing: $LOCKFILE" >&2
exit 1
fi
eval "$(python3 - "$LOCKFILE" <<'PY'
import sys, tomllib
path = sys.argv[1]
with open(path, "rb") as f:
data = tomllib.load(f)
required = data.get("required_bins")
optional = data.get("optional_bins") or []
if required is None:
required = data.get("bins") or data.get("binaries") or []
def emit(name, values):
print(f"{name}=(")
for v in values:
print(f" {v!r}")
print(")")
emit("REQUIRED_BINS", required)
emit("OPTIONAL_BINS", optional)
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() {
local path="$1" label="$2"
if [[ -x "$path" && -f "$path" ]]; then
ok "$label executable: $path"
else
bad "$label missing or not executable: $path"
fi
}
check_dir() {
local path="$1" label="$2"
if [[ -d "$path" ]]; then
ok "$label: $path"
else
bad "$label missing: $path"
fi
}
check_file() {
local path="$1" label="$2"
if [[ -f "$path" ]]; then
ok "$label: $path"
else
bad "$label missing: $path"
fi
}
for b in "${REQUIRED_BINS[@]}"; do
check_exec "$LAPTOP_HOME/.local/bin/$b" "required bin $b"
done
for b in "${OPTIONAL_BINS[@]}"; do
if [[ -x "$LAPTOP_HOME/.local/bin/$b" ]]; then
ok "optional bin $b present"
else
printf ' ---- optional bin %s not staged (ok until bread ships it)\n' "$b"
fi
done
check_dir "$LAPTOP_HOME/.local/share/breadhelp/content" "breadhelp content"
check_file "$LAPTOP_HOME/.cache/bakery/index.json" "bakery index cache"
check_file "$LAPTOP_HOME/.local/state/bakery/installed.json" "bakery installed.json"
mapfile -t UNITS < <(python3 - "$LAPTOP_HOME/.local/state/bakery/installed.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
)
if [[ ${#UNITS[@]} -eq 0 ]]; then
bad "installed.json lists no service units"
else
for unit in "${UNITS[@]}"; do
[[ -n "$unit" ]] || continue
check_file "$LAPTOP_HOME/.config/systemd/user/$unit" "unit $unit"
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
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
[[ -n "$unit" ]] || continue
check_file "$AIROOTFS/usr/lib/systemd/user/$unit" "image unit $unit"
if [[ -f "$AIROOTFS/usr/lib/systemd/user/$unit" ]]; then
if grep -q '^ExecStart=/usr/local/bin/' "$AIROOTFS/usr/lib/systemd/user/$unit"; then
ok "image unit $unit ExecStart uses /usr/local/bin"
elif grep -q '^ExecStart=' "$AIROOTFS/usr/lib/systemd/user/$unit"; then
bad "image unit $unit ExecStart is not /usr/local/bin: $(grep '^ExecStart=' "$AIROOTFS/usr/lib/systemd/user/$unit")"
fi
fi
done
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
printf 'Result: %d passed, %d failed\n' "$pass" "$fail"
[[ "$fail" -eq 0 ]]

View file

@ -40,13 +40,13 @@ check "grub-btrfs present" "pacman -Qq grub-btrfs"
echo "== enabled system services ==" echo "== enabled system services =="
for unit in NetworkManager.service greetd.service bluetooth.service tlp.service \ for unit in NetworkManager.service greetd.service bluetooth.service tlp.service \
cups.socket avahi-daemon.socket ufw.service systemd-timesyncd.service; do cups.socket avahi-daemon.service ufw.service systemd-timesyncd.service; do
check "$unit enabled" "systemctl is-enabled $unit" check "$unit enabled" "systemctl is-enabled $unit"
done done
check "graphical.target is default" "[ \"\$(systemctl get-default)\" = graphical.target ]" check "graphical.target is default" "[ \"\$(systemctl get-default)\" = graphical.target ]"
echo "== bread ecosystem on PATH ==" echo "== bread ecosystem on PATH =="
for bin in bakery bread breadd bread-emit bread-module-host breadbar breadbox breadbox-sync breadcrumbs breadpad breadman; do for bin in bakery bread breadd breadbar breadbox breadbox-sync breadcrumbs breadpad breadman; do
check "$bin found" "command -v $bin" check "$bin found" "command -v $bin"
done done
@ -55,56 +55,15 @@ check "bos-settings installed" "command -v bos-settings"
echo "== breadhelp ==" echo "== breadhelp =="
check "breadhelp installed" "command -v breadhelp" check "breadhelp installed" "command -v breadhelp"
check "breadhelp content installed" \ check "breadhelp content installed" "[ -d /usr/share/breadhelp/content ]"
"[ -d /usr/local/share/breadhelp/content ] || [ -d \"\$HOME/.local/share/breadhelp/content\" ]"
check "bos-netcheck present" "command -v bos-netcheck" check "bos-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 ==" echo "== default dotfiles =="
check "hyprland.lua present" "[ -f \"\$HOME/.config/hypr/hyprland.lua\" ]" 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 "binds.json present" "[ -f \"\$HOME/.config/hypr/binds.json\" ]"
check "monitors.json present" "[ -f \"\$HOME/.config/hypr/monitors.json\" ]" check "monitors.json present" "[ -f \"\$HOME/.config/hypr/monitors.json\" ]"
check "settings.json present" "[ -f \"\$HOME/.config/hypr/settings.json\" ]" check "settings.json present" "[ -f \"\$HOME/.config/hypr/settings.json\" ]"
check "autostart.json present" "[ -f \"\$HOME/.config/hypr/autostart.json\" ]" check "autostart.json present" "[ -f \"\$HOME/.config/hypr/autostart.json\" ]"
check "autostart includes first-boot probe" "grep -q bos-first-boot \"\$HOME/.config/hypr/autostart.json\""
check "hypr scripts/lib present" "[ -f \"\$HOME/.config/hypr/scripts/lib/json.lua\" ]" check "hypr scripts/lib present" "[ -f \"\$HOME/.config/hypr/scripts/lib/json.lua\" ]"
check "mimeapps.list present" "[ -f \"\$HOME/.config/mimeapps.list\" ]" check "mimeapps.list present" "[ -f \"\$HOME/.config/mimeapps.list\" ]"
check "kitty config present" "[ -f \"\$HOME/.config/kitty/kitty.conf\" ]" check "kitty config present" "[ -f \"\$HOME/.config/kitty/kitty.conf\" ]"