Compare commits

..

55 commits
v0.4.1 ... main

Author SHA1 Message Date
2b8eb2f1b6 Merge pull request 'skel/hypr: GSK_RENDERER=cairo + wire up shell layer rules' (#7) from fix/gsk-renderer-black-surfaces into main 2026-08-31 20:28:01 +08:00
Breadway
232ee0c7a9 skel/hypr: GSK_RENDERER=cairo + wire up the shell layer rules
Two fixes for the black-shell / no-blur symptoms (seen first in a VM,
but the renderer one bites real hardware too):

1. `GSK_RENDERER=cairo` in the session env. GTK4's default renderer
   (ngl/vulkan on 4.14+) draws transparent layer-shell surfaces as
   opaque black on wlroots — the whole bread shell (breadbox launcher,
   breadclip popup, breadbar, breadhelp, bos-settings) goes black-on-
   black, and it's guaranteed under a VM's virtio-gpu where there's no
   real GL/Vulkan context. cairo (software) always composites
   transparency correctly; the shell is flat colour/text/icons so
   there's no visible cost, and idle memory drops (~40%, no Mesa
   driver resident). Override with GSK_RENDERER=gl in the session for a
   GPU-heavy GTK4 app.

2. `scripts/ui/rules.lua` (new) + a `pcall(dofile ...)` for it in
   hyprland.lua + `bread-theme layerrules` in the bootstrap. The
   shell-theme work added layer-rule *generation* (bread-theme writes
   ~/.config/hypr/layerrules.json from the theme's [compositor] table)
   but BOS never shipped the read side, so no blur / ignore-alpha /
   per-namespace motion was ever applied to breadbar / breadbox /
   breadclip. rules.lua reads the JSON and emits `hl.layer_rule`,
   with the pre-theme hardcoded rule set as a pcall-guarded fallback
   (matches the live reference config). Layer rules only — window /
   workspace / focus rules stay in hyprland.lua.
2026-08-31 20:27:41 +08:00
87887f09af Merge pull request 'ISO: flip [breadway] to the signed dl.breadway.dev/arch repo' (#5) from fix/breadway-repo-signing into main
Reviewed-on: #5
2026-08-31 19:18:50 +08:00
e3cdf33f83 Merge pull request 'packaging: republish python-pywal to [breadway]' (#6) from fix/republish-python-pywal into main
Some checks failed
Build and publish python-pywal / python-pywal (push) Failing after 47s
Reviewed-on: #6
2026-08-31 19:18:37 +08:00
Breadway
716c77f93b packaging: republish python-pywal to [breadway]
All checks were successful
Build and publish python-pywal / python-pywal (push) Successful in 1m21s
python-pywal was dropped from Arch's [extra] repo (AUR-only now), so
`pacstrap` can no longer resolve it and every ISO build fails with
"target not found: python-pywal". The `wal` binary is load-bearing —
bread-theme shells out to it to extract the colour palette from the
user's wallpaper.

Republish it the same way as calamares / bibata / powerlevel10k /
yay-bin:

- `packaging/python-pywal/PKGBUILD` — in-house copy of the AUR PKGBUILD
  (Morten Linderud's), modernised to `python -m build` / `installer`
  instead of the removed `setup.py install`, sha256-only sources like
  the sibling PKGBUILDs. Test-built locally: 28 unit tests pass, package
  ships `/usr/bin/wal`.
- `.forgejo/workflows/python-pywal.yml` — builds + PUTs to the Forgejo
  Arch registry on a push to `packaging/python-pywal/**`.
- `signed-repo.yml` gains it as a `workflow_run` trigger;
  `ci-publish-signed-repo.sh` gains it in `PACKAGES` so the signed
  dl.breadway.dev/arch db picks it up.
- packages.x86_64 keeps the `python-pywal` line (now sourced from
  [breadway]) with a note.

Ordering: `python-pywal.yml` must publish to the registry once before
`signed-repo.yml` runs, or the collect step errors "registry db missing
packages: python-pywal".
2026-08-31 19:15:38 +08:00
Breadway
98cfe9d60b ISO: flip [breadway] to the signed dl.breadway.dev/arch repo
The signed repo is live: https://dl.breadway.dev/arch/x86_64/ serves
breadway.db + .files + every .pkg.tar.zst with a detached .sig from the
BOS release key (56203B86A110695AE7F310934AF3323D678EB5E2 = KEYS.asc),
rebuilt from the Forgejo Arch registry by signed-repo.yml +
scripts/ci-publish-signed-repo.sh. Verified: db/pkg sigs all GPG-good
against KEYS.asc.

Executes the "After the signed repo exists" plan in docs/signed-repo.md:

- `iso/pacman.conf` + `iso/airootfs/etc/pacman.conf`: section renamed
  `[Breadway.os.git.breadway.dev]` → `[breadway]` (matches breadway.db),
  `Server = https://dl.breadway.dev/arch/$arch`, `SigLevel = Required`.
  The old "Forgejo has no db sigs / KEYS.asc is not a repo key / do NOT
  flip" comments are gone — both are now false.
- `iso/airootfs/etc/pacman.d/breadway-repo.asc`: the public key, baked
  into the image.
- `build-local.sh`: trust the key in the build host's pacman keyring
  before mkarchiso (so pacstrap can verify [breadway] while assembling
  the airootfs); drop the now-obsolete Forgejo-registry URL rewrite.
- `iso/airootfs/root/customize_airootfs.sh` (new): trust the key in the
  image keyring so the live medium — and, via calamares unpackfs, the
  installed target — verify [breadway]. (archiso warns this hook is
  deprecated; there is no replacement for "add a repo key to the image
  keyring" and BOS ships no pacman-init.service.)
- `calamares/post-install.sh`: `pacman-key --add` + `--lsign-key` the
  BOS key in the target chroot as a fallback (unpackfs can skip
  /etc/pacman.d/gnupg).
- README.md / DESIGN.md / docs/signed-repo.md updated.

NOT yet done: build the ISO (`sudo ./build-local.sh`) and VM-verify
`pacman -Sy` + a `[breadway]` install with no signature prompt, on both
the live medium and a fresh install. The build-time keyring path
(pacstrap -G vs host keyring vs customize_airootfs) may need a tweak
once the real build runs.
2026-08-31 18:22:55 +08:00
Breadway
b38bbbac1a iso: add external-monitors bread module for zero-config docking 2026-08-23 15:23:07 +08:00
Breadway
df2e1310bb iso: audit-sweep UID, bakery update, lockfile, welcome, autostart
Resolve MAIN_USER after deleting liveuser so Snapper and skel target the
installed account. Wrap bakery update with sudo -n/pkexec for /usr/local.
Pin bakery and bread-theme 0.7.4; require bread-emit and bread-module-host.
Make Calamares internet check non-blocking against breadway.dev. Autostart
breadlock listen. Smoke avahi-daemon.socket to match post-install.
2026-08-23 14:32:30 +08:00
Breadway
b270d64adc ci: sign [breadway] repo as the runner user
Docker as root left 0600 .sig files the runner could not chmod
(run 1050), so publish_tree never moved the tree into
/srv/breadway-dl/arch/x86_64. Sign as the host uid so nginx can
read the files and the next publish can replace them.
2026-08-16 08:07:53 +08:00
Breadway
64aa3611b4 ci: do not fail signed-repo publish on chmod of gpg signatures
repo-add -s already wrote the signed db; chmod a+r on .sig files
returned EPERM and rolled back the stage dir.
2026-08-16 05:35:56 +08:00
Breadway
055d92cc5a iso: pin bakery versions to the published 0.6.1 index
Some checks failed
Build and release ISO / release-iso (push) Failing after 1m33s
bakery 0.7.3, bread-theme 0.7.3, bar/box 0.3.2, crumbs 2.1.8,
pad 0.5.2, paper 0.1.13, mon 0.1.4, search 0.3.2, clip 0.2.3,
shot 0.1.3, settings 0.8.1, help 0.2.5. bread stays 0.8.0.
HEAD-checked on dl.breadway.dev before commit.
2026-08-16 01:31:29 +08:00
Breadway
9fe02eeea6 ci: publish signed [breadway] repo to dl.breadway.dev/arch
Some checks failed
Build and release ISO / release-iso (push) Failing after 46s
Host job on hestia (no container) collects breadlock plus the ISO AUR
republishes from the Forgejo registry, detach-signs them, repo-add -s,
and writes /srv/breadway-dl/arch/x86_64/. ISO SigLevel stays Never.
2026-08-16 01:00:47 +08:00
Breadway
8cd5ec9e16 iso: ship restic for Settings home backup
Snapper remains root (@) only. Home backup is Settings → Backup
(restic). Restore goes to ~/bos-restore-<id>, not $HOME.
2026-08-16 00:57:39 +08:00
Breadway
863fb80de2 iso: add bos-nvidia-setup for optional proprietary NVIDIA
Click-to-install path for machines first-boot already offers. Installs
nvidia + nvidia-utils (never cuda), writes ~/.config/hypr/nvidia.lua,
and hyprland.lua dofiles that file only if it exists. Mesa stays
unchanged. Not on the ISO. Reboot after.
2026-08-16 00:55:54 +08:00
Breadway
43c0a5e2b5 iso: print GRUB snapshot recovery after bos-update
Recovery is reboot → GRUB “snapshots” submenu. snapper rollback
does not change what GRUB boots (rootflags=subvol=@). Same wording
in README Recovery and docs/hardware.md.
2026-08-16 00:48:25 +08:00
Breadway
f3d6c55234 iso: prefer bread-polkit when it is on PATH
The themed agent lives in bread-ecosystem and is not on the ISO lockfile
yet. Fall back to polkit-gnome so install-time auth still works.
2026-08-16 00:35:26 +08:00
Breadway
70d4dd424b iso: enable bakery user units globally for later accounts
Bins live in /usr/local, so a later useradd no longer gets
~/.local/bin copies. systemctl --global enable the bakery
--user units (bake writes /etc/systemd/user/*.wants/, and
post-install + live-setup run the same enable) so first
login starts breadd, breadbox-sync, breadclipd, breadcrumbs,
and breadmill. Stock useradd -m copies skel (Hyprland +
bakery state). Rollback is still grub-btrfs.
2026-08-16 00:27:14 +08:00
Breadway
34043086b9 iso: bake bakery apps into /usr/local
BOS opts in to bakery's system prefix so desktop apps live on @
and ride snapper/grub-btrfs snapshots. The builder home stays
~/.local; build-local.sh copies bins, share/data, and user units
onto the image. Per-user installed.json and the index cache stay
in skel. Recovery is still grub-btrfs, not snapper rollback.
2026-08-16 00:10:32 +08:00
Breadway
744f18cd90 iso: add bos-rescue, first-boot probe, optional Calamares refresh
Live-ISO bos-rescue finds the installed btrfs @ and ESP, then offers
arch-chroot and/or the same GRUB NVRAM + --removable sequence as
post-install.sh. Recovery is grub-btrfs or this reinstall — GRUB pins
rootflags=subvol=@.

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

Re-enable the Calamares packages module as a refresh-only step with
skip_if_no_internet and ignore_update_db_error so offline installs
cannot abort on pacman -Sy.
2026-08-15 23:59:38 +08:00
Breadway
fd385bafae ci: install grub on the ISO builder
profiledef.sh uses uefi.grub. mkarchiso checks for grub-install on the
host before building; archiso does not pull grub, so the v0.6.0 bake
aborted after bakery staging.
2026-08-15 23:47:03 +08:00
Breadway
59fa81de98 iso: pin bakery versions to the published stable index
Some checks failed
Build and release ISO / release-iso (push) Failing after 48s
Newer git tags for breadpad, breadmon, breadclip, breadshot,
breadhelp, and breadcrumbs have no artifacts on dl.breadway.dev
because bakery release CI failed. Pin what the signed index
actually serves so the ISO bake can verify sha256.
2026-08-15 23:39:33 +08:00
Breadway
ca550c3ffc Add notification history bind; pin bakery versions 2026-08-15 23:11:28 +08:00
Breadway
96a2f685a2 1.0 polish: os-release, snapper pre, lockfile pins, listen, docs
Point os-release at the bos repo and issues; drop Arch privacy terms.
Take a best-effort snapper pre snapshot before pacman and bakery.
Pin current stable bakery versions so CI fetches the same bits per commit.
Autostart breadpaper/breadshot listen behind command -v.
Document signed-repo setup and Mesa/NVIDIA/grub-btrfs recovery.
2026-08-15 22:53:01 +08:00
Breadway
3bd278c1b9 iso/pacman.conf: document optional NoExtract size levers
Some checks failed
Build and publish bibata-cursor-theme / bibata (push) Failing after 37s
Commented-out only. Keep the bakery-vs-pacman and SigLevel = Never
honesty comments already on main.
2026-08-15 22:35:58 +08:00
Breadway
6eda6cb5f7 packaging/bibata: ship only the Bibata-Modern-Ice variant
Upstream tarball has 12 variants. BOS only selects Bibata-Modern-Ice;
keep that plus the left-handed -Right sibling. Needs a [breadway]
rebuild to take effect on the ISO.
2026-08-15 22:35:58 +08:00
Breadway
00fe0d7fce calamares: strip live-only packages, mask homed, cap journald
Remove Calamares, archiso boot-chain, and memtest/EFI-shell packages
from the installed system (plus orphan sweep). Socket-activate avahi.
Mask the systemd-homed stack so presets cannot re-enable it. Cap
journald at 256 MiB instead of 10% of the @log pool.
2026-08-15 22:35:58 +08:00
Breadway
b91fe0aeb8 iso: trim package set -- nvidia firmware, CJK fonts, toolchain, zathura
List linux-firmware subpackages so nvidia blobs stay off the image.
Drop unused noto-fonts-cjk, base-devel, linux-headers, and zathura
(skel already maps PDF to Zen). Keep webkit2gtk-4.1, breadlock, cage,
and the bakery-vs-pacman comments; rustup was already gone.
2026-08-15 22:35:58 +08:00
Breadway
84033b879e iso: switch UEFI bootmode from systemd-boot to GRUB
systemd-boot can only read the ESP it launched from, so mkarchiso copies
vmlinuz + initramfs into efiboot.img on top of the ISO9660 copy (~244 MiB
duplicate). uefi.grub reads ISO9660 directly; iso/grub configs were already
present and BOS-branded.
2026-08-15 22:35:58 +08:00
Breadway
d7c4fbd51f hyprland: place 3-finger workspace swipe after binds, before autostart
The gesture was already on main but dangling at EOF. settings.lua no
longer carries the old hyprlang gestures table.
2026-08-15 22:34:31 +08:00
Breadway
a3ead6607a CI: stage bakery from signed stable index, drop bread-theme cargo build
The tagged ISO workflow fetched bos-settings/src/Cargo.toml from the
dev branch (404 after the Tauri split) and cargo-built bread-theme.
bread-theme 0.7.1 is already on the stable index. Stage required bins,
units, breadhelp content, and desktop/license files from the
minisign-verified index instead; optional bread-emit/module-host skip
until bread publishes them. Fail the bake if a required bin is missing.
2026-08-15 22:20:29 +08:00
Breadway
3ab97c1634 Track AGENTS.md (single-trunk ISO repo notes) 2026-08-15 22:03:30 +08:00
Breadway
a21e81476f Align ISO bake and docs with bakery/Tauri product story
Derive BREAD_BINS from iso/bread-lockfile.toml and fail the bake when a
listed binary or breadhelp content is missing. Bake bakery share files
and drop breadcast from the copied installed.json. Add WebKitGTK 4.1 for
Tauri bos-settings, remove rustup, and rewrite README/DESIGN to match the
ISO+skel tree.
2026-08-15 21:40:55 +08:00
Breadway
881ac41cbb CI: fast-forward a stable branch to the latest release tag
Adds a bot-moved-only stable marker branch, never merged into by
hand — fixes the same "long-lived branch silently rots" failure mode
found in the app repos' old dev/beta/main model, applied here since
bos shares the single-main-branch principle even though its ISO
release cadence stays manual/deliberate rather than continuous.
2026-07-31 11:14:12 +08:00
Breadway
8a934fa6b6 ci: fix release-iso bread-theme step for bos-settings Tauri migration
bos-settings moved its Rust manifest to src/Cargo.toml and switched the
bread-theme dependency from a tag pin to branch = "main" (no release with
the needed functions yet). The workflow was still fetching the old root
Cargo.toml path and grepping for a tag field, so it 404'd and broke every
v0.5.2 release-iso run.
2026-07-29 21:37:26 +08:00
Breadway
3fa1dd20ba can't be bothered writing a commit message
Some checks failed
Build and release ISO / release-iso (push) Failing after 48s
2026-07-29 21:18:28 +08:00
Breadway
6aead4f5e8 Drop bos-settings/breadhelp pacman packaging, bakery-only
Both now install like every other bread app: staged into skel via
build-local.sh's BREAD_BINS array from this laptop's real bakery
install, with hand-committed .desktop files matching the existing
breadclip/breadman/breadmon/breadsearch pattern. Removes the last two
BOS-specific pacman packages from packages.x86_64.
2026-07-23 13:34:45 +08:00
Breadway
1120729973 ci: remove GitHub push-mirror workflow 2026-07-21 19:16:11 +08:00
Breadway
c24202cff1 Add touchpad workspace-swipe gestures, pacman/yay aliases, re-enable breadhelp package, fix identity URLs
Some checks failed
Mirror to GitHub / mirror (push) Failing after 3s
Build and publish calamares / calamares (push) Failing after 3m2s
Build and release ISO / release-iso (push) Successful in 11m52s
2026-07-19 03:08:24 +08:00
Breadway
d826fd0576 Remove misplaced bakery.toml (copy-pasted from bos-settings)
bos is ISO-only (release-iso.yml); it has never been bakery-distributed.
This file was a byte-for-byte copy of bos-settings/bakery.toml (name,
binaries, description all describe bos-settings, not bos) and isn't
referenced by the bread-ecosystem registry or any workflow in this repo.
DESIGN.md already documents that bos-settings — not bos — is the one
meant to get a bakery.toml and a registry entry.
2026-07-17 14:02:40 +08:00
Breadway
cd5bf1b546 Fix release CI: stale bread-theme tag path + calamares default-branch clone
- release-iso.yml's "Build bread-theme from source" step grepped
  bos-settings/Cargo.toml for the bread-theme tag pin, but bos-settings was
  split out into its own repo (git.breadway.dev/Breadway/bos-settings) --
  that path no longer exists in this checkout, so the grep would fail (or
  silently find nothing). Now fetches bos-settings' Cargo.toml directly from
  its own repo (dev branch, the one its own CI actually publishes the
  bos-settings pacman package from) via the Forgejo raw-file endpoint.

- calamares.yml cloned the repo's default branch instead of the branch/tag
  that actually triggered the run -- bibata.yml, powerlevel10k.yml, and
  yay-bin.yml (the other in-house-PKGBUILD workflows in this same family)
  all correctly clone --branch "${GITHUB_REF_NAME}". Brought calamares.yml
  in line with them.

- breadhelp-tour.lua interpolated an untrusted, client-controlled Wayland
  window class / layer-shell namespace directly into a bread.exec shell
  command string -- a session-level shell injection vector (verified
  exploitable with a crafted window class before this fix, e.g.
  "evil; touch ~/pwned #"). bread.exec only accepts a single shell string
  (always run via `sh -lc`, per breadd/src/lua/mod.rs) -- there's no
  array-exec form to bypass the shell with -- so the fix is a proper POSIX
  shell_quote() helper wrapping every interpolated value in single quotes
  before it reaches bread.exec.
2026-07-17 03:28:48 +08:00
Breadway
c67cc3c6f6 iso: temporarily drop breadhelp pending [breadway] publish
breadhelp's own repo builds cleanly but hasn't been published yet (see
git.breadway.dev/Breadway/breadhelp package.yml — needs REGISTRY_TOKEN
added to that repo's Actions secrets). Re-add once a tag publish
succeeds.
2026-07-15 19:35:23 +08:00
Breadway
99cc0a4717 Extract breadhelp to its own repo (git.breadway.dev/Breadway/breadhelp)
All checks were successful
Mirror to GitHub / mirror (push) Successful in 3s
Mirrors the bos-settings extraction (664298b): a breadhelp release no
longer requires a v* tag on the whole bos monorepo, which was
indistinguishable from an actual BOS-version release tag. Source lives
at ~/Projects/breadhelp now, full history preserved via git-filter-repo.

Root Cargo.toml/Cargo.lock and .forgejo/workflows/package.yml removed
too — breadhelp was the sole workspace member and the sole thing that
workflow built.
2026-07-15 19:20:23 +08:00
Breadway
b2f0b2a500 breadhelp 0.2.0: live guided tour overlay replacing static onboarding
Replaces the old in-window onboarding wizard with a real screen-wide
tour: dim + spotlight cutout around the actual on-screen component
(breadbar, breadbox), floating callout teaching the shortcut, and
event-driven confirmation via real Hyprland/breadd signals instead of
click-through fakery.
2026-07-15 18:48:10 +08:00
Breadway
78b2f420a5 Fix snapper ALLOW_USERS seeding: set-config, not a fragile sed
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
The sed matched snapper's default config template text exactly
(ALLOW_USERS=""), so any drift in that template across snapper versions
made it silently no-op -- leaving ALLOW_USERS empty and every non-root
snapper call (including bos-settings' Snapshots page) failing with
"No permissions." forever, with no error surfaced anywhere at install
time. `snapper -c root set-config` is the stable API regardless of
template wording.
2026-07-05 09:16:22 +08:00
Breadway
664298b6b4 Extract bos-settings to its own repo; add breadhelp; JSON-driven Hyprland config
bos-settings moves to git.breadway.dev/Breadway/bos-settings (full history
preserved via git-filter-repo) so its release cadence is decoupled from
BOS's own. breadhelp takes its place as this repo's workspace member: a
GTK4 onboarding/help center replacing the old bos-welcome/bos-keybinds
bash scripts with searchable guides, an interactive keybind viewer
(sourced from the new keybinds.toml, not parsed out of hyprland.lua or
hardcoded), a troubleshooting wizard with one-click fixes, and a proper
first-run tour. bos-netcheck extracts bos-welcome's network-check half,
which still needs to run every login independent of breadhelp's own
first-run gating.

hyprland.lua's keybinds/settings/monitors/autostart are now JSON-driven
(binds.json/settings.json/monitors.json/autostart.json) with every
loader pcall-wrapped and falling back to hardcoded defaults per field on
bad or missing config, so bread* apps (bos-settings' new editors, and
breadhelp's keybind viewer) can read/write this config without ever
being able to leave the compositor unable to start.

CI's package.yml now builds breadhelp instead of bos-settings on tag
push; bos-settings needs its own equivalent workflow in its new repo
(not yet set up).
2026-07-05 09:16:14 +08:00
Breadway
434efcea22 Fix snapper create-config race: retry the whole dance, not just umount
All checks were successful
Mirror to GitHub / mirror (push) Successful in 3s
Build and publish package / package (push) Successful in 2m5s
Build and release ISO / release-iso (push) Successful in 13m59s
Confirmed on the test laptop's real install: /etc/snapper/configs/ was
completely empty post-install — snapper create-config failed silently and
BOS's advertised snapshot/rollback feature was entirely non-functional,
despite snapper-cleanup.timer being enabled and grub-btrfsd active (both
harmless no-ops with no config to act on).

Root cause is the known chroot-specific busy-mount race already documented
in this section's comments, but the existing recovery (retry umount 5x,
then one lazy-unmount fallback) wasn't sufficient on this hardware — a
lazy unmount detaches the mountpoint from the namespace immediately, but
whatever was holding it busy can take a moment longer to actually release,
and the immediately-following rmdir/create-config both fail if anything
still references /.snapshots at that instant.

Wrap the entire unmount → rmdir → create-config → cleanup → remount
sequence in an outer retry loop (checking whether the config file actually
exists before each attempt and after the loop), add a settle delay after
the lazy-unmount fallback, and turn the final failure into a loud ERROR
instead of a warning that's easy to miss — a system silently shipping
without snapshots is worse than one that's slow to set them up.
2026-07-04 19:00:15 +08:00
Breadway
22a8db09a6 Fix mkinitcpio hook detection: current default is systemd-based, not udev
All checks were successful
Mirror to GitHub / mirror (push) Successful in 3s
Build and publish yay-bin / yay-bin (push) Successful in 22s
Verified against the actual cached mkinitcpio package (41-4), not assumed:
its shipped /etc/mkinitcpio.conf template is
HOOKS=(base systemd autodetect microcode modconf kms keyboard sd-vconsole
block filesystems fsck) — there's no "udev" token at all on a stock
install anymore, systemd is the base hook instead.

This silently broke two things:
- The existing plymouth-hook insertion (`sed 's/\budev\b/\0 plymouth/'`)
  has been a no-op on every fresh install using a current mkinitcpio
  package — the boot splash was never actually getting wired into the
  initramfs, just failing quietly (the script's best-effort `|| echo WARN`
  pattern doesn't catch a sed that "succeeds" by matching nothing).
- My own new encrypt-hook insertion from the previous commit had the same
  flaw, and would have used the wrong hook regardless: `encrypt` is the
  udev-based hook, systemd-based initramfs needs `sd-encrypt` instead.

post-install.sh now detects which base hook (systemd or udev) is actually
in HOOKS once, and uses the matching hook name for both plymouth
(unaffected by the choice, just needed the right anchor to insert after)
and LUKS unlocking (encrypt vs sd-encrypt — genuinely different hooks).

Also: avoid a redundant pacman -Sy in release-iso.yml's signing step —
the job already synced repos earlier in the same container.
2026-07-04 11:20:01 +08:00
Breadway
ad4d71db34 bos-settings 0.6.0: Power, Firewall, Users, AUR, Firmware panels
Continues reducing terminal-reliance for graphical system control. Five
new panels, plus three GUI apps shipped for things better served by an
existing dedicated tool than reimplemented in bos-settings
(gnome-disk-utility, gufw, mission-center).

- Power: battery status/health, brightness, charge-limit thresholds where
  the hardware exposes them, TLP profile shown read-only by design (no
  Balanced/Performance switcher — TLP auto-selects by power source, and
  power-profiles-daemon isn't installed because it conflicts with tlp).
- Firewall: ufw enable/disable, add/remove rules, view active rules.
  ufw's own status check requires root (confirmed against the installed
  script — not just changes, reads too), so unlike every other panel this
  one does NOT query state in build(): every view is constructed eagerly
  at app launch, and an unconditional privileged read here would mean a
  polkit prompt on every single bos-settings open. Starts blank with a
  "Status not loaded" placeholder; state loads only on an explicit Refresh
  click, with a guard so refresh's own set_active() doesn't loop back into
  triggering ufw enable/disable.
- Users: add/remove accounts, change passwords. All through pkexec on a
  background thread. Can't remove the account you're currently running as.
- AUR: search via yay. Installing deliberately opens a terminal instead of
  a silent --noconfirm install — yay's interactive PKGBUILD diff review and
  sudo prompt are the actual safety mechanism against a malicious AUR
  package, not a formality worth automating away.
- Firmware: fwupd device list + updates, same stream-output-then-refresh
  pattern as Packages.

packages.x86_64: gnome-disk-utility, gufw, mission-center for disk/firewall/
task-manager GUIs that don't need reinventing inside bos-settings.
2026-07-04 11:04:16 +08:00
Breadway
5aaf71e80a Ship yay, wire up LUKS disk encryption, self-signed Secure Boot, release signing
yay (yay-bin, AUR-only like calamares/bibata) republished to [breadway] via
the same PKGBUILD + Forgejo workflow pattern, so users can reach the wider
AUR beyond bakery's bread ecosystem.

Disk encryption: Calamares' partition module already has LUKS support
enabled by default, but the checkbox led nowhere — no cryptsetup on the
live/target image, no mkinitcpio encrypt hook, no GRUB cryptodisk wiring.
An encrypted install would partition fine and then never boot. Added
cryptsetup, pinned luksGeneration to luks1 (GRUB doesn't support LUKS2 +
Argon2id), and post-install.sh now detects an encrypted root (lsblk TYPE
== crypt) and conditionally adds the encrypt hook + GRUB_ENABLE_CRYPTODISK +
--modules="cryptodisk luks luks2" on both grub-install passes. No effect on
a normal unencrypted install.

Secure Boot: self-signed via sbctl (shipped in packages.x86_64). BOS can't
ship a Microsoft-signed shim without going through Microsoft's own paid
UEFI CA process, so post-install.sh enrolls BOS's own keys automatically
only when the firmware is already in Setup Mode (sbctl status --json),
signs the kernel/bootloader, and leaves it alone otherwise — sbctl's own
pacman hook re-signs on every future kernel/GRUB update, no further
wiring needed.

Release signing: generated a dedicated Ed25519 "BOS Release Signing" key
(not reused from anything else), stored as the GPG_PRIVATE_KEY Forgejo
Actions secret. release-iso.yml now generates SHA256SUMS and a detached
SHA256SUMS.asc signature alongside every ISO upload; public key committed
at KEYS.asc with verification instructions in the README.

README updated: fixed a stale "greetd + tuigreet" line (breadgreet since
round 3), documented yay/encryption/secure-boot/verification.
2026-07-04 10:39:44 +08:00
Breadway
489d472240 bos-settings: round-2 GUI review fixes (stable column width, service UX, switch styling)
Root cause of two panels rendering full-bleed instead of the 760px column
(breadsearch, breadclip): hint()'s long unwrapped text reported an
unbounded natural width, and CONTENT_MAX_WIDTH is a floor (set_size_request),
not a cap — nothing was actually capping width. hint() and empty_state()
now bound their labels with set_max_width_chars, which stabilizes every
panel's column at a consistent width and left edge for the first time.

service_control(): added a second status line (enabled-at-boot vs
just-running), a confirm dialog on Stop for critical units (breadd — the
whole desktop's event backbone was one accidental click from stopping,
styled identically to optional helpers), and a consistent "Save only
writes the file, Restart applies it" hint on panels that have both a
service and a config file (previously only breadsearch said this, in its
own inconsistent wording).

Consistency sweep: Network's Scan button and empty-state treatment now
match the rest of the app (was full-width where every other secondary
action is auto-width; "not scanned" was a hint while "no results" was an
empty_state card — now both empty_state). breadclip gets an actual "Open
history" action instead of reading as an unfinished stub. breadbox's Save
button pulled out of a mixed row into its own row like every other panel.
Packages list rows now get the same card styling as every other row in
the app.

Panel title now shares view_scaffold's width+center treatment with the
content column, so it actually heads the column instead of sitting ~440px
to its left.

Also: switch widgets had a stray box-shadow/outline/border showing as a
faint ring around the knob, and bread-theme's switch slider color
(@on-surface) was being masked by Adwaita's default gloss background-image
— clearing it reveals the correctly-themed near-white knob against the
dark surface.
2026-07-04 10:09:59 +08:00
Breadway
9c2c95089c bos-settings: single-source-of-truth default page
sidebar.rs and window.rs each independently hardcoded "about" as the
default selected/visible page — harmless while both literals happened to
match, but a real latent bug: changing one without the other silently
desyncs the sidebar highlight from the actually-displayed panel. Found
while capturing screenshots of every panel for a design review, where
exactly that happened. window.rs now owns DEFAULT_PAGE and passes it to
sidebar::build().
2026-07-04 00:00:10 +08:00
Breadway
eb740a3724 bos-settings 0.5.0: GNOME-Settings-style redesign + live app control
New panels: About, Network (Wi-Fi/Ethernet via nmcli), Sound (PipeWire
volume/device via pactl), Date & Time (timedatectl), and Clipboard
(breadclip previously had no bos-settings presence at all despite running
its own daemon).

Layout: every panel now shares one scaffold (6 views were hand-rolling their
own, inconsistently) with a centered, width-capped content column instead of
rows stretching edge-to-edge on a wide window with the control stranded far
from its label. Rows render as individual cards. Sidebar got human labels +
icons + task-based grouping (System/Personalization/Maintenance/About)
instead of raw binary names under "Apps"/"System".

New capability: a shared service_control() widget gives every bread app
backed by a systemd --user daemon (breadd, breadbox-sync, breadcrumbs,
breadmill, breadclipd) live status plus Start/Stop/Restart/View logs —
previously these panels only ever edited a TOML file and hoped the running
process picked it up. breadbar (no systemd unit) gets an equivalent: Save
now actually sends the SIGHUP its own reload mechanism needs, instead of
just claiming to in the hint text.

Fixed two real bugs surfaced while building About: CPU model string kept a
stray leading tab+colon (trim_start_matches was missing '\t'), and GPU
showed "unknown" on hardware whose lspci entry says "Display controller"
instead of "VGA compatible controller".

Local CSS patches (bos-settings/src/theme.rs) for two upstream bread-theme
gaps: suggested/destructive buttons and scale widgets don't clear Adwaita's
default background-image, so flat colour overrides were invisible; and
destructive-action red must not be pywal-derived (it can land on gold
depending on the wallpaper, indistinguishable from a primary action).
2026-07-03 23:50:26 +08:00
Breadway
f43bfbb680 bos-settings 0.4.2: bump bread-theme to v0.2.10 (fixed dark background/surface)
Pulls the fix for panels turning brown/off-hue when pywal extracts a light
or muddy background from the wallpaper — background/surface now stay BOS's
fixed dark constants regardless of what the wallpaper looks like.
2026-07-03 22:52:21 +08:00
Breadway
41e7aaf9fc bos-settings: expose npu/rocm/cuda in breadsearch compute backend dropdown
All checks were successful
Mirror to GitHub / mirror (push) Successful in 3s
breadsearch v0.2.1+ ships breadmill built with --features full (npu + rocm
+ cuda in one binary via ort's load-dynamic/dlopen mode), so the earlier
CPU-only restriction here no longer applies. Hint explains what each
backend needs and how to confirm it actually took effect, since a failed
GPU EP registration falls back to CPU silently at the ONNX Runtime level.
2026-07-03 22:38:59 +08:00
Breadway
9119d99ba9 Fix pywal-brown theme regression: stop clobbering the curated black palette
Round-3's autostart fix (breadpaper set instead of raw awww img) had a side
effect I missed: breadpaper set always runs real pywal against the wallpaper
image, which overwrote the curated colors.json baked into skel (#0c0c0c
black background, bread-toned browns reserved for accent slots only) with
colors genuinely extracted from bread-background.png — an all-beige photo,
so every bread-theme app (breadbar included) turned brown on first boot.

Revert autostart to plain `awww img`. Bake .cache/wal/wal (pywal's own
"last image" marker — the only thing breadpaper get/the bos-settings panel
actually read) alongside the existing colors.json instead, so the panel
still shows the real default without ever running pywal for real. pywal
only generates a genuine palette once the user picks their own wallpaper.
2026-07-03 22:35:50 +08:00
101 changed files with 5434 additions and 4498 deletions

View file

@ -24,7 +24,12 @@ jobs:
kcoreaddons kpmcore libpwquality qt6-declarative qt6-svg yaml-cpp
useradd -m builder
git config --global --add safe.directory '*'
git clone --depth 1 "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src
# Clone the branch/tag that triggered this run (not the default
# branch) — same as bibata.yml/powerlevel10k.yml/yay-bin.yml, so a
# push to a feature branch (or a release tag) builds and publishes
# from that ref, not whatever happens to be on the default branch.
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/calamares && makepkg -f --noconfirm --nocheck"
PKG=$(find /home/builder/src/packaging/calamares -name '*.pkg.tar.zst' | head -1)

View file

@ -1,21 +0,0 @@
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,40 +0,0 @@
name: Build and publish package
on:
push:
tags: ['v*']
jobs:
package:
runs-on: [self-hosted, hestia]
container:
image: archlinux:latest
steps:
# Note: no actions/checkout — the archlinux image has no Node, which JS
# actions require. Everything runs as shell steps and clones manually.
- name: Build and publish
env:
PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
pacman -Syu --noconfirm base-devel git rust cargo gtk4 glib2
useradd -m builder
git config --global --add safe.directory '*'
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src
cd /home/builder/src
git archive --format=tar.gz --prefix="bos-settings-${VERSION}/" HEAD \
> packaging/arch/bos-settings-${VERSION}.tar.gz
SHA=$(sha256sum packaging/arch/bos-settings-${VERSION}.tar.gz | awk '{print $1}')
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD
sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD
chown -R builder:builder /home/builder/src
# --nocheck: packaging builds the artifact; tests belong in a CI job.
su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck"
PKG=$(find /home/builder/src/packaging/arch -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

@ -0,0 +1,39 @@
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,14 +1,21 @@
name: Build and release ISO
# Builds the BOS ISO on the hestia self-hosted runner (native Arch container),
# downloads all bakery ecosystem binaries from their GitHub releases, compiles
# bread-theme from source, and uploads the resulting ISO to a Forgejo pre-release.
# A matching GitHub release is created that points to Forgejo for the download
# Builds the BOS ISO on the hestia self-hosted runner (native Arch container).
# Stages bakery desktop apps from the *minisign-verified* stable index at
# https://dl.breadway.dev/index.json (see iso/bread-lockfile.toml), then runs
# build-local.sh and uploads the ISO to a Forgejo release. A matching GitHub
# release is created best-effort and points at Forgejo for the download
# (GitHub releases cannot host files larger than 2 GB).
#
# Required secrets:
# RELEASE_TOKEN — Forgejo API token with write:repository scope
# MIRROR_TOKEN — GitHub personal access token with repo scope (already used by mirror.yml)
# MIRROR_TOKEN — GitHub personal access token with repo scope
# GPG_PRIVATE_KEY — armoured secret key for the dedicated "BOS Release Signing"
# identity (releases@breadway.dev); public half is committed
# at KEYS.asc. Signs ISO SHA256SUMS here; the same secret
# signs the [breadway] repo in signed-repo.yml. No passphrase
# (CI-only key, access controlled via the Forgejo secret
# store).
on:
push:
@ -23,6 +30,8 @@ jobs:
release-iso:
runs-on: [self-hosted, hestia]
container:
# Floating tag: this environment cannot pin a reproducible digest of
# archlinux:latest. Do not invent one.
image: archlinux:latest
# --privileged: mkarchiso needs CAP_SYS_ADMIN for loop mounts + mknod
# --network=host: gives localhost:3002 access to Forgejo (avoids the
@ -32,7 +41,10 @@ jobs:
steps:
- name: Install build dependencies
run: |
pacman -Syu --noconfirm archiso curl python git rust
# grub is required by profiledef.sh bootmodes=('uefi.grub'):
# mkarchiso validates grub-install on the *builder*, not the image.
# archiso pulls syslinux/squashfs-tools/libisoburn; it does not pull grub.
pacman -Syu --noconfirm archiso grub curl python git minisign
- name: Determine tag and version
id: vars
@ -50,72 +62,17 @@ jobs:
git clone --branch "${{ steps.vars.outputs.tag }}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /bos
- name: Download bakery ecosystem binaries
- name: Stage bakery ecosystem from signed stable index
run: |
set -euo pipefail
mkdir -p /build-home/.local/bin \
/build-home/.local/state/bakery \
/build-home/.cache/bakery
cd /bos
LAPTOP_HOME=/build-home python3 scripts/ci-stage-bakery.py
# 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
- name: Verify staged bakery bake inputs
run: |
set -euo pipefail
# bread-theme is not in the bakery index; build it at the tag pinned
# in bos-settings/Cargo.toml so the CLI matches the library version.
THEME_TAG=$(grep 'bread-theme.*tag' /bos/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"
cd /bos
LAPTOP_HOME=/build-home bash scripts/ci-verify-bake.sh
- name: Build ISO
run: |
@ -129,14 +86,35 @@ jobs:
bash build-local.sh
ls -lh /bos-out/*.iso
- name: Create Forgejo release and upload ISO
- name: Checksum and sign
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
run: |
set -euo pipefail
VERSION="${{ steps.vars.outputs.version }}"
ISO=$(ls /bos-out/*.iso | head -1)
ISO_NAME="bos-${VERSION}-x86_64.iso"
cd /bos-out
mv "$(basename "$ISO")" "$ISO_NAME"
sha256sum "$ISO_NAME" > SHA256SUMS
cat SHA256SUMS
pacman -S --noconfirm --needed gnupg
export GNUPGHOME=/tmp/gnupg-release
mkdir -m 700 -p "$GNUPGHOME"
echo "$GPG_PRIVATE_KEY" | gpg --batch --import
gpg --batch --yes --local-user releases@breadway.dev \
--detach-sign --armor -o SHA256SUMS.asc SHA256SUMS
echo "Signed SHA256SUMS -> SHA256SUMS.asc"
- name: Create Forgejo release and upload assets
env:
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
set -euo pipefail
TAG="${{ steps.vars.outputs.tag }}"
VERSION="${{ steps.vars.outputs.version }}"
ISO=$(ls /bos-out/*.iso | head -1)
ISO_NAME="bos-${VERSION}-x86_64.iso"
# Use an existing release for this tag if one exists (e.g. created
@ -157,35 +135,41 @@ jobs:
\"tag_name\": \"${TAG}\",
\"name\": \"BOS ${TAG}\",
\"prerelease\": false,
\"body\": \"ISO image attached below.\\n\\nSee the [README](https://github.com/Breadway/bos#testing-in-a-vm) for VM testing instructions.\"
\"body\": \"ISO image attached below. Verify with SHA256SUMS + SHA256SUMS.asc (signed by the BOS Release Signing key — see KEYS.asc in the repo).\\n\\nSee the [README](https://github.com/Breadway/bos#testing-in-a-vm) for VM testing instructions.\"
}")
RELEASE_ID=$(echo "${RELEASE}" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")
fi
echo "Using release ID: ${RELEASE_ID}"
# Remove any existing asset with the same name before uploading
ASSET_ID=$(curl -sf \
upload_asset() {
local file="$1" name
name="$(basename "$file")"
local asset_id
asset_id=$(curl -sf \
-H "Authorization: token ${FORGEJO_TOKEN}" \
"http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets" \
| python3 -c "
import json,sys
assets=json.load(sys.stdin)
match=[a['id'] for a in assets if a['name']=='${ISO_NAME}']
match=[a['id'] for a in assets if a['name']=='${name}']
print(match[0] if match else '')
" 2>/dev/null || true)
if [ -n "${ASSET_ID}" ]; then
if [ -n "${asset_id}" ]; then
curl -fsS -X DELETE \
-H "Authorization: token ${FORGEJO_TOKEN}" \
"http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
echo "Removed existing ${ISO_NAME} asset"
"http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets/${asset_id}"
echo "Removed existing ${name} asset"
fi
curl -fsS -X POST \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-F "attachment=@${ISO};filename=${ISO_NAME}" \
-F "attachment=@${file};filename=${name}" \
"http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets"
echo "Uploaded: ${ISO_NAME}"
echo "Uploaded: ${name}"
}
upload_asset "/bos-out/${ISO_NAME}"
upload_asset "/bos-out/SHA256SUMS"
upload_asset "/bos-out/SHA256SUMS.asc"
- name: Create GitHub release
env:
@ -196,12 +180,27 @@ jobs:
VERSION="${{ steps.vars.outputs.version }}"
FORGEJO_URL="https://git.breadway.dev/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
printf '**Download ISO:** %s\n\nGitHub releases cannot host files >2 GB; the `bos-%s-x86_64.iso` (~2.5 GB) is on Forgejo.\n\nSee the [README](https://github.com/Breadway/bos#testing-in-a-vm) for VM testing instructions.' \
printf '**Download ISO:** %s\n\nGitHub releases cannot host files >2 GB; the `bos-%s-x86_64.iso` (~2.5 GB), SHA256SUMS, and SHA256SUMS.asc (signed by the BOS Release Signing key — public half at [KEYS.asc](https://github.com/Breadway/bos/blob/main/KEYS.asc)) are all on Forgejo.\n\nSee the [README](https://github.com/Breadway/bos#testing-in-a-vm) for VM testing instructions.' \
"${FORGEJO_URL}" "${VERSION}" > /tmp/gh-release-notes.md
gh release create "${TAG}" \
--repo "Breadway/bos" \
--title "BOS ${TAG}" \
\
--notes-file /tmp/gh-release-notes.md \
2>/dev/null || echo "GitHub release already exists — skipping"
|| echo "skip: GitHub release failed (MIRROR_TOKEN historically broken)"
# `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

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

View file

@ -0,0 +1,36 @@
name: Build and publish yay-bin
# yay (and every AUR helper) is AUR-only — not in Arch's official repos — so
# BOS maintains an in-house PKGBUILD and publishes the built package to the
# [breadway] repo, same as bibata-cursor-theme and calamares. Prebuilt
# release tarball, no build step.
on:
push:
paths:
- 'packaging/yay-bin/**'
workflow_dispatch:
jobs:
yay-bin:
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
useradd -m builder
git config --global --add safe.directory '*'
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/yay-bin && makepkg -f --noconfirm --nocheck"
PKG=$(find /home/builder/src/packaging/yay-bin -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"

7
.gitignore vendored
View file

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

58
AGENTS.md Normal file
View file

@ -0,0 +1,58 @@
# 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).

1039
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,3 +0,0 @@
[workspace]
members = ["bos-settings"]
resolver = "2"

165
DESIGN.md
View file

@ -1,4 +1,26 @@
# BOS — Bread Operating System Plan
# BOS — historical design 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
@ -7,51 +29,25 @@ The bread ecosystem (bread, breadbar, breadbox, breadcrumbs, breadpad/breadman,
Goals:
- **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
- **Unified config**: `bos-settings` GTK4 app surfaces all app configs + snapshot management + bakery updates
- **Unified config**: `bos-settings` surfaces all app configs + snapshot management + bakery updates
- **Future-compatible**: Btrfs layout is designed to allow A/B partition migration later (SteamOS model)
---
## Repo Structure
Single new repo: `Breadway/bos` — a Cargo workspace.
Single new repo: `Breadway/bos`*planned as* a Cargo workspace. **That is
not what landed**; see Current architecture.
```
bos/
├── Cargo.toml # Workspace (members: [bos-settings])
├── bos-settings/ # GTK4 unified settings app
│ ├── 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
├── Cargo.toml # Workspace (members: [bos-settings]) — NOT in tree
├── bos-settings/ # planned GTK4 app — now its own bakery repo
├── iso/ # archiso profile (this is the repo)
│ ├── profiledef.sh
│ ├── packages.x86_64 # Live ISO + installed system package list
│ ├── airootfs/ # Files overlaid onto live ISO root
│ │ └── 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
│ ├── packages.x86_64
│ └── airootfs/
└── dotfiles/ # planned install-time configs — NOT the live skel
```
---
@ -70,7 +66,11 @@ bos/
Mount options: `noatime,compress=zstd,space_cache=v2` on all subvolumes.
**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.
**A/B compatibility note (future):** The `@` subvolume is self-contained and
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)
@ -96,100 +96,79 @@ No user-facing CLI needed for this component — `bos-settings` is the interface
### archiso profile (`iso/`)
- Derives from `/usr/share/archiso/configs/releng/` (the standard baseline)
- `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
- `airootfs/etc/skel/` contains the default dotfiles (symlinked from `dotfiles/`)
- `packages.x86_64` is the live + installed pacman set (Hyprland, Calamares,
breadlock, WebKitGTK 4.1 for Tauri bos-settings, …). bakery apps are not
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
### 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)
2. **locale** — timezone + locale selection
3. **keyboard** — layout selection
4. **partition** — custom `btrfs` mode: creates EFI partition + single btrfs pool with the subvolume layout above
5. **users** — create main user, set password
6. **packages** — install package list (reuses `packages.x86_64`)
7. **bootloader** — install GRUB to EFI, `grub-mkconfig` with grub-btrfs hook
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)
7. **bootloader***planned*; actual GRUB install is in `post-install.sh`
8. **shellprocess (post-install)** — snapper, services, copy skel; does **not** run bakery
9. **finished** — reboot prompt
---
## Component 3: `bos-settings` GTK4 App
## Component 3: `bos-settings` (planned as GTK4)
### Tech choices (original)
### Tech choices
- **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`)
- Reads/writes each tool's own config file directly (no unified intermediate config)
- Window: 960×640, sidebar 190px, `gtk4::Stack` for view switching — identical structure to breadman
**What shipped:** Tauri 2 + Svelte in its own repo
(`git.breadway.dev/Breadway/bos-settings`), distributed by bakery. This
repo does not build it.
### Sidebar sections + views
| Section | View | What it does |
|---------|------|--------------|
| **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
The panel list is still roughly accurate; see README. Snapshots recovery
should send users through **grub-btrfs reboot**, not `snapper rollback N`.
### Distribution
`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.
`bos-settings` has its own `bakery.toml` and is installable via
`bakery install bos-settings` on any Arch/Hyprland system, not only as part
of a BOS install.
---
## Component 4: Default Dotfiles
Minimal but functional defaults deployed at install time. These are opinionated starting points, not locked configs — users edit freely after install.
Minimal but functional defaults. These live in `iso/airootfs/etc/skel`
(`hyprland.lua` + JSON binds, not `dotfiles/hyprland/*.conf`).
| File | Key content |
|------|-------------|
| `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) |
Zero-config bakery apps survive with no extra skel files. breadcrumbs
networks are user-filled after install — do not invent a full
`breadcrumbs.toml` in-tree.
---
## Build Order
1. **Dotfiles** — write default configs; these unblock installer testing immediately
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
Historical. The ISO profile + skel + Calamares path is what this repo
iterates on. bos-settings is developed in its own repo.
---
## Verification
- **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
- **ISO**: `sudo ./build-local.sh` (not a raw `mkarchiso iso/` — the bake
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
- **snapper**: `snapper list`; run `pacman -Syu` and confirm two new snapshots appear
- **grub-btrfs**: Reboot and confirm snapshot submenu in GRUB
- **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
- **bos-settings**: built and tested in the bos-settings repo, not here

15
KEYS.asc Normal file
View file

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

379
README.md
View file

@ -1,66 +1,142 @@
# BOS — Bread Operating System
An Arch-based, Hyprland desktop distribution that ships the [bread
ecosystem](https://github.com/Breadway) preconfigured. One Calamares install
ecosystem](https://git.breadway.dev/Breadway) preconfigured. One Calamares install
produces a themed, bootable Wayland desktop — no manual Arch bootstrap, no
wiring up dotfiles, no per-tool bakery installs.
> Design rationale and the btrfs/A-B roadmap live in [DESIGN.md](DESIGN.md).
> This file is the practical overview: what's in the image, how to build it,
> and how to test it.
> This file is the product as the tree ships it. [DESIGN.md](DESIGN.md) is the
> original plan, kept as history — several of its sections (in-tree GTK
> bos-settings, bakery-at-post-install, A/B as if it were current) are not
> how the ISO works today.
## What you get
- **Compositor**: Hyprland with a native-Lua config (`hyprland.lua`), curated
keybinds, snappy animations, blur, and pywal-driven colours on a black base.
- **bread ecosystem**, baked into `/etc/skel` from bakery-managed binaries
(no network needed at install time): `bread`/`breadd`, `breadbar` (status bar
+ notification daemon), `breadbox` (launcher), `breadcrumbs` (Wi-Fi profiles),
`breadpad` (notes/reminders), `breadman`, and the `bakery` package manager.
- **bos-settings**: a GTK4 control panel that configures every bread\* app's
config from a GUI (non-destructively), plus snapshot rollback and bakery
updates. See below.
- **Login**: greetd + tuigreet → Hyprland session.
- **bread ecosystem**, baked into `/usr/local` from bakery-managed binaries
(no network needed at install time; per-user bakery state is seeded in
`/etc/skel`): the `bread`/`breadd` automation daemon
(`bread-emit` / `bread-module-host` when the stable bread release publishes
them), `breadbar` (status bar + notifications), `breadbox` (launcher),
`breadclip` (clipboard history), `breadcrumbs` (Wi-Fi profiles),
`breadpad`/`breadman` (notes), `breadpaper` (wallpaper + theme),
`breadsearch` (system search), `breadmon` (monitor layout TUI),
`breadshot` (screenshots), `bread-theme` (the shared palette engine),
`breadhelp` (onboarding + cheatsheet), `bos-settings` (control panel),
and the `bakery` package manager. Most of those apps are zero-config on
first boot; breadcrumbs networks are user-filled after install. See
[below](#the-bread-ecosystem).
- **breadlock** (lock screen + greeter) is the one bread\* app that ships as
**pacman**, not bakery — it needs a root-owned PAM service.
- **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).
- **Theming**: global dark across GTK3 (Adwaita-dark), GTK4/libadwaita
(`color-scheme: prefer-dark`), and Qt (qt5ct/qt6ct Fusion dark); Papirus-Dark
icons; Bibata cursor.
- **Apps**: kitty, nautilus (+ gvfs), Zen browser, VLC, loupe, gnome-text-editor,
gnome-calculator, file-roller, with file associations wired in `mimeapps.list`.
`yay` ships for AUR access beyond bakery + `[breadway]`.
- **Hardware**: pipewire audio, NetworkManager, BlueZ + blueman, CUPS printing
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
pacman transaction; zram swap; ufw firewall (deny-incoming, mDNS allowed).
pacman transaction (**root `@` only** — snapper does not cover `@home`);
home backup is **Settings → Backup** (restic, local path or SFTP); zram
swap; ufw firewall (deny-incoming, mDNS allowed). A/B root swapping is
**not** implemented. Recovery is a grub-btrfs reboot, not
`snapper rollback` (GRUB pins `rootflags=subvol=@`). See
[docs/hardware.md](docs/hardware.md).
- **Security**: optional full-disk encryption is **LUKS1** (GRUB cannot unlock
LUKS2 + Argon2id). Secure Boot is **self-signed Setup Mode only** via
`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
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/
├── 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
│ ├── bread-lockfile.toml # bakery bins + optional version pins
│ ├── profiledef.sh
│ ├── packages.x86_64 # live + installed package set
│ ├── packages.x86_64 # live + installed pacman set
│ └── airootfs/ # files overlaid onto the image
│ └── etc/
│ ├── skel/ # default user dotfiles (hypr, kitty, gtk, …)
│ ├── skel/ # live user defaults (hypr, kitty, gtk, …)
│ └── calamares/ # installer config + post-install.sh
├── packaging/ # in-house PKGBUILDs for AUR-only deps
│ ├── arch/ # bos-settings
│ ├── calamares/
│ └── bibata/
├── .forgejo/workflows/ # CI: build + publish packages to [breadway]
│ ├── bibata/
│ ├── powerlevel10k/
│ └── 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
└── DESIGN.md
├── README.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
`build-local.sh` builds the image natively (no container) and bakes this
machine's bakery-installed bread binaries into `/etc/skel`:
`build-local.sh` builds the image natively (no container) and copies this
machine's bakery-installed bread binaries + breadhelp content from the
builder's `~/.local` into the image at `/usr/local` (bins, share/data,
desktop files, licenses) and `/usr/lib/systemd/user` (units). Per-user
bakery state (`installed.json` + index cache) is seeded in `/etc/skel`.
User units are `systemctl --global enable`'d so a later `useradd -m`
starts them on first login. BOS opts in via `/etc/bakery/config.toml`
(`prefix = "/usr/local"`); default bakery without that file is still
`~/.local`. Snapper `@` snapshots include `/usr/local`; recovery is
still grub-btrfs, not `snapper rollback`.
```sh
sudo ./build-local.sh # release-quality (xz squashfs)
@ -68,16 +144,47 @@ 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
`SOURCE_DATE_EPOCH` (reproducible UUIDs) and rewrites the `[breadway]` repo URL
to the Tailscale-reachable Forgejo registry for the build.
`SOURCE_DATE_EPOCH` (reproducible UUIDs), rewrites the `[breadway]` repo URL
to the Tailscale-reachable Forgejo registry for the build, and **exits
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
`calamares`, `zen-browser-bin`, and `bibata-cursor-theme` are AUR-only. BOS
keeps a PKGBUILD for each under `packaging/` and republishes the built package
to the `[breadway]` repo via a Forgejo Actions workflow (built on the hestia
self-hosted runner, published with a scoped registry token). `bos-settings`
itself publishes the same way on a `v*` tag.
`calamares`, `zen-browser-bin`, `bibata-cursor-theme`, and `yay-bin` are
AUR-only. BOS keeps a PKGBUILD for each under `packaging/` and republishes
the built package to the `[breadway]` repo via a Forgejo Actions workflow
(built on the hestia self-hosted runner, published with a scoped registry
token). `[breadway]` is **not** where bakery/breadbar/bos-settings live.
### Verifying a release
Every tagged release ISO on the [Forgejo releases
page](https://git.breadway.dev/Breadway/bos/releases) ships alongside a
`SHA256SUMS` file and a detached signature `SHA256SUMS.asc`, signed by a
dedicated release-signing key (not reused from anything else):
```
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 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
gpg --import KEYS.asc
gpg --verify SHA256SUMS.asc SHA256SUMS
sha256sum -c SHA256SUMS
```
## Testing in a VM
@ -93,43 +200,123 @@ 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
avoid memory pressure.
## bos-settings
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).
`bos-settings` edits each bread\* app's TOML **non-destructively**: it parses
the file with `toml_edit`, changes only the keys a view exposes, and writes it
back — preserving comments and any keys the UI doesn't model (calendar
passwords, saved-network passwords, model paths). Views:
## Second account
| View | Config |
|------|--------|
| bread | `bread/breadd.toml` — daemon, lua, modules, all adapters, events, notifications |
| breadbar | `breadbar/style.css` override |
| breadbox | `breadbox/config.toml` — launcher contexts |
| breadcrumbs | `breadcrumbs/breadcrumbs.toml` — settings, saved networks, profiles |
| breadpad | `breadpad/breadpad.toml` — settings, model + ollama, reminders, calendar |
| Snapshots | `snapper` list / rollback / delete |
| Packages | `bakery` installed list + updates |
| Hyprland | open config in editor + monitor list |
Bakery desktop apps live in `/usr/local` — shared, already on PATH. A later
account does **not** get a private copy of those binaries.
Build standalone:
`/etc/default/useradd` keeps `SKEL=/etc/skel`. Stock `useradd -m` is enough:
```sh
cargo build --release -p bos-settings
cargo test -p bos-settings # includes config round-trip tests
sudo useradd -m alice
sudo passwd alice
```
## The bread ecosystem at a glance
- **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
Standalone bakery product: **Tauri 2 + Svelte**, not GTK4, and not built
from this repo. Install/update with `bakery`; the ISO just bakes whatever
binary the builder has.
It aims for GNOME-Settings-style parity: live system state and control, so
day-to-day administration doesn't require a terminal. Bread-ecosystem
configs are edited **non-destructively** (comments and unmodeled keys stay).
Panels with a daemon (bread, breadbox, breadcrumbs, breadsearch, breadclip)
also get live systemd status + Start/Stop/Restart/Logs.
| Panel | What it does |
|-------|--------------|
| About | System info (OS/kernel/CPU/GPU/memory/disk/uptime) + hostname |
| Network | Wi-Fi scan/connect, Ethernet status, radio toggle |
| Wi-Fi Profiles (breadcrumbs) | `breadcrumbs.toml` — settings, saved networks, profiles |
| Firewall | ufw rules: enable/disable, add/remove, view active rules |
| Sound | PipeWire output/input device + volume via `pactl` |
| Power | Battery status/health, brightness, charge limits (hardware-dependent), TLP profile (read-only) |
| Date & Time | Timezone, NTP sync toggle |
| Display (Hyprland) | Connected monitors + open `hyprland.lua` in editor |
| Users | Add/remove accounts, change passwords |
| Wallpaper (breadpaper) | Set wallpaper, drives the pywal-derived accent palette |
| Bar (breadbar) | `breadbar/style.css` override, live-reloads on save |
| Launcher (breadbox) | `breadbox/config.toml` — launcher contexts |
| Clipboard (breadclip) | breadclipd service control + "open history" |
| Notes (breadpad) | `breadpad/breadpad.toml` — settings, model + ollama, reminders, calendar |
| File Search (breadsearch) | `breadsearch/config.toml` — index/search/model + breadmill service |
| Daemon (bread) | `breadd.toml` — daemon, lua, modules, adapters, events, notifications |
| Packages | `bakery` installed list + updates, pacman system update |
| AUR | Search via `yay`; installing opens a terminal (AUR build scripts need review) |
| Firmware | `fwupd` device list + updates |
| Snapshots | `snapper` list (number / date / description); reboot to pick in GRUB (grub-btrfs); delete — **root (`@`) only** |
| Backup | restic of `$HOME` (`@home`) via Settings → Backup; snapper does not cover home |
Source and build live in the [bos-settings](https://git.breadway.dev/Breadway/bos-settings)
repo, not here.
## The bread ecosystem
Everything below is a separate bakery-distributed project with its own repo
and release cadence, baked into `/usr/local` at ISO build time so a fresh
install has them all with no network round-trip. Some ship more than one
binary from a single package — that's noted where it applies. Most have a
corresponding **bos-settings** panel; this table is about *using* the app
directly.
**Desktop shell**
| Tool | Role | Launch |
|------|------|--------|
| `bread` / `breadd` | Reactive automation daemon — normalises hardware/compositor signals into events dispatched to Lua modules | runs at login |
| `breadbar` | Top status bar (workspaces, clock, stats, tray) **and** the notification daemon | runs at login |
| `breadbox` | Application launcher | `SUPER+Space` |
| `breadpad` | Notes & reminders (AI-classified, optional CalDAV sync) | `SUPER+U` |
| `breadman` | Package-manager UI | `SUPER+M` |
| `breadcrumbs` | Wi-Fi profile state machine (location-aware) | CLI / BOS Settings |
| `bakery` | CLI package manager for the ecosystem | `bakery` |
| `bos-settings` | Unified GTK4 control panel for all of the above + snapshots + updates | `SUPER+,` |
| `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`) |
| `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` |
| `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 |
| `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**
| Tool | Role | Launch |
|------|------|--------|
| `breadpad` | Quick-capture scratchpad/notes popup with AI classification and optional CalDAV calendar sync | `SUPER+U` |
| `breadman` | The fuller notes manager view (browse/organize) — ships from the same `breadpad` package as a second binary | `SUPER+M` |
| `breadclip` | Clipboard history. `breadclipd` is the background daemon that actually records history; `breadclip` is the GTK4 popup that browses it | `SUPER+V` / `SUPER+Shift+V` |
| `breadsearch` | Semantic system-wide search (indexes files/notes, embeds locally — CPU/ROCm/CUDA backend configurable). `breadmill` is its indexing daemon. | via breadbox, or BOS Settings → File Search |
| `breadhelp` | Onboarding + in-session help/cheatsheet. Content lives at `/usr/local/share/breadhelp/content` (bakery `content.tar.gz`, baked into the image). | `SUPER+/` |
**System**
| Tool | Role | Launch |
|------|------|--------|
| `breadcrumbs` | Location-aware Wi-Fi profile state machine, with optional Tailscale integration — switches network behavior based on which saved network you're on | CLI, or BOS Settings → Wi-Fi Profiles |
| `breadpaper` | Wallpaper manager — sets the wallpaper via `awww`, generates the pywal accent palette from it, and reloads every bread-theme app | BOS Settings → Wallpaper |
| `breadmon` | TUI monitor layout manager (resolution/position/scaling) — the interactive counterpart to BOS Settings' read-only Display panel | `breadmon` in a terminal |
| `breadshot` | Screenshot utility wrapping `grim`/`slurp`/`wl-copy` with Hyprland-aware geometry (multi-monitor-safe region select) | `breadshot`, or `SUPER+Shift+S/C/P` |
**Tooling**
| Tool | Role | Launch |
|------|------|--------|
| `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+,` |
## Keyboard shortcuts
@ -141,12 +328,16 @@ cheatsheet in-session; first boot shows a short welcome (once).
| `SUPER+Return` | Terminal (kitty) |
| `SUPER+Space` | App launcher (breadbox) |
| `SUPER+E` / `SUPER+B` | Files (nautilus) / Browser (zen) |
| `SUPER+U` / `SUPER+M` | breadpad / breadman |
| `SUPER+U` / `SUPER+M` | Notes (breadpad) / notes manager (breadman) |
| `SUPER+,` / `SUPER+/` | BOS Settings / keybind cheatsheet |
| `SUPER+L` / `SUPER+N` | Lock / log out |
| `SUPER+Backspace` | Close window |
| `SUPER+F` / `SUPER+V` / `SUPER+T` | Fullscreen / float / toggle split |
| `SUPER+Shift+V` | Clipboard history |
| `SUPER+F` | Fullscreen |
| `SUPER+I` | Toggle floating |
| `SUPER+P` | Toggle pseudotile |
| `SUPER+R` | Resize mode |
| `SUPER+T` | Toggle split direction |
| `SUPER+V` / `SUPER+Shift+V` | Clipboard history (breadclip) |
| `SUPER+Tab` | Last window |
| `SUPER+Shift+S/C/P` | Screenshot region→file / region→clipboard / screen→file |
| `SUPER+arrows` | Move focus |
@ -155,41 +346,75 @@ cheatsheet in-session; first boot shows a short welcome (once).
| `SUPER+1..0` | Switch to workspace 110 |
| `SUPER+Shift+1..0` | Move window to workspace |
| `SUPER+[ / ]` | Previous / next workspace |
| `SUPER+Shift+[ / ]` | Move window to previous / next workspace |
| `SUPER+scroll` | Cycle workspaces |
| `SUPER+left/right-drag` | Move / resize window with the mouse |
| Volume / brightness / play-pause / next / prev | Media keys — work even on the lock screen |
| Calculator key | Opens gnome-calculator |
## 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.
The **NVIDIA proprietary driver is not included**; NVIDIA users must install
`nvidia`/`nvidia-utils` and set the usual Hyprland env vars after install.
NVIDIA is **unsupported** (no proprietary driver, no NVIDIA firmware). See
[docs/hardware.md](docs/hardware.md).
- **Virtual machines**: Hyprland needs GPU acceleration to be smooth. Use
`virtio-vga-gl` + `-display gtk,gl=on` (virgl); plain software rendering is
noticeably laggy.
- **Wayland-first**: X11-only apps run through XWayland; a few may misbehave.
- **Secure Boot**: not configured. Boot with Secure Boot disabled, or enroll
your own keys. The installer writes both an NVRAM entry and the removable
`EFI/BOOT/BOOTX64.EFI` fallback.
- **Secure Boot**: self-signed only, via `sbctl` — BOS can't ship a
Microsoft-signed shim (that needs going through Microsoft's own paid UEFI
CA process). Post-install enrolls BOS's own keys automatically, but only
when the firmware is already in Setup Mode (no vendor keys installed yet);
otherwise it's skipped and you can run
`sudo sbctl enroll-keys --microsoft && sudo sbctl sign-all -g` yourself
later (after clearing your firmware's existing keys, if any). The installer
writes both an NVRAM entry and the removable `EFI/BOOT/BOOTX64.EFI` fallback
either way.
- **Disk encryption**: full-disk LUKS is available on the installer's "Erase
disk" page (Calamares' own checkbox) and on manually-created partitions —
BOS ships the matching `cryptsetup`/mkinitcpio/GRUB wiring so an encrypted
install actually boots (LUKS1, since GRUB doesn't support LUKS2 + Argon2id).
- **Snapshots assume btrfs**: the snapper/grub-btrfs tooling expects the default
btrfs subvolume layout the installer creates.
btrfs subvolume layout the installer creates. Recovery is the GRUB
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
**An update broke something (system still boots):** open BOS Settings →
Snapshots and roll back, or pick a pre-update snapshot from the **GRUB
“snapshots” submenu** at boot, then run `snapper rollback` from the booted
snapshot.
**An update broke something (system still boots):** reboot → **GRUB
“snapshots” submenu** (grub-btrfs), then boot that entry.
BOS Settings → Snapshots lists each snapshots number, date, and
description so you know which GRUB entry to pick. It does not roll the
running root back in place. Snapper is root only. Home files are
**Settings → Backup** (restic restore into `~/bos-restore-<id>`, not
over `$HOME`).
Do **not** run `snapper rollback`. BOS GRUB pins `rootflags=subvol=@`, so
a snapper-swapped default subvolume is not what the installed grub.cfg
will boot next. Details: [docs/hardware.md](docs/hardware.md).
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):**
1. Boot the BOS ISO and open a terminal (`SUPER+Return`).
2. Mount the installed root and EFI, then chroot:
2. Run `sudo bos-rescue`. It finds the installed btrfs `@` and the ESP,
prints the devices it will use, and asks `YES` before writing. It can
`arch-chroot` and/or reinstall GRUB with the same sequence the
installer uses (NVRAM + `--removable` + `grub-mkconfig`).
3. Manual equivalent, if you would rather type it:
```sh
mount -o subvol=@ /dev/sdXN /mnt
mount /dev/sdXP /mnt/boot/efi # the EFI partition
arch-chroot /mnt
```
3. Reinstall the bootloader (the same sequence the installer uses):
```sh
grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=BOS --recheck
grub-install --target=x86_64-efi --efi-directory=/boot/efi --removable --recheck
grub-mkconfig -o /boot/grub/grub.cfg

View file

@ -1,12 +0,0 @@
name = "bos-settings"
description = "System settings app for Bread OS"
binaries = ["bos-settings"]
system_deps = ["gtk4", "glib2"]
optional_system_deps = ["snapper"]
bread_deps = []
[config]
dir = "~/.config"
[install]
post_install = []

View file

@ -1,19 +0,0 @@
[package]
name = "bos-settings"
version = "0.4.1"
edition = "2021"
[dependencies]
gtk4 = { version = "0.11", features = ["v4_12"] }
glib = "0.22"
# Shared ecosystem theming — bos-settings loads the same generated stylesheet as
# breadbar/breadbox/breadpad so the whole desktop looks consistent.
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
# toml_edit drives non-destructive config editing: it preserves comments and
# any keys the UI doesn't model, so saving a single field never rewrites or
# drops the rest of the user's config file.
toml_edit = "0.22"
async-channel = "2"

View file

@ -1,213 +0,0 @@
//! Non-destructive config editing.
//!
//! Every bread* app owns a TOML config that may contain keys, sections, and
//! comments this settings app does not model (e.g. breadpad's calendar
//! credentials, breadcrumbs' saved-network passwords). To edit safely we parse
//! the file into a `toml_edit::DocumentMut`, mutate only the specific keys the
//! UI exposes, and write the document back — preserving everything else,
//! formatting and comments included.
use std::error::Error;
use std::path::{Path, PathBuf};
use toml_edit::{value, Array, DocumentMut, Item, Table, Value};
/// Load a TOML file into an editable document. A missing file yields an
/// empty document so the UI still renders with defaults — normal for a fresh
/// install. A file that *exists* but fails to parse is far more dangerous:
/// falling back to an empty document there means the next Save (see
/// `save_doc`) overwrites it with only the UI-modelled keys, silently
/// destroying anything else in the file (breadpad's calendar credentials,
/// breadcrumbs' saved network passwords, ...). Back up the unparseable file
/// once before falling back, so a bad edit is always recoverable.
pub fn load_doc(path: &Path) -> DocumentMut {
let Ok(text) = std::fs::read_to_string(path) else {
return DocumentMut::default();
};
match text.parse::<DocumentMut>() {
Ok(doc) => doc,
Err(e) => {
let backup = PathBuf::from(format!("{}.bak", path.display()));
eprintln!(
"bos-settings: {} failed to parse ({e}); backed up to {} before falling back to defaults",
path.display(),
backup.display()
);
let _ = std::fs::write(&backup, &text);
DocumentMut::default()
}
}
}
/// Write the document back to disk, creating parent dirs as needed.
pub fn save_doc(path: &Path, doc: &DocumentMut) -> Result<(), Box<dyn Error>> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, doc.to_string())?;
Ok(())
}
pub fn config_dir() -> PathBuf {
// Honour XDG_CONFIG_HOME if set; otherwise fall back to $HOME/.config.
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
let p = PathBuf::from(xdg);
if p.is_absolute() {
return p;
}
}
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
PathBuf::from(home).join(".config")
}
// --- typed readers (walk a dotted path, return None if absent/wrong type) ---
fn get<'a>(doc: &'a DocumentMut, path: &[&str]) -> Option<&'a Item> {
let mut tbl = doc.as_table();
let (last, parents) = path.split_last()?;
for key in parents {
tbl = tbl.get(key)?.as_table()?;
}
tbl.get(last)
}
pub fn get_bool(doc: &DocumentMut, path: &[&str]) -> Option<bool> {
get(doc, path)?.as_bool()
}
pub fn get_str(doc: &DocumentMut, path: &[&str]) -> Option<String> {
get(doc, path)?.as_str().map(str::to_string)
}
pub fn get_i64(doc: &DocumentMut, path: &[&str]) -> Option<i64> {
get(doc, path)?.as_integer()
}
pub fn get_f64(doc: &DocumentMut, path: &[&str]) -> Option<f64> {
let item = get(doc, path)?;
item.as_float().or_else(|| item.as_integer().map(|i| i as f64))
}
/// Read an array of strings (e.g. modules.disable, contexts[].priority).
pub fn get_str_list(doc: &DocumentMut, path: &[&str]) -> Vec<String> {
match get(doc, path).and_then(Item::as_array) {
Some(arr) => arr
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect(),
None => Vec::new(),
}
}
// --- setters (auto-create intermediate tables, replace only the leaf) ---
fn table_at_mut<'a>(doc: &'a mut DocumentMut, parents: &[&str]) -> &'a mut Table {
let mut tbl = doc.as_table_mut();
for key in parents {
let entry = tbl.entry(key).or_insert_with(|| Item::Table(Table::new()));
if !entry.is_table() {
*entry = Item::Table(Table::new());
}
tbl = entry.as_table_mut().expect("just ensured table");
}
tbl
}
fn set_item(doc: &mut DocumentMut, path: &[&str], item: Item) {
let Some((last, parents)) = path.split_last() else {
return;
};
table_at_mut(doc, parents).insert(last, item);
}
pub fn set_bool(doc: &mut DocumentMut, path: &[&str], v: bool) {
set_item(doc, path, value(v));
}
pub fn set_str(doc: &mut DocumentMut, path: &[&str], v: &str) {
set_item(doc, path, value(v));
}
pub fn set_i64(doc: &mut DocumentMut, path: &[&str], v: i64) {
set_item(doc, path, value(v));
}
pub fn set_f64(doc: &mut DocumentMut, path: &[&str], v: f64) {
set_item(doc, path, value(v));
}
pub fn set_str_list(doc: &mut DocumentMut, path: &[&str], items: &[String]) {
let mut arr = Array::new();
for s in items {
arr.push(s.as_str());
}
set_item(doc, path, Item::Value(Value::Array(arr)));
}
/// Set a string key, or remove it entirely when the value is empty — keeps
/// optional fields out of the file rather than persisting `key = ""`.
pub fn set_str_or_remove(doc: &mut DocumentMut, path: &[&str], v: &str) {
if v.is_empty() {
remove(doc, path);
} else {
set_str(doc, path, v);
}
}
pub fn remove(doc: &mut DocumentMut, path: &[&str]) {
if let Some((last, parents)) = path.split_last() {
let mut tbl = doc.as_table_mut();
for key in parents {
match tbl.get_mut(key).and_then(Item::as_table_mut) {
Some(t) => tbl = t,
None => return,
}
}
tbl.remove(last);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn edits_preserve_unmodelled_keys_and_comments() {
let src = "\
# a leading comment
[daemon]
log_level = \"info\"
[calendar]
password = \"secret\" # keep me
";
let mut doc: DocumentMut = src.parse().unwrap();
// Modify a single modelled key.
set_str(&mut doc, &["daemon", "log_level"], "debug");
// A key/section the UI never touches must survive untouched.
let out = doc.to_string();
assert!(out.contains("log_level = \"debug\""));
assert!(out.contains("password = \"secret\""));
assert!(out.contains("# keep me"));
assert!(out.contains("# a leading comment"));
}
#[test]
fn setters_create_missing_tables() {
let mut doc = DocumentMut::new();
set_bool(&mut doc, &["adapters", "power", "enabled"], false);
set_i64(&mut doc, &["adapters", "power", "poll_interval_secs"], 45);
assert_eq!(get_bool(&doc, &["adapters", "power", "enabled"]), Some(false));
assert_eq!(
get_i64(&doc, &["adapters", "power", "poll_interval_secs"]),
Some(45)
);
}
#[test]
fn empty_string_removes_key() {
let mut doc: DocumentMut = "[calendar]\nurl = \"x\"\n".parse().unwrap();
set_str_or_remove(&mut doc, &["calendar", "url"], "");
assert_eq!(get_str(&doc, &["calendar", "url"]), None);
}
#[test]
fn str_list_roundtrips() {
let mut doc = DocumentMut::new();
let items = vec!["a".to_string(), "b".to_string()];
set_str_list(&mut doc, &["modules", "disable"], &items);
assert_eq!(get_str_list(&doc, &["modules", "disable"]), items);
}
}

View file

@ -1,13 +0,0 @@
mod config;
mod theme;
mod ui;
use gtk4::prelude::*;
fn main() {
let app = gtk4::Application::builder()
.application_id("com.breadway.bos-settings")
.build();
app.connect_activate(ui::window::build_ui);
app.run();
}

View file

@ -1,30 +0,0 @@
//! Theming for bos-settings.
//!
//! bos-settings deliberately owns almost no styling: it loads the ecosystem's
//! shared stylesheet (the same one breadbar/breadbox/breadpad use, generated by
//! `bread-theme` from the pywal palette) and adds only the few layout rules
//! specific to this app's sidebar + content shell. This keeps it visually
//! identical to the rest of the bread desktop and live-recolouring for free.
use gtk4::CssProvider;
use std::cell::RefCell;
// App-specific layout only — everything visual (colours, buttons, entries,
// switches, sidebar/row styling, cards, scrollbars) comes from the shared sheet.
const APP_CSS: &str = "\
.view-content { padding: 24px; }\n\
.view-content > label.title { margin-bottom: 16px; }\n\
";
thread_local! {
static APP_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
}
pub fn load(_display: &gtk4::gdk::Display) {
// Shared ecosystem stylesheet (loads the generated file or a rendered
// fallback, and live-reloads when the palette changes).
bread_theme::gtk::apply_shared();
// bos-settings layout, layered on top at APPLICATION priority.
APP_PROVIDER.with(|cell| bread_theme::gtk::apply_css(APP_CSS, cell));
}

View file

@ -1,4 +0,0 @@
pub mod sidebar;
pub mod views;
pub mod widgets;
pub mod window;

View file

@ -1,74 +0,0 @@
use gtk4::prelude::*;
use gtk4::{Box as GBox, Label, ListBox, ListBoxRow, Orientation};
pub struct SidebarItem {
pub id: &'static str,
pub label: &'static str,
}
pub const APPS_ITEMS: &[SidebarItem] = &[
SidebarItem { id: "bread", label: "bread" },
SidebarItem { id: "breadbar", label: "breadbar" },
SidebarItem { id: "breadbox", label: "breadbox" },
SidebarItem { id: "breadcrumbs", label: "breadcrumbs" },
SidebarItem { id: "breadpad", label: "breadpad" },
SidebarItem { id: "breadpaper", label: "breadpaper" },
SidebarItem { id: "breadsearch", label: "breadsearch" },
];
pub const SYSTEM_ITEMS: &[SidebarItem] = &[
SidebarItem { id: "snapshots", label: "Snapshots" },
SidebarItem { id: "packages", label: "Packages" },
SidebarItem { id: "hyprland", label: "Hyprland" },
];
pub fn build() -> (GBox, ListBox) {
let vbox = GBox::new(Orientation::Vertical, 0);
vbox.add_css_class("sidebar");
vbox.set_width_request(190);
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::Single);
list.add_css_class("sidebar");
append_section(&list, "Apps", APPS_ITEMS);
append_section(&list, "System", SYSTEM_ITEMS);
// Select the bread row so it matches the default stack page
let mut i = 0;
loop {
match list.row_at_index(i) {
None => break,
Some(row) if row.widget_name() == "bread" => {
list.select_row(Some(&row));
break;
}
_ => i += 1,
}
}
vbox.append(&list);
(vbox, list)
}
fn append_section(list: &ListBox, title: &str, items: &[SidebarItem]) {
let header_row = ListBoxRow::new();
header_row.set_selectable(false);
header_row.set_activatable(false);
let header_lbl = Label::new(Some(title));
header_lbl.add_css_class("section-header");
header_lbl.set_xalign(0.0);
header_row.set_child(Some(&header_lbl));
list.append(&header_row);
for item in items {
let row = ListBoxRow::new();
row.set_widget_name(item.id);
let lbl = Label::new(Some(item.label));
lbl.set_xalign(0.0);
lbl.set_margin_top(2);
lbl.set_margin_bottom(2);
row.set_child(Some(&lbl));
list.append(&row);
}
}

View file

@ -1,158 +0,0 @@
//! breadd.toml — the bread daemon config.
//! Schema mirrors breadd/src/core/config.rs (daemon, lua, modules, adapters,
//! events, notifications). Edited non-destructively via the shared document.
use std::cell::RefCell;
use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::Box as GBox;
use crate::config;
use crate::ui::widgets as w;
fn config_path() -> std::path::PathBuf {
config::config_dir().join("bread/breadd.toml")
}
pub fn build() -> GBox {
let path = config_path();
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
let (outer, c) = w::view_scaffold("bread");
c.append(&w::section("Daemon"));
c.append(&w::dropdown_row(
"Log level",
&doc,
&["daemon", "log_level"],
&["error", "warn", "info", "debug", "trace"],
"info",
));
c.append(&w::entry_row(
"Socket path",
&doc,
&["daemon", "socket_path"],
"default (XDG runtime dir)",
"",
));
c.append(&w::section("Lua"));
c.append(&w::entry_row(
"Entry point",
&doc,
&["lua", "entry_point"],
"~/.config/bread/init.lua",
"",
));
c.append(&w::entry_row(
"Module path",
&doc,
&["lua", "module_path"],
"~/.config/bread/modules",
"",
));
c.append(&w::section("Modules"));
c.append(&w::switch_row(
"Load built-in modules",
&doc,
&["modules", "builtin"],
true,
));
c.append(&w::csv_row(
"Disabled modules",
&doc,
&["modules", "disable"],
"module-a, module-b",
));
c.append(&w::section("Adapters"));
c.append(&w::hint(
"Sources breadd normalises into events. Disable any you don't use.",
));
c.append(&w::switch_row(
"Hyprland",
&doc,
&["adapters", "hyprland", "enabled"],
true,
));
c.append(&w::switch_row(
"udev (devices)",
&doc,
&["adapters", "udev", "enabled"],
true,
));
c.append(&w::csv_row(
"udev subsystems",
&doc,
&["adapters", "udev", "subsystems"],
"usb, input, power_supply",
));
c.append(&w::switch_row(
"Power",
&doc,
&["adapters", "power", "enabled"],
true,
));
c.append(&w::spin_row(
"Power poll interval (s)",
&doc,
&["adapters", "power", "poll_interval_secs"],
1.0,
3600.0,
1.0,
30,
));
c.append(&w::switch_row(
"Network",
&doc,
&["adapters", "network", "enabled"],
true,
));
c.append(&w::switch_row(
"Bluetooth",
&doc,
&["adapters", "bluetooth", "enabled"],
true,
));
c.append(&w::section("Events"));
c.append(&w::spin_row(
"Dedup window (ms)",
&doc,
&["events", "dedup_window_ms"],
0.0,
10000.0,
50.0,
250,
));
c.append(&w::section("Notifications"));
c.append(&w::spin_row(
"Default timeout (ms)",
&doc,
&["notifications", "default_timeout_ms"],
0.0,
60000.0,
500.0,
5000,
));
c.append(&w::dropdown_row(
"Default urgency",
&doc,
&["notifications", "default_urgency"],
&["low", "normal", "critical"],
"normal",
));
c.append(&w::entry_row(
"notify-send path",
&doc,
&["notifications", "notify_send_path"],
"auto-detected",
"",
));
outer.append(&w::save_button(&doc, path));
outer
}

View file

@ -1,75 +0,0 @@
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Label, Orientation, ScrolledWindow, TextView};
use std::path::PathBuf;
fn css_path() -> PathBuf {
crate::config::config_dir().join("breadbar/style.css")
}
pub fn build() -> GBox {
let path = css_path();
let existing_css = std::fs::read_to_string(&path).unwrap_or_default();
let vbox = GBox::new(Orientation::Vertical, 12);
vbox.add_css_class("view-content");
let title = Label::new(Some("breadbar"));
title.add_css_class("title");
title.set_xalign(0.0);
vbox.append(&title);
let subtitle = Label::new(Some(
"CSS overrides for breadbar. Leave empty to use the default bread theme.",
));
subtitle.set_xalign(0.0);
subtitle.set_margin_bottom(8);
subtitle.set_wrap(true);
vbox.append(&subtitle);
let buf = gtk4::TextBuffer::new(None);
buf.set_text(&existing_css);
let text_view = TextView::with_buffer(&buf);
text_view.set_monospace(true);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_child(Some(&text_view));
vbox.append(&scroll);
let btn_row = GBox::new(Orientation::Horizontal, 12);
btn_row.set_margin_top(12);
let save_btn = Button::with_label("Save");
let status_lbl = Label::new(None);
status_lbl.add_css_class("dim-label");
{
let path = path.clone();
let status_lbl = status_lbl.clone();
save_btn.connect_clicked(move |_| {
let (start, end) = buf.bounds();
let text = buf.text(&start, &end, false);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::write(&path, text.as_str()) {
Ok(()) => {
status_lbl.set_text("Saved");
let lbl = status_lbl.clone();
glib::timeout_add_seconds_local(3, move || {
lbl.set_text("");
glib::ControlFlow::Break
});
}
Err(e) => status_lbl.set_text(&format!("Error: {e}")),
}
});
}
btn_row.append(&save_btn);
btn_row.append(&status_lbl);
vbox.append(&btn_row);
vbox
}

View file

@ -1,204 +0,0 @@
//! breadbox config.toml — launcher contexts.
//! Schema mirrors breadbox-shared (`#[serde(rename = "context")]` — the TOML
//! key is `[[context]]`, singular, despite the Rust field being `contexts`),
//! with `name` + `priority`, an ordered list of app/category hints. The
//! context array is rewritten on save; any other top-level keys/comments in
//! the file are preserved.
use std::cell::RefCell;
use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::{
Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow,
};
use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
use crate::config;
#[derive(Clone, Default)]
struct Context {
name: String,
priority: Vec<String>,
}
fn config_path() -> std::path::PathBuf {
config::config_dir().join("breadbox/config.toml")
}
fn read_contexts(doc: &DocumentMut) -> Vec<Context> {
let Some(aot) = doc.get("context").and_then(Item::as_array_of_tables) else {
return Vec::new();
};
aot.iter()
.map(|t| Context {
name: t.get("name").and_then(Item::as_str).unwrap_or("").to_string(),
priority: t
.get("priority")
.and_then(Item::as_array)
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default(),
})
.collect()
}
/// Rewrite only the `contexts` array-of-tables, leaving the rest of the doc.
fn write_contexts(doc: &mut DocumentMut, ctxs: &[Context]) {
let mut aot = ArrayOfTables::new();
for ctx in ctxs {
let mut t = Table::new();
t.insert("name", value(&ctx.name));
let mut arr = Array::new();
for p in &ctx.priority {
arr.push(p.as_str());
}
t.insert("priority", value(arr));
aot.push(t);
}
doc.as_table_mut().insert("context", Item::ArrayOfTables(aot));
}
fn rebuild_list(list: &ListBox, model: &Rc<RefCell<Vec<Context>>>) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
for (i, ctx) in model.borrow().iter().enumerate() {
let row = ListBoxRow::new();
row.set_selectable(false);
let hbox = GBox::new(Orientation::Horizontal, 8);
hbox.set_margin_top(6);
hbox.set_margin_bottom(6);
hbox.set_margin_start(8);
hbox.set_margin_end(8);
let name_entry = Entry::new();
name_entry.set_text(&ctx.name);
name_entry.set_width_chars(14);
name_entry.set_placeholder_text(Some("name"));
let prio_entry = Entry::new();
prio_entry.set_text(&ctx.priority.join(", "));
prio_entry.set_hexpand(true);
prio_entry.set_placeholder_text(Some("firefox, code, Development, ..."));
let remove_btn = Button::with_label("Remove");
remove_btn.add_css_class("destructive-action");
{
let model = model.clone();
name_entry.connect_changed(move |e| {
if let Some(c) = model.borrow_mut().get_mut(i) {
c.name = e.text().to_string();
}
});
}
{
let model = model.clone();
prio_entry.connect_changed(move |e| {
if let Some(c) = model.borrow_mut().get_mut(i) {
c.priority = e
.text()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
});
}
{
let model = model.clone();
let list = list.clone();
remove_btn.connect_clicked(move |_| {
model.borrow_mut().remove(i);
rebuild_list(&list, &model);
});
}
hbox.append(&name_entry);
hbox.append(&prio_entry);
hbox.append(&remove_btn);
row.set_child(Some(&hbox));
list.append(&row);
}
}
pub fn build() -> GBox {
let path = config_path();
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
let model = Rc::new(RefCell::new(read_contexts(&doc.borrow())));
let vbox = GBox::new(Orientation::Vertical, 12);
vbox.add_css_class("view-content");
let title = Label::new(Some("breadbox"));
title.add_css_class("title");
title.set_xalign(0.0);
vbox.append(&title);
let subtitle = Label::new(Some(
"Launcher contexts — each lists, in priority order, the apps/categories surfaced first.",
));
subtitle.set_xalign(0.0);
subtitle.set_wrap(true);
subtitle.set_margin_bottom(8);
vbox.append(&subtitle);
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
rebuild_list(&list, &model);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_child(Some(&list));
vbox.append(&scroll);
let btn_row = GBox::new(Orientation::Horizontal, 8);
btn_row.set_margin_top(8);
let add_btn = Button::with_label("Add context");
{
let model = model.clone();
let list = list.clone();
add_btn.connect_clicked(move |_| {
model.borrow_mut().push(Context {
name: "new".to_string(),
priority: Vec::new(),
});
rebuild_list(&list, &model);
});
}
let save_btn = Button::with_label("Save");
save_btn.add_css_class("suggested-action");
let status_lbl = Label::new(None);
status_lbl.add_css_class("dim-label");
{
let doc = doc.clone();
let model = model.clone();
let path = path.clone();
let status_lbl = status_lbl.clone();
save_btn.connect_clicked(move |_| {
write_contexts(&mut doc.borrow_mut(), &model.borrow());
match config::save_doc(&path, &doc.borrow()) {
Ok(()) => {
status_lbl.set_text("Saved");
let lbl = status_lbl.clone();
glib::timeout_add_seconds_local(3, move || {
lbl.set_text("");
glib::ControlFlow::Break
});
}
Err(e) => status_lbl.set_text(&format!("Error: {e}")),
}
});
}
btn_row.append(&add_btn);
btn_row.append(&save_btn);
btn_row.append(&status_lbl);
vbox.append(&btn_row);
vbox
}

View file

@ -1,479 +0,0 @@
//! breadcrumbs.toml — Wi-Fi profile state machine.
//! Schema mirrors breadcrumbs/src/config.rs:
//! [settings] scalar tunables
//! [[networks]] saved networks (ssid / password / hidden)
//! [profiles.<name>] per-location profile (networks, tailscale, …)
//! `[settings]` is edited in place; the `networks` array and `profiles` table
//! are rewritten from their editors on save. Other keys/comments are preserved.
use std::cell::RefCell;
use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::{
Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch,
};
use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
use crate::config;
use crate::ui::widgets as w;
fn config_path() -> std::path::PathBuf {
config::config_dir().join("breadcrumbs/breadcrumbs.toml")
}
// --- networks ---------------------------------------------------------------
#[derive(Clone, Default)]
struct Network {
ssid: String,
password: String,
hidden: bool,
}
fn read_networks(doc: &DocumentMut) -> Vec<Network> {
let Some(aot) = doc.get("networks").and_then(Item::as_array_of_tables) else {
return Vec::new();
};
aot.iter()
.map(|t| Network {
ssid: t.get("ssid").and_then(Item::as_str).unwrap_or("").to_string(),
password: t
.get("password")
.and_then(Item::as_str)
.unwrap_or("")
.to_string(),
hidden: t.get("hidden").and_then(Item::as_bool).unwrap_or(false),
})
.collect()
}
fn write_networks(doc: &mut DocumentMut, nets: &[Network]) {
let mut aot = ArrayOfTables::new();
for n in nets {
let mut t = Table::new();
t.insert("ssid", value(&n.ssid));
t.insert("password", value(&n.password));
t.insert("hidden", value(n.hidden));
aot.push(t);
}
doc.as_table_mut().insert("networks", Item::ArrayOfTables(aot));
}
fn rebuild_networks(list: &ListBox, model: &Rc<RefCell<Vec<Network>>>) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
for (i, n) in model.borrow().iter().enumerate() {
let row = ListBoxRow::new();
row.set_selectable(false);
let hbox = GBox::new(Orientation::Horizontal, 8);
hbox.set_margin_top(6);
hbox.set_margin_bottom(6);
hbox.set_margin_start(8);
hbox.set_margin_end(8);
let ssid = Entry::new();
ssid.set_text(&n.ssid);
ssid.set_width_chars(16);
ssid.set_placeholder_text(Some("SSID"));
let pass = Entry::new();
pass.set_text(&n.password);
pass.set_hexpand(true);
pass.set_visibility(false);
pass.set_input_purpose(gtk4::InputPurpose::Password);
pass.set_placeholder_text(Some("password"));
let hidden = Switch::new();
hidden.set_active(n.hidden);
hidden.set_valign(gtk4::Align::Center);
hidden.set_tooltip_text(Some("Hidden network"));
let remove = Button::with_label("Remove");
remove.add_css_class("destructive-action");
{
let model = model.clone();
ssid.connect_changed(move |e| {
if let Some(n) = model.borrow_mut().get_mut(i) {
n.ssid = e.text().to_string();
}
});
}
{
let model = model.clone();
pass.connect_changed(move |e| {
if let Some(n) = model.borrow_mut().get_mut(i) {
n.password = e.text().to_string();
}
});
}
{
let model = model.clone();
hidden.connect_active_notify(move |s| {
if let Some(n) = model.borrow_mut().get_mut(i) {
n.hidden = s.is_active();
}
});
}
{
let model = model.clone();
let list = list.clone();
remove.connect_clicked(move |_| {
model.borrow_mut().remove(i);
rebuild_networks(&list, &model);
});
}
hbox.append(&ssid);
hbox.append(&pass);
hbox.append(&Label::new(Some("hidden")));
hbox.append(&hidden);
hbox.append(&remove);
row.set_child(Some(&hbox));
list.append(&row);
}
}
// --- profiles ---------------------------------------------------------------
#[derive(Clone, Default)]
struct Profile {
name: String,
networks: Vec<String>,
detect_ssids: Vec<String>,
bootstrap: String,
exit_node: String,
tailscale: bool,
include_all_known: bool,
}
fn read_profiles(doc: &DocumentMut) -> Vec<Profile> {
let Some(tbl) = doc.get("profiles").and_then(Item::as_table) else {
return Vec::new();
};
let str_list = |item: Option<&Item>| -> Vec<String> {
item.and_then(Item::as_array)
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default()
};
tbl.iter()
.filter_map(|(name, item)| {
let p = item.as_table()?;
Some(Profile {
name: name.to_string(),
networks: str_list(p.get("networks")),
detect_ssids: str_list(p.get("detect_ssids")),
bootstrap: p.get("bootstrap").and_then(Item::as_str).unwrap_or("").to_string(),
exit_node: p.get("exit_node").and_then(Item::as_str).unwrap_or("").to_string(),
tailscale: p.get("tailscale").and_then(Item::as_bool).unwrap_or(false),
include_all_known: p
.get("include_all_known")
.and_then(Item::as_bool)
.unwrap_or(false),
})
})
.collect()
}
fn write_profiles(doc: &mut DocumentMut, profiles: &[Profile]) {
let mut tbl = Table::new();
let to_arr = |items: &[String]| {
let mut a = Array::new();
for s in items {
a.push(s.as_str());
}
a
};
for p in profiles {
if p.name.is_empty() {
continue;
}
let mut t = Table::new();
t.insert("networks", value(to_arr(&p.networks)));
t.insert("tailscale", value(p.tailscale));
t.insert("include_all_known", value(p.include_all_known));
if !p.detect_ssids.is_empty() {
t.insert("detect_ssids", value(to_arr(&p.detect_ssids)));
}
if !p.bootstrap.is_empty() {
t.insert("bootstrap", value(&p.bootstrap));
}
if !p.exit_node.is_empty() {
t.insert("exit_node", value(&p.exit_node));
}
tbl.insert(&p.name, Item::Table(t));
}
doc.as_table_mut().insert("profiles", Item::Table(tbl));
}
fn field(label: &str, control: &impl IsA<gtk4::Widget>) -> GBox {
let row = GBox::new(Orientation::Horizontal, 12);
let lbl = Label::new(Some(label));
lbl.set_xalign(0.0);
lbl.set_width_chars(16);
row.append(&lbl);
control.set_hexpand(true);
row.append(control);
row
}
fn rebuild_profiles(container: &GBox, model: &Rc<RefCell<Vec<Profile>>>) {
while let Some(child) = container.first_child() {
container.remove(&child);
}
for (i, p) in model.borrow().iter().enumerate() {
let card = GBox::new(Orientation::Vertical, 6);
card.add_css_class("card");
card.set_margin_top(6);
card.set_margin_bottom(6);
let header = GBox::new(Orientation::Horizontal, 8);
let name = Entry::new();
name.set_text(&p.name);
name.set_hexpand(true);
name.set_placeholder_text(Some("profile name (e.g. home)"));
let remove = Button::with_label("Remove");
remove.add_css_class("destructive-action");
header.append(&name);
header.append(&remove);
card.append(&header);
let networks = Entry::new();
networks.set_text(&p.networks.join(", "));
networks.set_placeholder_text(Some("SSID1, SSID2"));
card.append(&field("Networks", &networks));
let detect = Entry::new();
detect.set_text(&p.detect_ssids.join(", "));
detect.set_placeholder_text(Some("SSIDs that auto-select this profile"));
card.append(&field("Detect SSIDs", &detect));
let exit_node = Entry::new();
exit_node.set_text(&p.exit_node);
exit_node.set_placeholder_text(Some("tailscale exit node (optional)"));
card.append(&field("Exit node", &exit_node));
let bootstrap = Entry::new();
bootstrap.set_text(&p.bootstrap);
bootstrap.set_placeholder_text(Some("bootstrap command (optional)"));
card.append(&field("Bootstrap", &bootstrap));
let tailscale = Switch::new();
tailscale.set_active(p.tailscale);
tailscale.set_halign(gtk4::Align::Start);
card.append(&field("Tailscale", &tailscale));
let include_all = Switch::new();
include_all.set_active(p.include_all_known);
include_all.set_halign(gtk4::Align::Start);
card.append(&field("Include all known", &include_all));
// bind each control to the in-memory model entry
macro_rules! bind_csv {
($entry:ident, $f:ident) => {{
let model = model.clone();
$entry.connect_changed(move |e| {
if let Some(p) = model.borrow_mut().get_mut(i) {
p.$f = e
.text()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
});
}};
}
macro_rules! bind_str {
($entry:ident, $f:ident) => {{
let model = model.clone();
$entry.connect_changed(move |e| {
if let Some(p) = model.borrow_mut().get_mut(i) {
p.$f = e.text().to_string();
}
});
}};
}
macro_rules! bind_bool {
($sw:ident, $f:ident) => {{
let model = model.clone();
$sw.connect_active_notify(move |s| {
if let Some(p) = model.borrow_mut().get_mut(i) {
p.$f = s.is_active();
}
});
}};
}
bind_str!(name, name);
bind_csv!(networks, networks);
bind_csv!(detect, detect_ssids);
bind_str!(exit_node, exit_node);
bind_str!(bootstrap, bootstrap);
bind_bool!(tailscale, tailscale);
bind_bool!(include_all, include_all_known);
{
let model = model.clone();
let container = container.clone();
remove.connect_clicked(move |_| {
model.borrow_mut().remove(i);
rebuild_profiles(&container, &model);
});
}
container.append(&card);
}
}
// --- view -------------------------------------------------------------------
pub fn build() -> GBox {
let path = config_path();
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
let nets = Rc::new(RefCell::new(read_networks(&doc.borrow())));
let profiles = Rc::new(RefCell::new(read_profiles(&doc.borrow())));
let outer = GBox::new(Orientation::Vertical, 8);
outer.add_css_class("view-content");
let title = Label::new(Some("breadcrumbs"));
title.add_css_class("title");
title.set_xalign(0.0);
outer.append(&title);
let content = GBox::new(Orientation::Vertical, 8);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_hscrollbar_policy(gtk4::PolicyType::Never);
scroll.set_child(Some(&content));
outer.append(&scroll);
// [settings] — edited in place on the shared doc
content.append(&w::section("Settings"));
content.append(&w::dropdown_row(
"Default profile",
&doc,
&["settings", "default_profile"],
&["home", "away"],
// breadcrumbs' own default_profile_name() is "away", not "home" —
// this was showing the wrong value for an unset key.
"away",
));
content.append(&w::entry_row("DNS", &doc, &["settings", "dns"], "1.1.1.1", ""));
content.append(&w::entry_row(
"Exit node",
&doc,
&["settings", "exit_node"],
"tailscale exit node",
"",
));
content.append(&w::entry_row(
"Ping host",
&doc,
&["settings", "ping_host"],
"1.1.1.1",
"",
));
content.append(&w::entry_row(
"Connectivity URL",
&doc,
&["settings", "connectivity_url"],
"http://connectivitycheck.gstatic.com/generate_204",
"",
));
content.append(&w::spin_row(
"nmcli wait (s)",
&doc,
&["settings", "nmcli_wait"],
1.0,
120.0,
1.0,
8,
));
content.append(&w::spin_row(
"Watch interval (s)",
&doc,
&["settings", "watch_interval"],
1.0,
600.0,
1.0,
12,
));
// [[networks]]
content.append(&w::section("Saved networks"));
let net_list = ListBox::new();
net_list.set_selection_mode(gtk4::SelectionMode::None);
rebuild_networks(&net_list, &nets);
content.append(&net_list);
let add_net = Button::with_label("Add network");
add_net.set_halign(gtk4::Align::Start);
{
let nets = nets.clone();
let net_list = net_list.clone();
add_net.connect_clicked(move |_| {
nets.borrow_mut().push(Network::default());
rebuild_networks(&net_list, &nets);
});
}
content.append(&add_net);
// [profiles.*]
content.append(&w::section("Profiles"));
let prof_box = GBox::new(Orientation::Vertical, 4);
rebuild_profiles(&prof_box, &profiles);
content.append(&prof_box);
let add_prof = Button::with_label("Add profile");
add_prof.set_halign(gtk4::Align::Start);
{
let profiles = profiles.clone();
let prof_box = prof_box.clone();
add_prof.connect_clicked(move |_| {
profiles.borrow_mut().push(Profile {
name: "new".to_string(),
..Default::default()
});
rebuild_profiles(&prof_box, &profiles);
});
}
content.append(&add_prof);
// Save — fold the network + profile editors back into the doc, then write.
let btn_row = GBox::new(Orientation::Horizontal, 12);
btn_row.set_margin_top(16);
let save_btn = Button::with_label("Save");
save_btn.add_css_class("suggested-action");
let status = Label::new(None);
status.add_css_class("dim-label");
{
let doc = doc.clone();
let nets = nets.clone();
let profiles = profiles.clone();
let path = path.clone();
let status = status.clone();
save_btn.connect_clicked(move |_| {
{
let mut d = doc.borrow_mut();
write_networks(&mut d, &nets.borrow());
write_profiles(&mut d, &profiles.borrow());
}
match config::save_doc(&path, &doc.borrow()) {
Ok(()) => {
status.set_text("Saved");
let lbl = status.clone();
glib::timeout_add_seconds_local(3, move || {
lbl.set_text("");
glib::ControlFlow::Break
});
}
Err(e) => status.set_text(&format!("Error: {e}")),
}
});
}
btn_row.append(&save_btn);
btn_row.append(&status);
outer.append(&btn_row);
outer
}

View file

@ -1,146 +0,0 @@
//! breadpad.toml — the breadpad notes/reminders config.
//! Schema mirrors breadpad-shared/src/config.rs (settings, model + model.ollama,
//! reminders, calendar). Edited non-destructively (the calendar password and
//! model paths are preserved across saves).
use std::cell::RefCell;
use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::Box as GBox;
use crate::config;
use crate::ui::widgets as w;
fn config_path() -> std::path::PathBuf {
config::config_dir().join("breadpad/breadpad.toml")
}
pub fn build() -> GBox {
let path = config_path();
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
let (outer, c) = w::view_scaffold("breadpad");
c.append(&w::section("Capture"));
c.append(&w::dropdown_row(
"Default note type",
&doc,
&["settings", "default_type"],
&["note", "reminder", "task"],
"note",
));
c.append(&w::switch_row(
"Tag with active workspace",
&doc,
&["settings", "workspace_tag"],
true,
));
c.append(&w::csv_row(
"Snooze options",
&doc,
&["settings", "snooze_options"],
"15m, 1h, tomorrow_morning",
));
c.append(&w::spin_row(
"Archive after (days)",
&doc,
&["settings", "archive_after_days"],
0.0,
3650.0,
1.0,
30,
));
c.append(&w::section("Classifier model"));
c.append(&w::entry_row(
"ONNX model path",
&doc,
&["model", "path"],
"~/.local/share/breadpad/model/classifier.onnx",
"",
));
c.append(&w::entry_row(
"Tokenizer path",
&doc,
&["model", "tokenizer"],
"~/.local/share/breadpad/model/tokenizer.json",
"",
));
c.append(&w::section("Ollama (LLM classifier)"));
c.append(&w::switch_row(
"Use Ollama",
&doc,
&["model", "ollama", "enabled"],
true,
));
c.append(&w::entry_row(
"Endpoint",
&doc,
&["model", "ollama", "endpoint"],
"http://localhost:11434",
"",
));
c.append(&w::entry_row(
"Model",
&doc,
&["model", "ollama", "model"],
"e.g. fastflowlm",
"",
));
c.append(&w::spin_f64_row(
"Confidence threshold",
&doc,
&["model", "ollama", "confidence_threshold"],
0.0,
1.0,
0.05,
2,
0.6,
));
c.append(&w::section("Reminders"));
c.append(&w::entry_row(
"Default morning time",
&doc,
&["reminders", "default_morning"],
"7:00",
"",
));
c.append(&w::spin_row(
"Missed grace (minutes)",
&doc,
&["reminders", "missed_grace_minutes"],
0.0,
1440.0,
5.0,
60,
));
c.append(&w::section("Calendar (CalDAV)"));
c.append(&w::switch_row(
"Sync to calendar",
&doc,
&["calendar", "enabled"],
false,
));
c.append(&w::entry_row(
"CalDAV URL",
&doc,
&["calendar", "url"],
"https://host/remote.php/dav/calendars/...",
"",
));
c.append(&w::entry_row(
"Username",
&doc,
&["calendar", "username"],
"",
"",
));
c.append(&w::password_row("Password", &doc, &["calendar", "password"]));
outer.append(&w::save_button(&doc, path));
outer
}

View file

@ -1,153 +0,0 @@
//! breadpaper — wallpaper manager. No config file to edit here; breadpaper
//! takes no persistent settings, just an image path via its CLI (`breadpaper
//! set <path>` / `breadpaper get`). This panel is a thin GUI front-end for
//! that CLI so wallpaper (and the pywal-driven theme it generates) has a
//! discoverable home in BOS Settings instead of only being reachable from a
//! terminal.
use std::path::PathBuf;
use std::process::Command;
use gtk4::prelude::*;
use gtk4::{
Box as GBox, Button, FileChooserAction, FileChooserDialog, Image, Label, Orientation,
ResponseType,
};
use crate::ui::widgets as w;
fn current_wallpaper() -> Option<PathBuf> {
let out = Command::new("breadpaper").arg("get").output().ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(PathBuf::from(s))
}
}
fn refresh_preview(preview: &Image, path_lbl: &Label) {
match current_wallpaper() {
Some(path) => {
path_lbl.set_text(&path.display().to_string());
preview.set_from_file(Some(&path));
}
None => {
path_lbl.set_text("No wallpaper set");
preview.set_icon_name(Some("image-missing"));
}
}
}
pub fn build() -> GBox {
let (outer, c) = w::view_scaffold("breadpaper");
c.append(&w::hint(
"Sets the desktop wallpaper, generates a matching pywal palette, and \
reloads the shared bread-theme stylesheet the wallpaper drives \
the whole desktop's accent colors.",
));
let preview = Image::new();
preview.set_pixel_size(320);
preview.set_margin_top(8);
preview.set_margin_bottom(8);
c.append(&preview);
let path_lbl = Label::new(None);
path_lbl.set_wrap(true);
path_lbl.add_css_class("dim-label");
c.append(&path_lbl);
refresh_preview(&preview, &path_lbl);
let btn_row = GBox::new(Orientation::Horizontal, 8);
btn_row.set_margin_top(8);
let choose_btn = Button::with_label("Choose image...");
let status = Label::new(None);
status.add_css_class("dim-label");
{
let preview = preview.clone();
let path_lbl = path_lbl.clone();
let status = status.clone();
choose_btn.connect_clicked(move |btn| {
let window = btn.root().and_then(|r| r.downcast::<gtk4::Window>().ok());
let dialog = FileChooserDialog::new(
Some("Choose a wallpaper"),
window.as_ref(),
FileChooserAction::Open,
&[("Cancel", ResponseType::Cancel), ("Set", ResponseType::Accept)],
);
// Restricted to what breadpaper's own validate() actually
// accepts (png/jpg/jpeg/webp/gif/bmp) — add_pixbuf_formats()
// also offers svg/tiff/etc. that breadpaper rejects outright.
let filter = gtk4::FileFilter::new();
for ext in ["png", "jpg", "jpeg", "webp", "gif", "bmp"] {
filter.add_suffix(ext);
}
filter.set_name(Some("Images"));
dialog.add_filter(&filter);
let preview = preview.clone();
let path_lbl = path_lbl.clone();
let status = status.clone();
dialog.connect_response(move |dialog, response| {
if response == ResponseType::Accept {
if let Some(file) = dialog.file() {
if let Some(path) = file.path() {
// breadpaper set runs `awww img` + pywal palette
// generation, which is routinely 1-3s (pywal
// spawns Python + an ImageMagick backend) — not
// the "sub-second" call this used to assume.
// GTK widgets aren't Send, so run it in a thread
// and hand the result back over a channel
// (same pattern as snapshots.rs).
status.set_text("Setting...");
let (tx, rx) = async_channel::bounded::<bool>(1);
std::thread::spawn(move || {
let ok = Command::new("breadpaper")
.arg("set")
.arg(&path)
.status()
.map(|s| s.success())
.unwrap_or(false);
let _ = tx.send_blocking(ok);
});
let preview = preview.clone();
let path_lbl = path_lbl.clone();
let status = status.clone();
glib::spawn_future_local(async move {
let ok = rx.recv().await.unwrap_or(false);
if ok {
refresh_preview(&preview, &path_lbl);
status.set_text("Wallpaper set");
} else {
status.set_text("breadpaper failed — see terminal/journal");
}
let lbl = status.clone();
glib::timeout_add_seconds_local(3, move || {
lbl.set_text("");
glib::ControlFlow::Break
});
});
}
}
}
dialog.close();
});
dialog.show();
});
}
btn_row.append(&choose_btn);
btn_row.append(&status);
c.append(&btn_row);
outer
}

View file

@ -1,111 +0,0 @@
//! breadsearch/config.toml — semantic search indexer (breadmill) + GUI.
//! Schema mirrors breadsearch-shared::Config ([index], [search], [model], [power]).
use std::cell::RefCell;
use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::Box as GBox;
use crate::config;
use crate::ui::widgets as w;
fn config_path() -> std::path::PathBuf {
config::config_dir().join("breadsearch/config.toml")
}
pub fn build() -> GBox {
let path = config_path();
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
let (outer, c) = w::view_scaffold("breadsearch");
c.append(&w::section("Power"));
c.append(&w::hint(
"breadmill's embedding step is CPU/NPU/GPU-heavy. Turn it off entirely, \
or just pause it on battery it resumes automatically on AC power.",
));
c.append(&w::switch_row("Enabled", &doc, &["power", "enabled"], true));
c.append(&w::switch_row(
"Index while on battery",
&doc,
&["power", "run_on_battery"],
false,
));
c.append(&w::section("Model"));
// breadmill supports npu/rocm backends in its own codebase, but the
// prebuilt binary bakery actually ships is CPU-only (no --features
// npu/rocm in its release build) — offering them here would just be a
// dropdown option that silently falls back to cpu. Re-add once a build
// with those features is published.
c.append(&w::dropdown_row(
"Compute backend",
&doc,
&["model", "backend"],
&["cpu"],
"cpu",
));
c.append(&w::hint(
"The bakery-published breadmill is CPU-only for now. NPU (AMD Ryzen \
AI) and ROCm backends exist in breadmill's own code but need a \
separately-built binary with those features enabled.",
));
c.append(&w::section("Index"));
c.append(&w::csv_row(
"Roots",
&doc,
&["index", "roots"],
"~/Documents, ~/Projects",
));
c.append(&w::csv_row(
"Excludes",
&doc,
&["index", "excludes"],
"~/Projects/some-noisy-repo",
));
c.append(&w::csv_row(
"Extensions",
&doc,
&["index", "extensions"],
"md, txt, org, pdf, odt, docx",
));
c.append(&w::spin_f64_row(
"Max file size (MB)",
&doc,
&["index", "max_file_mb"],
0.1,
500.0,
0.5,
1,
10.0,
));
c.append(&w::section("Search"));
c.append(&w::spin_row(
"Result limit",
&doc,
&["search", "limit"],
1.0,
100.0,
1.0,
10,
));
c.append(&w::spin_row(
"Snippet length",
&doc,
&["search", "snippet_len"],
20.0,
2000.0,
20.0,
200,
));
c.append(&w::hint(
"Changes take effect after: systemctl --user restart breadmill",
));
outer.append(&w::save_button(&doc, path));
outer
}

View file

@ -1,95 +0,0 @@
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Label, Orientation};
use std::process::Command;
fn get_monitors() -> Vec<String> {
let Ok(output) = Command::new("hyprctl").args(["monitors", "-j"]).output() else {
return Vec::new();
};
let text = String::from_utf8_lossy(&output.stdout);
let Ok(monitors) = serde_json::from_str::<Vec<serde_json::Value>>(&text) else {
return Vec::new();
};
monitors
.iter()
.filter_map(|m| {
let name = m.get("name")?.as_str()?;
let w = m.get("width")?.as_u64()?;
let h = m.get("height")?.as_u64()?;
let refresh = m.get("refreshRate")?.as_f64()?;
Some(format!("{name} {w}x{h} @ {refresh:.0}Hz"))
})
.collect()
}
fn hypr_path(name: &str) -> std::path::PathBuf {
crate::config::config_dir().join("hypr").join(name)
}
/// Open `path` in $EDITOR (nano if unset) inside a terminal window. Spawning
/// an editor directly (no terminal) is a silent no-op for any TUI editor —
/// there's nothing for it to attach to — so it always needs a terminal
/// wrapper. Uses kitty, which is what BOS actually ships (not foot).
fn open_in_terminal(path: &std::path::Path) {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string());
if let Ok(mut child) = Command::new("kitty").args(["-e", &editor]).arg(path).spawn() {
std::thread::spawn(move || { let _ = child.wait(); });
}
}
pub fn build() -> GBox {
let vbox = GBox::new(Orientation::Vertical, 12);
vbox.add_css_class("view-content");
let title = Label::new(Some("Hyprland"));
title.add_css_class("title");
title.set_xalign(0.0);
vbox.append(&title);
let monitors_lbl = Label::new(Some("Connected monitors"));
monitors_lbl.set_xalign(0.0);
monitors_lbl.set_margin_top(8);
monitors_lbl.set_margin_bottom(4);
vbox.append(&monitors_lbl);
let monitors = get_monitors();
if monitors.is_empty() {
let lbl = Label::new(Some("No monitors detected (is Hyprland running?)"));
lbl.set_xalign(0.0);
vbox.append(&lbl);
} else {
for mon in &monitors {
let lbl = Label::new(Some(mon));
lbl.set_xalign(0.0);
lbl.add_css_class("monospace");
vbox.append(&lbl);
}
}
// BOS's Hyprland config is Lua-native (hyprland.lua), not the classic
// hyprland.conf/keybinds.conf pair — those names only ever matched a
// stale, unshipped dotfiles/ directory, so this button opened (or
// silently created) the wrong file entirely.
let open_btn = Button::with_label("Open hyprland.lua in editor");
open_btn.set_margin_top(16);
open_btn.set_halign(gtk4::Align::Start);
{
let conf_path = hypr_path("hyprland.lua");
open_btn.connect_clicked(move |_| open_in_terminal(&conf_path));
}
vbox.append(&open_btn);
// Keybinds are defined inline in hyprland.lua (no separate file); point
// this at the shipped cheat sheet instead of a keybinds.conf that has
// never existed on BOS.
let keybinds_btn = Button::with_label("View keybinds cheat sheet");
keybinds_btn.set_margin_top(8);
keybinds_btn.set_halign(gtk4::Align::Start);
{
let kb_path = std::path::PathBuf::from("/usr/share/bos/keybinds.txt");
keybinds_btn.connect_clicked(move |_| open_in_terminal(&kb_path));
}
vbox.append(&keybinds_btn);
vbox
}

View file

@ -1,10 +0,0 @@
pub mod bread;
pub mod breadbar;
pub mod breadbox;
pub mod breadcrumbs;
pub mod breadpad;
pub mod breadpaper;
pub mod breadsearch;
pub mod hyprland;
pub mod packages;
pub mod snapshots;

View file

@ -1,258 +0,0 @@
use async_channel;
use gtk4::prelude::*;
use gtk4::{
Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, TextView,
};
use std::collections::HashMap;
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use crate::ui::widgets as w;
fn read_installed() -> HashMap<String, String> {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
let path = std::path::Path::new(&home)
.join(".local/state/bakery/installed.json");
let Ok(text) = std::fs::read_to_string(&path) else {
return HashMap::new();
};
let Ok(mut parsed) = serde_json::from_str::<serde_json::Value>(&text) else {
return HashMap::new();
};
// installed.json is {"packages": {name: {version, binaries, services}}},
// not a flat map of package name to metadata — without unwrapping this,
// every install shows a single bogus row named "packages".
let Some(packages) = parsed.get_mut("packages").map(std::mem::take) else {
return HashMap::new();
};
let Ok(packages) = serde_json::from_value::<HashMap<String, serde_json::Value>>(packages) else {
return HashMap::new();
};
packages
.into_iter()
.filter_map(|(name, val)| {
let version = val
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
Some((name, version))
})
.collect()
}
fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) {
stream_command_then(args, log_buf, || {});
}
/// Same as stream_command, but runs `on_done` once the process's output
/// stream ends (i.e. the child has exited) — the channel closes when both
/// the stdout- and stderr-forwarding threads drop their sender, which only
/// happens after `child.wait()` returns.
fn stream_command_then(args: &[&str], log_buf: gtk4::TextBuffer, on_done: impl FnOnce() + 'static) {
let (sender, receiver) = async_channel::bounded::<String>(256);
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
std::thread::spawn(move || {
let mut child = match Command::new(&args[0])
.args(&args[1..])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
let _ = sender.send_blocking(format!("Error: {e}"));
return;
}
};
// Merge stderr into the channel too.
// Both are Some because we spawned with Stdio::piped() above.
let stdout = child.stdout.take().expect("stdout piped");
let stderr = child.stderr.take().expect("stderr piped");
let tx2 = sender.clone();
let stderr_thread = std::thread::spawn(move || {
for line in BufReader::new(stderr).lines().flatten() {
let _ = tx2.send_blocking(line);
}
});
for line in BufReader::new(stdout).lines().flatten() {
let _ = sender.send_blocking(line);
}
let _ = child.wait();
let _ = stderr_thread.join();
});
glib::spawn_future_local(async move {
while let Ok(line) = receiver.recv().await {
let mut end = log_buf.end_iter();
log_buf.insert(&mut end, &format!("{line}\n"));
}
on_done();
});
}
fn populate_packages(list: &ListBox, log_buf: &gtk4::TextBuffer) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
let packages = read_installed();
if packages.is_empty() {
let row = ListBoxRow::new();
row.set_selectable(false);
let lbl = Label::new(Some(
"No bakery packages found (~/.local/state/bakery/installed.json)",
));
lbl.set_margin_top(8);
lbl.set_margin_bottom(8);
lbl.set_margin_start(8);
row.set_child(Some(&lbl));
list.append(&row);
return;
}
let mut names: Vec<_> = packages.iter().collect();
names.sort_by_key(|(k, _)| k.as_str());
for (name, version) in names {
let row = ListBoxRow::new();
row.set_selectable(false);
let hbox = GBox::new(Orientation::Horizontal, 16);
hbox.set_margin_top(6);
hbox.set_margin_bottom(6);
hbox.set_margin_start(8);
hbox.set_margin_end(8);
let name_lbl = Label::new(Some(name));
name_lbl.set_hexpand(true);
name_lbl.set_xalign(0.0);
let ver_lbl = Label::new(Some(version));
ver_lbl.set_xalign(1.0);
let pkg_name = name.clone();
let update_btn = Button::with_label("Update");
{
let log_buf = log_buf.clone();
let list = list.clone();
update_btn.connect_clicked(move |_| {
log_buf.set_text("");
let list2 = list.clone();
let log_buf2 = log_buf.clone();
// Route through stream_command (like the other buttons) so
// output is visible and the row refreshes with the new
// version once the update actually finishes — previously
// this was fire-and-forget with the Err case silently
// swallowed, so a missing `bakery` binary made the button
// look broken with zero feedback either way.
stream_command_then(&["bakery", "update", &pkg_name], log_buf.clone(), move || {
populate_packages(&list2, &log_buf2);
});
});
}
hbox.append(&name_lbl);
hbox.append(&ver_lbl);
hbox.append(&update_btn);
row.set_child(Some(&hbox));
list.append(&row);
}
}
pub fn build() -> GBox {
let vbox = GBox::new(Orientation::Vertical, 0);
vbox.add_css_class("view-content");
let title = Label::new(Some("Packages"));
title.add_css_class("title");
title.set_xalign(0.0);
vbox.append(&title);
let subtitle = Label::new(Some("Bread ecosystem packages installed via bakery, and system packages via pacman below."));
subtitle.set_xalign(0.0);
subtitle.set_margin_bottom(16);
vbox.append(&subtitle);
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
let log_buf = gtk4::TextBuffer::new(None);
populate_packages(&list, &log_buf);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_child(Some(&list));
vbox.append(&scroll);
let log_view = TextView::with_buffer(&log_buf);
log_view.set_editable(false);
log_view.set_monospace(true);
log_view.set_height_request(140);
log_view.set_margin_top(8);
let btn_row = GBox::new(Orientation::Horizontal, 8);
btn_row.set_margin_top(12);
// Labeled "List installed", not "Check for updates" — bakery list is a
// listing of installed packages, it doesn't check for available updates.
let check_btn = Button::with_label("List installed");
let update_all_btn = Button::with_label("Update all");
{
let log_buf = log_buf.clone();
check_btn.connect_clicked(move |_| {
log_buf.set_text("");
stream_command(&["bakery", "list"], log_buf.clone());
});
}
{
let log_buf = log_buf.clone();
update_all_btn.connect_clicked(move |_| {
log_buf.set_text("");
stream_command(&["bakery", "update", "--all"], log_buf.clone());
});
}
btn_row.append(&check_btn);
btn_row.append(&update_all_btn);
vbox.append(&btn_row);
// ---------------------------------------------------------------------
// System packages (pacman) — the other update channel. bakery only
// covers the userspace bread apps; base system/kernel/bos-settings/AUR
// republished packages come from pacman + the [breadway] repo, and
// bos-update (the CLI) already updates both — this panel previously
// only exposed the bakery half, so a user relying on it alone would
// never get base-system updates through the GUI.
// ---------------------------------------------------------------------
vbox.append(&w::section("System packages (pacman)"));
vbox.append(&w::hint(
"Base system, kernel, bos-settings, and republished AUR packages — \
the other half of what `bos-update` covers. Needs your password \
(polkit) since pacman requires root.",
));
let pacman_btn_row = GBox::new(Orientation::Horizontal, 8);
pacman_btn_row.set_margin_top(8);
let pacman_update_btn = Button::with_label("Update system (pacman -Syu)");
{
let log_buf = log_buf.clone();
pacman_update_btn.connect_clicked(move |_| {
log_buf.set_text("");
stream_command(&["pkexec", "pacman", "-Syu", "--noconfirm"], log_buf.clone());
});
}
pacman_btn_row.append(&pacman_update_btn);
vbox.append(&pacman_btn_row);
vbox.append(&log_view);
vbox
}

View file

@ -1,245 +0,0 @@
use gtk4::prelude::*;
use gtk4::{
AlertDialog, Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow,
};
use std::process::Command;
#[derive(Clone)]
struct SnapshotRow {
number: String,
date: String,
description: String,
}
fn list_snapshots() -> Vec<SnapshotRow> {
// NOTE: the real flag is --columns, not --output-cols (which snapper
// rejects outright with "Unknown option") — confirmed against snapper
// 0.13's own --help. With the wrong flag this always failed and the
// panel silently showed "No snapshots found" on every install.
let Ok(output) = Command::new("snapper")
.args(["list", "--columns", "number,date,description"])
.output()
else {
return Vec::new();
};
if !output.status.success() {
eprintln!(
"bos-settings: snapper list failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
return Vec::new();
}
let text = String::from_utf8_lossy(&output.stdout);
text.lines()
.skip(2) // header + separator
.filter_map(|line| {
let mut cols = line.splitn(3, '|');
let number = cols.next()?.trim().to_string();
// Snapshot 0 ("current") always exists, can't be rolled back to
// or deleted, and isn't a real snapshot — filter it out.
if number == "0" {
return None;
}
Some(SnapshotRow {
number,
date: cols.next()?.trim().to_string(),
description: cols.next()?.trim().to_string(),
})
})
.collect()
}
fn populate_list(list: &ListBox) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
let snapshots = list_snapshots();
if snapshots.is_empty() {
let row = ListBoxRow::new();
row.set_selectable(false);
let lbl = Label::new(Some("No snapshots found (snapper may not be configured yet)"));
lbl.set_margin_top(8);
lbl.set_margin_bottom(8);
lbl.set_margin_start(8);
row.set_child(Some(&lbl));
list.append(&row);
return;
}
for snap in &snapshots {
let row = ListBoxRow::new();
row.set_widget_name(&snap.number);
let hbox = GBox::new(Orientation::Horizontal, 16);
hbox.set_margin_top(6);
hbox.set_margin_bottom(6);
hbox.set_margin_start(8);
hbox.set_margin_end(8);
let num_lbl = Label::new(Some(&snap.number));
num_lbl.set_width_chars(4);
num_lbl.set_xalign(0.0);
let date_lbl = Label::new(Some(&snap.date));
date_lbl.set_width_chars(22);
date_lbl.set_xalign(0.0);
let desc_lbl = Label::new(Some(&snap.description));
desc_lbl.set_hexpand(true);
desc_lbl.set_xalign(0.0);
hbox.append(&num_lbl);
hbox.append(&date_lbl);
hbox.append(&desc_lbl);
row.set_child(Some(&hbox));
list.append(&row);
}
}
pub fn build() -> GBox {
let vbox = GBox::new(Orientation::Vertical, 0);
vbox.add_css_class("view-content");
let title = Label::new(Some("Snapshots"));
title.add_css_class("title");
title.set_xalign(0.0);
vbox.append(&title);
let subtitle = Label::new(Some(
"System snapshots created by snap-pac on each pacman transaction. \
Boot into one from the GRUB menu to recover; delete old ones here.",
));
subtitle.set_xalign(0.0);
subtitle.set_margin_bottom(16);
vbox.append(&subtitle);
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::Single);
populate_list(&list);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_child(Some(&list));
vbox.append(&scroll);
let btn_row = GBox::new(Orientation::Horizontal, 8);
btn_row.set_margin_top(12);
let refresh_btn = Button::with_label("Refresh");
let rollback_btn = Button::with_label("Boot into selected...");
let delete_btn = Button::with_label("Delete selected");
delete_btn.add_css_class("destructive-action");
{
let list = list.clone();
refresh_btn.connect_clicked(move |_| {
populate_list(&list);
});
}
{
let list = list.clone();
rollback_btn.connect_clicked(move |btn| {
let Some(row) = list.selected_row() else { return };
let number = row.widget_name().to_string();
if number.is_empty() { return }
let window = btn
.root()
.and_then(|r| r.downcast::<gtk4::Window>().ok());
// BOS boots with root pinned to a named subvolume (grub emits
// rootflags=subvol=@), so `snapper rollback`'s usual mechanism —
// switching the btrfs *default* subvolume — has no effect here;
// grub never consults it. The real, working way to get back to a
// snapshot on this layout is grub-btrfs (already installed +
// running via grub-btrfsd.service): it generates a GRUB submenu
// entry per snapshot, bootable directly. So this button doesn't
// touch the filesystem at all — it just points you at that menu.
let dialog = AlertDialog::builder()
.message(&format!("Boot into snapshot #{number}?"))
.detail("Snapshots on BOS are booted directly from the GRUB \
menu (under \"BOS snapshots\"), not rolled back in \
place. Reboot now and pick this snapshot there, or \
later if you'd rather keep working the menu entry \
will still be there.")
.buttons(["Later", "Reboot now"])
.cancel_button(0)
.default_button(0)
.build();
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
if result == Ok(1) {
let _ = Command::new("systemctl").args(["reboot"]).spawn();
}
});
});
}
{
let list = list.clone();
delete_btn.connect_clicked(move |btn| {
let Some(row) = list.selected_row() else { return };
let number = row.widget_name().to_string();
if number.is_empty() { return }
let window = btn
.root()
.and_then(|r| r.downcast::<gtk4::Window>().ok());
let dialog = AlertDialog::builder()
.message(&format!("Delete snapshot #{number}?"))
.detail("This cannot be undone.")
.buttons(["Cancel", "Delete"])
.cancel_button(0)
.default_button(0)
.build();
let window2 = window.clone();
let list2 = list.clone();
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
if result != Ok(1) { return }
// snapper's DBus path authorizes via ALLOW_USERS, not pkexec —
// this only works because post-install.sh seeds that config
// key, but if it's ever missing this fails silently unless we
// check the exit status. GTK widgets aren't Send, so hand the
// outcome back over a channel rather than touching them from
// the thread (same pattern as the rollback flow used to).
let (tx, rx) = async_channel::bounded::<bool>(1);
std::thread::spawn(move || {
let ok = Command::new("snapper")
.args(["delete", &number])
.status()
.map(|s| s.success())
.unwrap_or(false);
let _ = tx.send_blocking(ok);
});
let list = list2.clone();
let window = window2.clone();
glib::spawn_future_local(async move {
let ok = rx.recv().await.unwrap_or(false);
if ok {
populate_list(&list);
} else {
let err = AlertDialog::builder()
.message("Delete failed")
.detail("snapper delete exited with an error — the \
snapshot wasn't removed.")
.buttons(["OK"])
.build();
err.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, |_| {});
}
});
});
});
}
btn_row.append(&refresh_btn);
btn_row.append(&rollback_btn);
btn_row.append(&delete_btn);
vbox.append(&btn_row);
vbox
}

View file

@ -1,235 +0,0 @@
//! Reusable settings rows bound to a shared `toml_edit` document.
//!
//! Every row reads its current value from the document on build and writes the
//! single key it owns back into the document on change. A view collects rows,
//! then a [`save_button`] persists the whole document to disk in one shot — so
//! unmodelled keys and comments are always preserved (see `crate::config`).
use std::cell::RefCell;
use std::path::PathBuf;
use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::{
Adjustment, Box as GBox, Button, DropDown, Entry, Expression, Label, Orientation,
SpinButton, StringList, Switch,
};
use toml_edit::DocumentMut;
use crate::config;
/// Shared, mutable config document handed to every row in a view.
pub type Doc = Rc<RefCell<DocumentMut>>;
/// A fixed key path into the document, e.g. `&["adapters", "power", "enabled"]`.
type Path = &'static [&'static str];
fn field_label(text: &str) -> Label {
let lbl = Label::new(Some(text));
lbl.set_hexpand(true);
lbl.set_xalign(0.0);
lbl
}
fn row(label: &str, control: &impl IsA<gtk4::Widget>) -> GBox {
let row = GBox::new(Orientation::Horizontal, 16);
row.append(&field_label(label));
control.set_halign(gtk4::Align::End);
control.set_valign(gtk4::Align::Center);
row.append(control);
row
}
/// A bold section heading with spacing above it.
pub fn section(text: &str) -> Label {
let lbl = Label::new(Some(text));
lbl.add_css_class("heading");
lbl.set_xalign(0.0);
lbl.set_margin_top(12);
lbl.set_margin_bottom(2);
lbl
}
/// Small dimmed helper text under a section or row.
pub fn hint(text: &str) -> Label {
let lbl = Label::new(Some(text));
lbl.add_css_class("dim-label");
lbl.set_xalign(0.0);
lbl.set_wrap(true);
lbl.set_margin_bottom(4);
lbl
}
/// Standard view scaffold: an outer vertical box with a title and a scrollable
/// content area. Append setting rows to the returned `content`, then append a
/// [`save_button`] to `outer`. Returns `(outer, content)`.
pub fn view_scaffold(title: &str) -> (GBox, GBox) {
let outer = GBox::new(Orientation::Vertical, 8);
outer.add_css_class("view-content");
let title_lbl = Label::new(Some(title));
title_lbl.add_css_class("title");
title_lbl.set_xalign(0.0);
outer.append(&title_lbl);
let content = GBox::new(Orientation::Vertical, 8);
let scroll = gtk4::ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_hscrollbar_policy(gtk4::PolicyType::Never);
scroll.set_child(Some(&content));
outer.append(&scroll);
(outer, content)
}
pub fn switch_row(label: &str, doc: &Doc, path: Path, default: bool) -> GBox {
let cur = config::get_bool(&doc.borrow(), path).unwrap_or(default);
let sw = Switch::new();
sw.set_active(cur);
let doc = doc.clone();
sw.connect_active_notify(move |s| {
config::set_bool(&mut doc.borrow_mut(), path, s.is_active());
});
row(label, &sw)
}
pub fn entry_row(label: &str, doc: &Doc, path: Path, placeholder: &str, default: &str) -> GBox {
let cur = config::get_str(&doc.borrow(), path).unwrap_or_else(|| default.to_string());
let entry = Entry::new();
entry.set_text(&cur);
entry.set_hexpand(true);
entry.set_width_chars(28);
if !placeholder.is_empty() {
entry.set_placeholder_text(Some(placeholder));
}
let doc = doc.clone();
entry.connect_changed(move |e| {
config::set_str_or_remove(&mut doc.borrow_mut(), path, e.text().as_str());
});
row(label, &entry)
}
pub fn password_row(label: &str, doc: &Doc, path: Path) -> GBox {
let cur = config::get_str(&doc.borrow(), path).unwrap_or_default();
let entry = Entry::new();
entry.set_text(&cur);
entry.set_visibility(false);
entry.set_hexpand(true);
entry.set_width_chars(28);
entry.set_input_purpose(gtk4::InputPurpose::Password);
let doc = doc.clone();
entry.connect_changed(move |e| {
config::set_str_or_remove(&mut doc.borrow_mut(), path, e.text().as_str());
});
row(label, &entry)
}
/// A dropdown that stores the selected option string at `path`.
pub fn dropdown_row(label: &str, doc: &Doc, path: Path, options: &[&str], default: &str) -> GBox {
let cur = config::get_str(&doc.borrow(), path).unwrap_or_else(|| default.to_string());
let model = StringList::new(options);
let dd = DropDown::new(Some(model), Expression::NONE);
let sel = options.iter().position(|o| *o == cur).unwrap_or(0) as u32;
dd.set_selected(sel);
let owned: Vec<String> = options.iter().map(|s| s.to_string()).collect();
let doc = doc.clone();
dd.connect_selected_notify(move |dd| {
if let Some(opt) = owned.get(dd.selected() as usize) {
config::set_str(&mut doc.borrow_mut(), path, opt);
}
});
row(label, &dd)
}
/// An integer spin button storing its value at `path`.
pub fn spin_row(
label: &str,
doc: &Doc,
path: Path,
min: f64,
max: f64,
step: f64,
default: i64,
) -> GBox {
let cur = config::get_i64(&doc.borrow(), path).unwrap_or(default);
let adj = Adjustment::new(cur as f64, min, max, step, step, 0.0);
let spin = SpinButton::new(Some(&adj), step, 0);
let doc = doc.clone();
spin.connect_value_changed(move |s| {
config::set_i64(&mut doc.borrow_mut(), path, s.value() as i64);
});
row(label, &spin)
}
/// A fractional spin button (e.g. 0.01.0 confidence) storing a float.
pub fn spin_f64_row(
label: &str,
doc: &Doc,
path: Path,
min: f64,
max: f64,
step: f64,
digits: u32,
default: f64,
) -> GBox {
let cur = config::get_f64(&doc.borrow(), path).unwrap_or(default);
let adj = Adjustment::new(cur, min, max, step, step, 0.0);
let spin = SpinButton::new(Some(&adj), step, digits);
let doc = doc.clone();
spin.connect_value_changed(move |s| {
config::set_f64(&mut doc.borrow_mut(), path, s.value());
});
row(label, &spin)
}
/// A comma-separated list editor storing an array of strings at `path`.
pub fn csv_row(label: &str, doc: &Doc, path: Path, placeholder: &str) -> GBox {
let cur = config::get_str_list(&doc.borrow(), path).join(", ");
let entry = Entry::new();
entry.set_text(&cur);
entry.set_hexpand(true);
entry.set_width_chars(28);
if !placeholder.is_empty() {
entry.set_placeholder_text(Some(placeholder));
}
let doc = doc.clone();
entry.connect_changed(move |e| {
let items: Vec<String> = e
.text()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
config::set_str_list(&mut doc.borrow_mut(), path, &items);
});
row(label, &entry)
}
/// A Save button + transient status label that persists the document to `path`.
pub fn save_button(doc: &Doc, path: PathBuf) -> GBox {
let btn_row = GBox::new(Orientation::Horizontal, 12);
btn_row.set_margin_top(16);
let save_btn = Button::with_label("Save");
save_btn.add_css_class("suggested-action");
let status = Label::new(None);
status.add_css_class("dim-label");
let doc = doc.clone();
let status_c = status.clone();
save_btn.connect_clicked(move |_| match config::save_doc(&path, &doc.borrow()) {
Ok(()) => {
status_c.set_text("Saved");
let lbl = status_c.clone();
glib::timeout_add_seconds_local(3, move || {
lbl.set_text("");
glib::ControlFlow::Break
});
}
Err(e) => status_c.set_text(&format!("Error: {e}")),
});
btn_row.append(&save_btn);
btn_row.append(&status);
btn_row
}

View file

@ -1,60 +0,0 @@
use gtk4::prelude::*;
use gtk4::{Application, ApplicationWindow, Orientation, Paned, Stack};
use super::sidebar;
use super::views;
pub fn build_ui(app: &Application) {
let window = ApplicationWindow::builder()
.application(app)
.title("BOS Settings")
.default_width(960)
.default_height(640)
.build();
crate::theme::load(&WidgetExt::display(&window));
let hpaned = Paned::new(Orientation::Horizontal);
hpaned.set_position(190);
hpaned.set_shrink_start_child(false);
hpaned.set_resize_start_child(false);
let (sidebar_box, list) = sidebar::build();
let stack = Stack::new();
stack.set_hexpand(true);
stack.set_vexpand(true);
stack.add_named(&views::snapshots::build(), Some("snapshots"));
stack.add_named(&views::packages::build(), Some("packages"));
stack.add_named(&views::bread::build(), Some("bread"));
stack.add_named(&views::breadbar::build(), Some("breadbar"));
stack.add_named(&views::breadbox::build(), Some("breadbox"));
stack.add_named(&views::breadcrumbs::build(), Some("breadcrumbs"));
stack.add_named(&views::breadpad::build(), Some("breadpad"));
stack.add_named(&views::breadpaper::build(), Some("breadpaper"));
stack.add_named(&views::breadsearch::build(), Some("breadsearch"));
stack.add_named(&views::hyprland::build(), Some("hyprland"));
// Default to the bread panel — Snapshots was previously first, an odd
// first impression for a settings app named after the bread ecosystem.
stack.set_visible_child_name("bread");
{
let stack = stack.clone();
list.connect_row_selected(move |_, row| {
if let Some(row) = row {
let name = row.widget_name();
if !name.is_empty() {
stack.set_visible_child_name(&name);
}
}
});
}
hpaned.set_start_child(Some(&sidebar_box));
hpaned.set_end_child(Some(&stack));
window.set_child(Some(&hpaned));
window.present();
}

View file

@ -25,14 +25,19 @@ OUT="${OUT:-$REPO/out}"
STAGE=/tmp/bos-iso-stage
rm -rf "$STAGE" && cp -a "$REPO/iso" "$STAGE"
# Rewrite the [breadway] pacman repo URL to the fastest reachable address.
# 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
# Only ever rewrites the staged copy, never the committed pacman.conf.
if [ "${CI_BUILD:-0}" = "1" ]; then
sed -i 's#https://git.breadway.dev/api/packages/Breadway/arch/os#http://localhost:3002/api/packages/Breadway/arch/os#' "$STAGE/pacman.conf"
else
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] now points at the signed public repo https://dl.breadway.dev/arch
# (SigLevel = Required) — no Forgejo-registry URL rewrite needed anymore.
#
# Trust the [breadway] repo key in *this* build host's pacman keyring so
# `pacstrap` can verify [breadway] packages while assembling the airootfs.
# The same key is baked into the image at etc/pacman.d/breadway-repo.asc and
# re-trusted on the live medium / installed target (calamares/post-install.sh).
BREADWAY_KEY_FPR="56203B86A110695AE7F310934AF3323D678EB5E2"
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
if [ "${FAST_BUILD:-0}" = "1" ]; then
@ -41,89 +46,402 @@ if [ "${FAST_BUILD:-0}" = "1" ]; then
fi
grep airootfs_image_tool_options "$STAGE/profiledef.sh"
# --- Bake this laptop's bakery-installed bread ecosystem into /etc/skel -------
# The bread apps are managed by bakery (which fetches release binaries from
# GitHub), not pacman. bakery needs DNS at install time, which the live/installed
# image doesn't have — so instead of running bakery on the target, we copy the
# exact binaries + bakery manifest this laptop already has into skel. Every user
# created from skel (the live user and the installed user) then gets the same
# versions `bakery list` reports here, fully offline. Copied at build time so the
# binaries never bloat the git repo and always track the current bakery state.
BREAD_BINS=(bakery bread breadd breadman breadbar breadbox breadbox-sync breadcrumbs breadpad breadpaper bread-theme breadmon breadsearch breadmill breadclip breadclipd breadshot)
# --- Bake this machine's bakery-installed bread ecosystem into the image ------
# The bread desktop apps are bakery-managed (release binaries from
# dl.breadway.dev / GitHub), not pacman. bakery needs DNS at install time,
# which the live/installed image doesn't have — so instead of running bakery
# on the target, we copy the binaries + bakery manifest this builder already
# has. Builder home stays user-layout (~/.local); the *image* is system-prefix
# /usr/local so apps live on @ and ride snapper/grub-btrfs snapshots.
# installed.json + index cache stay per-user in skel. Copied at build time
# so the binaries never bloat the git repo.
#
# CI should prefer the stable bakery index when populating the builder home.
# Local builds still snapshot the builder. required_bins fail the bake if
# 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)}"
BAKERY_BIN="$LAPTOP_HOME/.local/bin"
BAKERY_STATE="$LAPTOP_HOME/.local/state/bakery"
BAKERY_CACHE="$LAPTOP_HOME/.cache/bakery"
SKEL="$STAGE/airootfs/etc/skel"
BAKERY_SHARE="$LAPTOP_HOME/.local/share"
AIROOTFS="$STAGE/airootfs"
IMAGE_BIN="$AIROOTFS/usr/local/bin"
IMAGE_SHARE="$AIROOTFS/usr/local/share"
IMAGE_UNITS="$AIROOTFS/usr/lib/systemd/user"
SKEL="$AIROOTFS/etc/skel"
echo "=== baking bakery bread ecosystem from $LAPTOP_HOME ==="
install -d -m 0755 "$SKEL/.local/bin" "$SKEL/.local/state/bakery" "$SKEL/.cache/bakery"
for b in "${BREAD_BINS[@]}"; do
install -m 0755 "$BAKERY_BIN/$b" "$SKEL/.local/bin/$b"
echo "lockfile: $LOCKFILE (${#REQUIRED_BINS[@]} required, ${#OPTIONAL_BINS[@]} optional)"
echo "image prefix: /usr/local (bins $IMAGE_BIN, share $IMAGE_SHARE, units $IMAGE_UNITS)"
missing=()
for b in "${REQUIRED_BINS[@]}"; do
if [[ ! -x "$BAKERY_BIN/$b" ]]; then
missing+=("$BAKERY_BIN/$b")
fi
done
install -m 0644 "$BAKERY_STATE/installed.json" "$SKEL/.local/state/bakery/installed.json"
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
install -m 0755 "$BAKERY_BIN/$b" "$IMAGE_BIN/$b"
done
# 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),
# 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,
# so bake it in too — then bakery works fully offline (list/info from cache;
# 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"
echo "baked: $(ls "$SKEL/.local/bin")"
echo "baked bins: $(ls "$IMAGE_BIN")"
# --- Bake bakery data dirs the apps need offline ------------------------------
# bakery extracts data_archive (breadhelp's content.tar.gz) to
# $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 -----------
# Historically only breadd.service was hand-committed to skel; every other
# bakery package's service (breadbox-sync, breadmill, breadclipd, ...) was
# silently left out, so those daemons never start on a fresh install/live
# boot until the user re-runs `bakery install` (which needs network).
# Generalize from the same source of truth as the binary bake above: read
# the services this laptop's bakery actually installed, copy each unit file
# into skel with ExecStart rewritten from this laptop's literal home path to
# the portable `%h` specifier, and recreate whichever *.target.wants enable
# symlink bakery created locally. Units already committed by hand (breadd.service
# carries a RuntimeDirectoryPreserve=yes fix not yet upstreamed — see bread-release-build
# notes) are left alone rather than overwritten.
echo "=== baking bakery service units into skel ==="
# Units come from installed.json + the bakery index + local unit files
# whose ExecStart is a lockfile binary (installed.json has omitted
# breadcrumbs.service before). Units go to /usr/lib/systemd/user with
# ExecStart rewritten to /usr/local/bin. Recreate whichever
# *.target.wants enable symlink bakery created locally (or that skel
# already ships), and write /etc/systemd/user/*.wants/ (--global).
# Hand-committed skel units (breadd.service carries a
# RuntimeDirectoryPreserve=yes fix not yet upstreamed) are the source
# for that unit and also get their ExecStart rewritten in skel.
echo "=== baking bakery service units into /usr/lib/systemd/user ==="
SYSTEMD_USER_DIR="$LAPTOP_HOME/.config/systemd/user"
SKEL_SYSTEMD="$SKEL/.config/systemd/user"
mapfile -t SERVICE_UNITS < <(python3 -c "
import json
with open('$BAKERY_STATE/installed.json') as f:
d = json.load(f)
for pkg in d.get('packages', d).values():
for s in pkg.get('services', []):
print(s)
")
for unit in "${SERVICE_UNITS[@]}"; do
if [[ -f "$SKEL_SYSTEMD/$unit" ]]; then
echo " $unit already committed in skel, leaving as-is"
install -d -m 0755 "$IMAGE_UNITS"
# installed.json on the builder can omit a service even when the index and
# the local unit file exist (breadcrumbs has done this). Merge all three
# so every lockfile daemon is baked and can be --global enabled.
mapfile -t SERVICE_UNITS < <(python3 - \
"$SKEL/.local/state/bakery/installed.json" \
"$BAKERY_CACHE/index.json" \
"$SYSTEMD_USER_DIR" \
"${BREAD_BINS[@]}" <<'PY'
import json, os, sys
installed_path, index_path, user_dir, *bins = sys.argv[1:]
wanted = set(bins)
units = set()
def add_svc(svc):
name = svc["unit"] if isinstance(svc, dict) else svc
if not name or str(name).startswith(("breadcast", "breadarr")):
return
units.add(str(name))
if os.path.isfile(installed_path):
with open(installed_path) as f:
data = json.load(f)
for pkg in data.get("packages", data).values():
if isinstance(pkg, dict):
for svc in pkg.get("services") or []:
add_svc(svc)
if os.path.isfile(index_path):
with open(index_path) as f:
idx = json.load(f)
for name, pkg in (idx.get("packages") or {}).items():
if not isinstance(pkg, dict):
continue
pbins = []
for b in pkg.get("binaries") or []:
n = b["name"] if isinstance(b, dict) else b
pbins.append(str(n).removesuffix("-x86_64"))
if name in wanted or any(b in wanted for b in pbins):
for svc in pkg.get("services") or []:
add_svc(svc)
if os.path.isdir(user_dir):
for fn in os.listdir(user_dir):
if not fn.endswith(".service"):
continue
path = os.path.join(user_dir, fn)
if not os.path.isfile(path):
continue
try:
text = open(path).read()
except OSError:
continue
for line in text.splitlines():
if line.lstrip().startswith("ExecStart="):
argv0 = line.split("=", 1)[1].split()
if argv0 and os.path.basename(argv0[0]) in wanted:
add_svc(fn)
break
for unit in sorted(units):
print(unit)
PY
)
if [[ ! " ${SERVICE_UNITS[*]} " =~ " breadd.service " ]]; then
echo "ERROR: breadd.service not in the bakery unit list — refusing to bake" >&2
exit 1
fi
rewrite_exec_start() {
local src="$1" dest="$2"
python3 - "$src" "$dest" <<'PY'
import os, sys
src, dest = sys.argv[1], sys.argv[2]
text = open(src).read()
lines = []
for line in text.splitlines():
if line.lstrip().startswith("ExecStart="):
key, rest = line.split("=", 1)
argv = rest.split()
if argv:
name = os.path.basename(argv[0])
argv[0] = "/usr/local/bin/" + name
line = key + "=" + " ".join(argv)
lines.append(line)
out = "\n".join(lines)
if text.endswith("\n"):
out += "\n"
os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, "w") as f:
f.write(out)
PY
}
for unit in "${SERVICE_UNITS[@]}"; do
[[ -n "$unit" ]] || continue
if [[ -f "$SKEL_SYSTEMD/$unit" ]]; then
src="$SKEL_SYSTEMD/$unit"
echo " $unit using committed skel unit as source"
else
src="$SYSTEMD_USER_DIR/$unit"
if [[ ! -f "$src" ]]; then
echo " warning: $unit not found at $src, skipping"
continue
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
install -d -m 0755 "$SKEL_SYSTEMD"
sed "s#ExecStart=$LAPTOP_HOME/.local/bin/#ExecStart=%h/.local/bin/#" "$src" > "$SKEL_SYSTEMD/$unit"
for wants_dir in "$SYSTEMD_USER_DIR"/*.target.wants; do
fi
rewrite_exec_start "$src" "$IMAGE_UNITS/$unit"
if [[ -f "$SKEL_SYSTEMD/$unit" ]]; then
rewrite_exec_start "$src" "$SKEL_SYSTEMD/$unit"
fi
for base in "$SYSTEMD_USER_DIR" "$SKEL_SYSTEMD"; do
[[ -d "$base" ]] || continue
for wants_dir in "$base"/*.target.wants; do
[[ -e "$wants_dir" || -L "$wants_dir" ]] || continue
[[ -L "$wants_dir/$unit" ]] || continue
target_name="$(basename "$wants_dir")"
install -d -m 0755 "$SKEL_SYSTEMD/$target_name"
ln -sf "../$unit" "$SKEL_SYSTEMD/$target_name/$unit"
install -d -m 0755 "$IMAGE_UNITS/$target_name"
ln -sf "../$unit" "$IMAGE_UNITS/$target_name/$unit"
done
echo " baked $unit"
done
# systemctl --global enable equivalent: /etc/systemd/user/<WantedBy>.wants/
# so the live image and a later useradd inherit the unit without a per-home
# enable. Vendor wants above are extra; this is what --global writes.
python3 - "$IMAGE_UNITS/$unit" "$AIROOTFS/etc/systemd/user" "$unit" <<'PY'
import os, sys
unit_path, etc_user, unit = sys.argv[1:]
in_install = False
targets = []
for line in open(unit_path):
s = line.strip()
if s.startswith("[") and s.endswith("]"):
in_install = s == "[Install]"
continue
if in_install and s.startswith("WantedBy="):
targets.extend(t for t in s.split("=", 1)[1].split() if t)
for target in targets:
wants = os.path.join(etc_user, f"{target}.wants")
os.makedirs(wants, exist_ok=True)
dest = os.path.join(wants, unit)
if os.path.lexists(dest):
os.remove(dest)
os.symlink(f"/usr/lib/systemd/user/{unit}", dest)
print(f" global enable {unit} -> {dest}")
PY
echo " baked $unit -> $IMAGE_UNITS/$unit"
done
# Document the baked set. The committed preset is the fallback; the staged
# copy lists whatever this bake actually shipped.
preset_dest="$AIROOTFS/usr/lib/systemd/user-preset/90-bos-bakery.preset"
install -d -m 0755 "$(dirname "$preset_dest")"
{
echo "# Bakery systemd --user units baked into this image."
echo "# Applied by systemctl --global enable (post-install + live setup)"
echo "# so a later useradd starts them on first login."
echo "# breadclipd is also started from hyprland.lua: WantedBy="
echo "# graphical-session.target is not reached on BOS (no uwsm)."
for unit in "${SERVICE_UNITS[@]}"; do
[[ -n "$unit" ]] || continue
printf 'enable %s\n' "$unit"
done
} >"$preset_dest"
echo " wrote $preset_dest"
# mkarchiso resets every airootfs file to 0644, so executables must be declared
# in profiledef.sh's file_permissions array or they ship non-executable and the
# exec-once launches fail with "permission denied". Inject a 0755 entry for each
# baked binary right after the array opener (keeps the binary list in one place).
# baked bakery binary right after the array opener (bos-* bins are already
# listed; keeps the bakery list in one place — the lockfile).
perm_file="$(mktemp)"
for b in "${BREAD_BINS[@]}"; do
printf ' ["/etc/skel/.local/bin/%s"]="0:0:755"\n' "$b" >>"$perm_file"
printf ' ["/usr/local/bin/%s"]="0:0:755"\n' "$b" >>"$perm_file"
done
sed -i "/^file_permissions=(/r $perm_file" "$STAGE/profiledef.sh"
rm -f "$perm_file"
echo "=== file_permissions after injection ==="; grep -A14 '^file_permissions=(' "$STAGE/profiledef.sh"
echo "=== file_permissions after injection ==="; grep -A40 '^file_permissions=(' "$STAGE/profiledef.sh"
# Pin one timestamp for the whole build. Without this, mkarchiso derives the
# boot-config UUID (%ARCHISO_UUID%) when it starts and the iso9660 volume UUID

46
docs/hardware.md Normal file
View file

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

169
docs/signed-repo.md Normal file
View file

@ -0,0 +1,169 @@
# 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…`.

13
dotfiles/README.md Normal file
View file

@ -0,0 +1,13 @@
# `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,3 +1,7 @@
# 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
# App launchers

View file

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

View file

@ -9,10 +9,10 @@ strings:
versionedName: "BOS (rolling)"
shortVersionedName: "BOS"
bootloaderEntryName: "BOS"
productUrl: "https://github.com/Breadway/bos"
supportUrl: "https://github.com/Breadway/bos/issues"
knownIssuesUrl: "https://github.com/Breadway/bos/issues"
releaseNotesUrl: "https://github.com/Breadway/bos/releases"
productUrl: "https://git.breadway.dev/Breadway/bos"
supportUrl: "https://git.breadway.dev/Breadway/bos/issues"
knownIssuesUrl: "https://git.breadway.dev/Breadway/bos/issues"
releaseNotesUrl: "https://git.breadway.dev/Breadway/bos/releases"
images:
productLogo: "logo.png"

View file

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

View file

@ -19,3 +19,15 @@ userSwapChoices:
- small
- suspend
- file
# Full-disk encryption (LUKS) is enabled by default in Calamares' partition
# module (enableLuksAutomatedPartitioning defaults to true) — the checkbox
# already shows on the "Erase disk" page with no config needed here. Pin the
# LUKS generation explicitly rather than relying on Calamares' own implicit
# default: GRUB doesn't support LUKS2 + Argon2id, only PBKDF2, and using the
# wrong KDF produces an encrypted install GRUB can't unlock at boot. luks1
# is unconditionally safe with BOS's plain grub-install setup (no separate
# unencrypted /boot — GRUB itself has to unlock the LUKS container to read
# the kernel). See post-install.sh for the matching cryptsetup/mkinitcpio/
# GRUB wiring this actually needs to be bootable.
luksGeneration: luks1

View file

@ -3,9 +3,19 @@ showSupportUrl: false
showKnownIssuesUrl: 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:
requiredStorage: 20
requiredRam: 2.0
checkInternet: true
checkPower: true
internetCheckUrl: "https://archlinux.org"
internetCheckUrl: "https://breadway.dev"
check:
- storage
- ram
- power
- internet
- root
required:
- storage
- ram
- root

View file

@ -8,7 +8,18 @@
# Best-effort: do NOT use `set -e`; a single failure here must not abort the rest.
set -uo pipefail
MAIN_USER="$(getent passwd 1000 | cut -d: -f1 || true)"
# Whether Calamares encrypted the root partition (LUKS) — checked once here,
# used below to conditionally wire mkinitcpio's encrypt hook and GRUB's
# cryptodisk support. `lsblk TYPE` reports "crypt" for a cryptsetup-opened
# mapper device regardless of what Calamares named it, so this works whether
# the user picked automated "Erase disk" encryption or hand-encrypted a
# partition in manual mode.
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
# ---------------------------------------------------------------------------
# Strip live-only bits that unpackfs copied verbatim from the live medium.
@ -20,6 +31,40 @@ rm -f /usr/local/bin/bos-live-setup /usr/local/bin/bos-launch-calamares
rm -f /etc/sudoers.d/99-bos-live
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).
passwd -l root || true
@ -28,12 +73,23 @@ passwd -l root || true
# 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
# with "keyring is not writable / required key missing". Initialise it here so a
# fresh install can update out of the box. archlinux-keyring is already present;
# [breadway] is SigLevel=Never so it needs no key.
# fresh install can update out of the box. archlinux-keyring verifies official
# Arch packages; the BOS release key (56203B86…, shipped at
# /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
pacman-key --init || echo "WARN: pacman-key --init 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
# ---------------------------------------------------------------------------
@ -52,11 +108,44 @@ if [[ -f /etc/mkinitcpio.conf ]]; then
sed -i 's/^\(HOOKS=.*\bautodetect\b\)/\1 microcode/' /etc/mkinitcpio.conf \
|| echo "WARN: adding microcode hook failed"
fi
# Current mkinitcpio's own shipped default template (verified against the
# actual package, not assumed) uses the systemd-based hook set
# (`HOOKS=(base systemd autodetect ... sd-vconsole block filesystems
# fsck)`), NOT the classic udev-based one — there is no literal "udev"
# token to match against on a stock install. The two hook sets are
# mutually exclusive alternatives (systemd substitutes for udev as the
# base hook providing the init program), and each has its own
# counterpart for anything that hooks into device/root setup:
# plymouth is the same either way, but LUKS unlocking needs `encrypt`
# under udev and `sd-encrypt` under systemd. Detect which is in play
# once and use the matching hook, instead of assuming udev (which
# silently no-ops the sed on every current install — this was already
# true for the plymouth insertion below before this fix, just never
# surfaced because it fails quietly).
if grep -qE '^HOOKS=.*\bsystemd\b' /etc/mkinitcpio.conf; then
BASE_HOOK="systemd"
ENCRYPT_HOOK="sd-encrypt"
else
BASE_HOOK="udev"
ENCRYPT_HOOK="encrypt"
fi
if command -v plymouth-set-default-theme &>/dev/null \
&& ! grep -qE '^HOOKS=.*\bplymouth\b' /etc/mkinitcpio.conf; then
sed -i 's/^\(HOOKS=.*\budev\b\)/\1 plymouth/' /etc/mkinitcpio.conf \
sed -i "s/^\(HOOKS=.*\b${BASE_HOOK}\b\)/\1 plymouth/" /etc/mkinitcpio.conf \
|| echo "WARN: adding plymouth hook failed"
fi
# encrypt/sd-encrypt — only when root is actually LUKS-encrypted
# (ROOT_ENCRYPTED, detected above). Must sit after `block` (provides the
# device nodes it opens) and before `filesystems` (mounts the now-
# unlocked root) — both already present in stock mkinitcpio.conf's
# default HOOKS regardless of which base hook is in use.
if [[ "$ROOT_ENCRYPTED" == "1" ]] \
&& ! grep -qE '^HOOKS=.*\bencrypt\b' /etc/mkinitcpio.conf; then
sed -i "s/^\(HOOKS=.*\bblock\b\)/\1 ${ENCRYPT_HOOK}/" /etc/mkinitcpio.conf \
|| echo "WARN: adding ${ENCRYPT_HOOK} hook failed"
fi
fi
# ---------------------------------------------------------------------------
@ -74,6 +163,17 @@ if command -v plymouth-set-default-theme &>/dev/null; then
plymouth-set-default-theme bos || echo "WARN: plymouth-set-default-theme failed"
fi
# GRUB needs to unlock LUKS itself to reach the kernel — there's no separate
# unencrypted /boot partition (only /boot/efi is separate). GRUB_ENABLE_CRYPTODISK
# makes grub-mkconfig emit the cryptomount commands grub.cfg needs; the
# --modules flags below (on both grub-install calls) make sure the cryptodisk
# and luks decoders are actually compiled into core.img, not just referenced.
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
# Rebuild every preset (default + fallback that bos-copy-kernel wrote) so the
# microcode + plymouth HOOKS above are actually baked into the initramfs.
mkinitcpio -P || echo "WARN: mkinitcpio -P failed"
@ -95,18 +195,20 @@ mkinitcpio -P || echo "WARN: mkinitcpio -P failed"
# BIOS: MBR install onto the disk hosting /.
# ---------------------------------------------------------------------------
if command -v grub-install &>/dev/null; then
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 \
--bootloader-id=BOS --recheck "${CRYPT_MODULES[@]}" \
|| echo "WARN: grub-install (nvram) failed"
grub-install --target=x86_64-efi --efi-directory=/boot/efi \
--removable --recheck \
--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 "/dev/$ROOT_DISK" \
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 / (root device: ${ROOT_DEV:-unknown}) — BIOS grub-install skipped"
@ -117,6 +219,33 @@ if command -v grub-mkconfig &>/dev/null; then
grub-mkconfig -o /boot/grub/grub.cfg || echo "WARN: grub-mkconfig failed"
fi
# ---------------------------------------------------------------------------
# Secure Boot: self-signed keys via sbctl, only when the firmware is already
# in Setup Mode (no vendor PK enrolled — the state a fresh/never-used
# machine boots in, or one where the user cleared their firmware's keys
# before installing). BOS can't ship a Microsoft-signed shim — that requires
# going through Microsoft's own paid UEFI CA signing process — so this is
# the realistic path for an Arch-based distro: generate our own keys, enroll
# them (plus Microsoft's, so a dual-booted Windows bootmgr and fwupd's
# signed capsule updates still verify), and sign the kernel + GRUB. sbctl's
# own package ships a pacman hook (zz-sbctl.hook) that re-signs everything
# automatically on every future kernel/GRUB update — nothing else to wire up.
# Best-effort and silent-skip (not a WARN) when out of Setup Mode — that's
# the expected state on most real hardware, not a failure.
# ---------------------------------------------------------------------------
if [[ -d /sys/firmware/efi ]] && command -v sbctl &>/dev/null; then
SETUP_MODE="$(sbctl status --json 2>/dev/null | python3 -c \
'import json,sys; print(json.load(sys.stdin).get("setup_mode", False))' 2>/dev/null)"
if [[ "$SETUP_MODE" == "True" ]]; then
sbctl create-keys || echo "WARN: sbctl create-keys failed"
sbctl enroll-keys --microsoft || echo "WARN: sbctl enroll-keys failed"
sbctl sign-all -g || echo "WARN: sbctl sign-all failed"
echo "Secure Boot: keys enrolled and boot files signed."
else
echo "Secure Boot: firmware not in Setup Mode — skipped (run 'sudo sbctl enroll-keys --microsoft && sudo sbctl sign-all -g' manually later if desired)."
fi
fi
# ---------------------------------------------------------------------------
# Create @snapshots, @log, @cache as top-level btrfs subvolumes (peers of @,
# not nested under it — so snapshots of @ don't recursively include
@ -186,11 +315,20 @@ fi
# transiently fail inside the Calamares chroot (the same mount unmounts
# cleanly moments later once booted normally — a chroot-specific busy-mount
# race, not a logic error), which cascades into snapper create-config
# refusing because .snapshots "already exists". Retry a few times, then
# fall back to a lazy unmount (detaches the mountpoint immediately even if
# something still transiently references it) rather than give up.
# refusing because .snapshots "already exists". A single pass — retry the
# umount a few times, then fall back to a lazy unmount — was NOT enough on
# real hardware: also confirmed is a run where every umount attempt in that
# single pass failed (including the lazy fallback settling too slowly for
# the immediately-following rmdir/create-config), leaving
# /etc/snapper/configs/ completely empty and BOS's advertised snapshot/
# rollback feature silently non-functional on that install. So retry the
# WHOLE dance, not just the umount substep, and verify at the end that the
# config file actually exists before declaring success.
# ---------------------------------------------------------------------------
if command -v snapper &>/dev/null; then
for attempt in 1 2 3; do
[[ -f /etc/snapper/configs/root ]] && break
unmounted=0
for _ in 1 2 3 4 5; do
if umount /.snapshots 2>/dev/null; then
@ -200,24 +338,41 @@ if command -v snapper &>/dev/null; then
sleep 1
done
if [[ "$unmounted" != "1" ]]; then
echo "WARN: umount /.snapshots failed after retries, forcing lazy unmount"
umount -l /.snapshots || echo "WARN: lazy umount /.snapshots also failed"
echo "WARN: umount /.snapshots failed after retries (attempt $attempt), forcing lazy unmount"
umount -l /.snapshots || echo "WARN: lazy umount /.snapshots also failed (attempt $attempt)"
# Lazy unmount detaches the mountpoint from the namespace right
# away, but whatever was holding it busy may take a moment
# longer to actually let go — rmdir/create-config right after
# this both fail if anything still references /.snapshots.
sleep 2
fi
rmdir /.snapshots || echo "WARN: rmdir /.snapshots failed"
snapper -c root create-config / || echo "WARN: snapper create-config failed"
rmdir /.snapshots 2>/dev/null || echo "WARN: rmdir /.snapshots failed (attempt $attempt)"
snapper -c root create-config / || echo "WARN: snapper create-config failed (attempt $attempt)"
if [[ -d /.snapshots ]]; then
btrfs subvolume delete /.snapshots || echo "WARN: deleting snapper's own .snapshots subvolume failed"
btrfs subvolume delete /.snapshots || echo "WARN: deleting snapper's own .snapshots subvolume failed (attempt $attempt)"
fi
mkdir -p /.snapshots
mount /.snapshots || echo "WARN: remounting the real @snapshots subvolume failed"
mount /.snapshots || echo "WARN: remounting the real @snapshots subvolume failed (attempt $attempt)"
[[ -f /etc/snapper/configs/root ]] || sleep 2
done
if [[ -f /etc/snapper/configs/root ]]; then
sed -i 's/TIMELINE_CREATE="yes"/TIMELINE_CREATE="no"/' /etc/snapper/configs/root
sed -i 's/NUMBER_CLEANUP="no"/NUMBER_CLEANUP="yes"/' /etc/snapper/configs/root
sed -i 's/NUMBER_MIN_AGE="[^"]*"/NUMBER_MIN_AGE="1800"/' /etc/snapper/configs/root
sed -i 's/NUMBER_LIMIT="[^"]*"/NUMBER_LIMIT="10"/' /etc/snapper/configs/root
sed -i 's/NUMBER_LIMIT_IMPORTANT="[^"]*"/NUMBER_LIMIT_IMPORTANT="5"/' /etc/snapper/configs/root
# set-config (not sed) — snapper's own template text for this line has
# drifted across versions before, and a sed that doesn't match just
# silently no-ops, leaving ALLOW_USERS empty and every non-root
# `snapper` call (bos-settings' Snapshots page included) failing with
# "No permissions." forever. set-config is the stable API regardless
# of template wording.
[[ -n "$MAIN_USER" ]] && \
sed -i "s/ALLOW_USERS=\"\"/ALLOW_USERS=\"$MAIN_USER\"/" /etc/snapper/configs/root
snapper -c root set-config "ALLOW_USERS=$MAIN_USER"
else
echo "ERROR: snapper config for root still missing after 3 attempts — snapshots/rollback will not work on this install"
fi
fi
@ -229,15 +384,39 @@ fi
# greetd — graphical login (shipped disabled; live uses tty autologin)
# grub-btrfsd — regenerates GRUB snapshot entries (the unit is grub-btrfsd.service,
# 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 \
tlp.service greetd.service snapper-cleanup.timer grub-btrfsd.service \
fstrim.timer cups.socket avahi-daemon.service ufw.service \
fstrim.timer cups.socket avahi-daemon.socket ufw.service \
fwupd-refresh.timer reflector.timer; do
systemctl enable "$unit" || echo "WARN: failed to enable $unit"
done
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
# resolver answers *.local (network printers, other hosts) via avahi. Idempotent.
@ -259,11 +438,21 @@ if command -v ufw &>/dev/null; then
ufw --force enable || echo "WARN: ufw enable failed"
fi
# The bread ecosystem (bakery + bread, breadbar, breadbox, breadcrumbs, breadpad)
# is bakery-managed, not pacman: the binaries and bakery manifest live in
# /etc/skel/.local (baked in at ISO build time) and are copied into the user's
# home below, so the install works fully offline with no DNS for bakery/GitHub.
# bos-settings is the only pacman bread package and was installed by unpackfs.
# The whole bread ecosystem (bakery, bread, breadbar, breadbox, breadcrumbs,
# breadpad, bos-settings, breadhelp, ...) is bakery-managed, not pacman:
# binaries, share/data, and user units are baked into /usr/local and
# /usr/lib/systemd/user (system prefix). Per-user bakery state (installed.json
# + index cache) is seeded from /etc/skel/.local and copied into the user's
# home below, so the install works fully offline with no DNS for bakery.
#
# systemd --user units in /usr/lib/systemd/user are not enabled for new
# accounts unless enabled --global (or the user enables them). Do that here
# so a later `useradd -m` starts breadd / breadbox-sync / breadclipd /
# breadcrumbs / breadmill on first login. Safe if the helper is missing.
if [[ -x /usr/local/bin/bos-enable-bakery-user-units ]]; then
/usr/local/bin/bos-enable-bakery-user-units \
|| echo "WARN: enabling bakery user units globally failed"
fi
# ---------------------------------------------------------------------------
# Deploy dotfiles + the bakery bread ecosystem into the user's home (Calamares

View file

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

View file

@ -3,5 +3,8 @@ GROUP=users
HOME=/home
INACTIVE=-1
EXPIRE=
# useradd -m copies Hyprland + bakery per-user state from here. Bakery
# binaries live in /usr/local/bin (not skel). User units are enabled
# --global so a second account starts them on first login.
SKEL=/etc/skel
CREATE_MAIL_SPOOL=no

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
/usr/share/backgrounds/bos/bread-background.png

View file

@ -0,0 +1,16 @@
-- breadhelp-suggest — nudge breadhelp's Home tab banner when a bread event
-- suggests a relevant guide (e.g. a newly connected monitor -> breadmon
-- setup). Auto-discovered by breadd. If breadhelp isn't already running,
-- `--suggest <id>` launches it straight to Home with the banner focused; if
-- it's already running, the banner just updates silently (see
-- breadhelp's services/breadd.rs) rather than stealing focus on every event.
local M = bread.module({ name = "breadhelp-suggest", version = "1.0.0" })
function M.on_load()
bread.on("bread.monitor.connected", function(event)
bread.exec("breadhelp --suggest monitor-setup")
end)
end
return M

View file

@ -0,0 +1,53 @@
-- breadhelp-tour — forwards bread/Hyprland events the live guided tour
-- (breadhelp's ui::tour) uses to detect "the user actually did the thing"
-- and auto-advance a step. Always forwards; breadhelp's `--tour-event`
-- handler is a hard no-op unless a tour is currently waiting for that exact
-- id, so this module doesn't need to know whether a tour is even running.
--
-- `bread.window.opened`/`bread.workspace.changed` are already normalized by
-- breadd. Layer-surface opens/closes (breadbox, breadclip, breadsearch) are
-- NOT normalized — `openlayer`/`closelayer` fall through to the generic
-- `bread.hyprland.event` topic, which `on_raw` filters by raw kind.
--
-- Fullscreen click-catcher launchers (breadbox et al.) report their surface
-- as covering the whole monitor while open, so the tour can't safely show a
-- clickable callout at the same time — a step targeting one of these should
-- key its success on the *close* event (implying the user picked something
-- and it dismissed), not the open event, so the callout only ever reappears
-- once the launcher's surface is already gone and input contention is moot.
local M = bread.module({ name = "breadhelp-tour", version = "1.0.0" })
-- `bread.exec` only takes a single shell command string — it always runs it
-- as `sh -lc <cmd>` (see breadd's Lua runtime), there's no array-exec form
-- that bypasses the shell. `event.data.class` (a Wayland window class) and
-- `event.data.data` (a layer-shell namespace) are both arbitrary strings a
-- client fully controls — a window/surface can name itself
-- `x; rm -rf ~ #` and have that land in a real shell command otherwise.
-- POSIX single-quoting neutralizes that: wrap the value in single quotes,
-- and turn any single quote *inside* it into `'\''` (close the quote, an
-- escaped literal quote, reopen the quote) — the one escaping rule `sh`
-- needs to treat the whole thing as inert data, never command syntax.
local function shell_quote(s)
return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
end
function M.on_load()
bread.on("bread.window.opened", function(event)
bread.exec("breadhelp --tour-event " .. shell_quote("window:" .. event.data.class))
end)
bread.on("bread.workspace.changed", function(event)
bread.exec("breadhelp --tour-event workspace-changed")
end)
bread.hyprland.on_raw("openlayer", function(event)
bread.exec("breadhelp --tour-event " .. shell_quote("layer:" .. event.data.data))
end)
bread.hyprland.on_raw("closelayer", function(event)
bread.exec("breadhelp --tour-event " .. shell_quote("layer-closed:" .. event.data.data))
end)
end
return M

View file

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

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

@ -0,0 +1,89 @@
{
"default_mods": ["SUPER"],
"bindings": [
{ "action": "exec", "command": "kitty", "key": "RETURN", "label": "Open a terminal", "category": "apps" },
{ "action": "close", "key": "BACKSPACE", "label": "Close the focused window", "category": "windows" },
{ "action": "exec", "command": "breadbox", "key": "SPACE", "label": "Open the app launcher (breadbox)", "category": "apps", "demo_cmd": "breadbox" },
{ "action": "exec", "command": "nautilus", "key": "E", "label": "Open files (nautilus)", "category": "apps" },
{ "action": "exec", "command": "zen-browser", "key": "B", "label": "Open the browser (zen)", "category": "apps" },
{ "action": "exec", "command": "breadpad", "key": "U", "label": "Notes / reminders (breadpad)", "category": "apps", "demo_cmd": "breadpad" },
{ "action": "exec", "command": "breadman", "key": "M", "label": "Notes / task manager (breadman)", "category": "apps" },
{ "action": "exec", "command": "bos-settings", "key": "comma", "label": "Open BOS Settings", "category": "apps", "demo_cmd": "bos-settings" },
{ "action": "exec", "command": "breadhelp", "key": "slash", "label": "Show this keybind cheatsheet", "category": "apps" },
{ "action": "exec", "command": "loginctl lock-session", "key": "L", "label": "Lock screen", "category": "apps" },
{ "action": "fullscreen", "key": "F", "label": "Toggle fullscreen", "category": "windows" },
{ "action": "float", "key": "I", "label": "Toggle floating", "category": "windows" },
{ "action": "pseudo", "key": "P", "label": "Toggle pseudotile", "category": "windows" },
{ "action": "resize", "key": "R", "label": "Resize mode", "category": "windows" },
{ "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": "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": "focus_last", "key": "Tab", "label": "Focus last window", "category": "windows" },
{ "action": "exit", "key": "N", "label": "Exit Hyprland (log out)", "category": "windows" },
{ "action": "exec", "command": "breadshot region -o ~/Pictures/Screenshots", "key": "S", "mods": ["SUPER", "SHIFT"], "label": "Screenshot: select region -> file", "category": "screenshots" },
{ "action": "exec", "command": "breadshot region --clipboard-only", "key": "C", "mods": ["SUPER", "SHIFT"], "label": "Screenshot: select region -> clipboard", "category": "screenshots" },
{ "action": "exec", "command": "breadshot active-output -o ~/Pictures/Screenshots", "key": "P", "mods": ["SUPER", "SHIFT"], "label": "Screenshot: whole active screen -> file", "category": "screenshots" },
{ "action": "focus", "direction": "left", "key": "left", "label": "Move focus left", "category": "focus" },
{ "action": "focus", "direction": "right", "key": "right", "label": "Move focus right", "category": "focus" },
{ "action": "focus", "direction": "up", "key": "up", "label": "Move focus up", "category": "focus" },
{ "action": "focus", "direction": "down", "key": "down", "label": "Move focus down", "category": "focus" },
{ "action": "move_dir", "direction": "left", "key": "h", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window left", "category": "focus" },
{ "action": "move_dir", "direction": "down", "key": "j", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window down", "category": "focus" },
{ "action": "move_dir", "direction": "up", "key": "k", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window up", "category": "focus" },
{ "action": "move_dir", "direction": "right", "key": "l", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window right", "category": "focus" },
{ "action": "resize_dir", "x": 30, "y": 0, "key": "right", "mods": ["SUPER", "SHIFT"], "options": { "repeating": true }, "label": "Resize the focused window (grow right)", "category": "focus" },
{ "action": "resize_dir", "x": -30, "y": 0, "key": "left", "mods": ["SUPER", "SHIFT"], "options": { "repeating": true }, "label": "Resize the focused window (grow left)", "category": "focus" },
{ "action": "resize_dir", "x": 0, "y": -30, "key": "up", "mods": ["SUPER", "SHIFT"], "options": { "repeating": true }, "label": "Resize the focused window (grow up)", "category": "focus" },
{ "action": "resize_dir", "x": 0, "y": 30, "key": "down", "mods": ["SUPER", "SHIFT"], "options": { "repeating": true }, "label": "Resize the focused window (grow down)", "category": "focus" },
{ "action": "focus", "workspace": 1, "key": "1", "label": "Switch to workspace 1", "category": "workspaces" },
{ "action": "focus", "workspace": 2, "key": "2", "label": "Switch to workspace 2", "category": "workspaces" },
{ "action": "focus", "workspace": 3, "key": "3", "label": "Switch to workspace 3", "category": "workspaces" },
{ "action": "focus", "workspace": 4, "key": "4", "label": "Switch to workspace 4", "category": "workspaces" },
{ "action": "focus", "workspace": 5, "key": "5", "label": "Switch to workspace 5", "category": "workspaces" },
{ "action": "focus", "workspace": 6, "key": "6", "label": "Switch to workspace 6", "category": "workspaces" },
{ "action": "focus", "workspace": 7, "key": "7", "label": "Switch to workspace 7", "category": "workspaces" },
{ "action": "focus", "workspace": 8, "key": "8", "label": "Switch to workspace 8", "category": "workspaces" },
{ "action": "focus", "workspace": 9, "key": "9", "label": "Switch to workspace 9", "category": "workspaces" },
{ "action": "focus", "workspace": 10, "key": "0", "label": "Switch to workspace 10", "category": "workspaces" },
{ "action": "move", "workspace": 1, "key": "1", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 1", "category": "workspaces" },
{ "action": "move", "workspace": 2, "key": "2", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 2", "category": "workspaces" },
{ "action": "move", "workspace": 3, "key": "3", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 3", "category": "workspaces" },
{ "action": "move", "workspace": 4, "key": "4", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 4", "category": "workspaces" },
{ "action": "move", "workspace": 5, "key": "5", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 5", "category": "workspaces" },
{ "action": "move", "workspace": 6, "key": "6", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 6", "category": "workspaces" },
{ "action": "move", "workspace": 7, "key": "7", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 7", "category": "workspaces" },
{ "action": "move", "workspace": 8, "key": "8", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 8", "category": "workspaces" },
{ "action": "move", "workspace": 9, "key": "9", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 9", "category": "workspaces" },
{ "action": "move", "workspace": 10, "key": "0", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 10", "category": "workspaces" },
{ "action": "focus", "workspace": "e+1", "key": "bracketright", "label": "Next workspace", "category": "workspaces" },
{ "action": "focus", "workspace": "e-1", "key": "bracketleft", "label": "Previous workspace", "category": "workspaces" },
{ "action": "move", "workspace": "e+1", "key": "bracketright", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to the next workspace", "category": "workspaces" },
{ "action": "move", "workspace": "e-1", "key": "bracketleft", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to the previous workspace", "category": "workspaces" },
{ "action": "focus", "workspace": "e+1", "key": "mouse_down", "label": "Cycle to the next workspace (scroll)", "category": "mouse" },
{ "action": "focus", "workspace": "e-1", "key": "mouse_up", "label": "Cycle to the previous workspace (scroll)", "category": "mouse" },
{ "action": "drag", "key": "mouse:272", "options": { "mouse": true }, "label": "Move a window (drag)", "category": "mouse" },
{ "action": "resize", "key": "mouse:273", "options": { "mouse": true }, "label": "Resize a window (drag)", "category": "mouse" },
{ "action": "exec", "command": "wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+", "key": "XF86AudioRaiseVolume", "mods": [], "options": { "locked": true, "repeating": true }, "label": "Volume up", "category": "media" },
{ "action": "exec", "command": "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-", "key": "XF86AudioLowerVolume", "mods": [], "options": { "locked": true, "repeating": true }, "label": "Volume down", "category": "media" },
{ "action": "exec", "command": "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle", "key": "XF86AudioMute", "mods": [], "options": { "locked": true }, "label": "Mute", "category": "media" },
{ "action": "exec", "command": "wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle", "key": "XF86AudioMicMute", "mods": [], "options": { "locked": true }, "label": "Mic mute", "category": "media" },
{ "action": "exec", "command": "brightnessctl -e4 -n2 set 5%+", "key": "XF86MonBrightnessUp", "mods": [], "options": { "locked": true, "repeating": true }, "label": "Brightness up", "category": "media" },
{ "action": "exec", "command": "brightnessctl -e4 -n2 set 5%-", "key": "XF86MonBrightnessDown", "mods": [], "options": { "locked": true, "repeating": true }, "label": "Brightness down", "category": "media" },
{ "action": "exec", "command": "playerctl next", "key": "XF86AudioNext", "mods": [], "options": { "locked": true }, "label": "Next track", "category": "media" },
{ "action": "exec", "command": "playerctl previous", "key": "XF86AudioPrev", "mods": [], "options": { "locked": true }, "label": "Previous track", "category": "media" },
{ "action": "exec", "command": "playerctl play-pause", "key": "XF86AudioPlay", "mods": [], "options": { "locked": true }, "label": "Play / pause", "category": "media" },
{ "action": "exec", "command": "gnome-calculator", "key": "XF86Calculator", "mods": [], "label": "Open the calculator", "category": "media" }
]
}

View file

@ -1,61 +1,30 @@
-- BOS Hyprland configuration — native Lua config (Hyprland 0.55+).
-- hyprlang (.conf) is deprecated; this uses the built-in `hl` API.
-- Single-file and non-modular by design. Reference: https://wiki.hypr.land/
local mod = "SUPER"
-- Mostly single-file by design (reference: https://wiki.hypr.land/) — the
-- exceptions are keybinds, appearance settings, monitor layout, and the
-- extra autostart list, each loaded from a JSON file (binds.json,
-- settings.json, monitors.json, autostart.json) so bread* apps can read/edit
-- them as structured data instead of parsing this Lua file.
--
-- Every loader below is wrapped in `pcall`: this file is loaded as a single
-- Lua chunk, so an uncaught error partway through would abort everything
-- *after* it too (no keybinds, no window rules, no autostart) — a bad or
-- hand-edited JSON file must degrade to that one section's hardcoded
-- defaults, never take down the rest of the session.
local script_dir = os.getenv("HOME") .. "/.config/hypr/scripts/"
local config_home = os.getenv("HOME") .. "/.config/hypr/"
-- ---------------------------------------------------------------------------
-- Monitors — generic default that works on any hardware.
-- Monitors — from monitors.json, always falls back to a generic
-- any-hardware default (see scripts/display/monitors.lua).
-- ---------------------------------------------------------------------------
hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" })
pcall(dofile, script_dir .. "display/monitors.lua")
-- ---------------------------------------------------------------------------
-- Core settings.
-- Core appearance/input settings — from settings.json (see
-- scripts/ui/settings.lua).
-- ---------------------------------------------------------------------------
hl.config({
general = {
gaps_in = 5,
gaps_out = 10,
border_size = 2,
col = {
active_border = "rgba(88c0d0ff)",
inactive_border = "rgba(4c566aff)",
},
layout = "dwindle",
resize_on_border = true,
},
decoration = {
rounding = 8,
active_opacity = 1.0,
inactive_opacity = 1.0,
blur = {
enabled = true,
size = 6,
passes = 2,
new_optimizations = true,
},
shadow = {
enabled = true,
range = 12,
render_power = 3,
},
},
input = {
kb_layout = "us",
follow_mouse = 1,
touchpad = { natural_scroll = true },
},
dwindle = {
preserve_split = true,
},
animations = {
enabled = true,
},
misc = {
disable_hyprland_logo = true,
disable_splash_rendering = true,
},
})
pcall(dofile, script_dir .. "ui/settings.lua")
-- ---------------------------------------------------------------------------
-- Animations — snappy curves + per-leaf speeds (matches the reference laptop;
@ -86,12 +55,24 @@ for _, animation in ipairs(animations) do
end
-- ---------------------------------------------------------------------------
-- Window rules — float + centre the onboarding popups (kitty --class …).
-- Window rules — float + centre the onboarding/help popups.
-- breadhelp replaces the old bos-welcome/bos-keybinds kitty+less popups.
-- bos-netsetup (nmtui, from bos-netcheck) is unrelated to breadhelp and
-- still floats the same way.
-- ---------------------------------------------------------------------------
hl.window_rule({ name = "bos-keybinds", match = { class = "^(bos-keybinds)$" }, float = true, size = { 760, 720 } })
hl.window_rule({ name = "bos-welcome", match = { class = "^(bos-welcome)$" }, float = true, size = { 700, 560 } })
hl.window_rule({ name = "breadhelp", match = { class = "^(com\\.breadway\\.breadhelp)$" }, float = true, size = { 880, 600 } })
hl.window_rule({ name = "bos-netsetup", match = { class = "^(bos-netsetup)$" }, float = true, size = { 700, 560 } })
-- ---------------------------------------------------------------------------
-- Layer-shell rules — blur / ignore-alpha / per-namespace motion for the
-- bread shell (breadbar island + osd + notif + panel, breadbox launcher,
-- breadclip popup). Theme-driven via ~/.config/hypr/layerrules.json (written
-- by `bread-theme layerrules` in the bootstrap below), with a hardcoded
-- fallback so a missing/broken theme file can never leave the shell
-- unblurred. Without these the launcher and popups get no compositor blur.
-- ---------------------------------------------------------------------------
pcall(dofile, script_dir .. "ui/rules.lua")
-- ---------------------------------------------------------------------------
-- Environment (vendor-neutral; no GPU-specific vars so it works on Intel/AMD).
-- ---------------------------------------------------------------------------
@ -105,102 +86,73 @@ hl.env("QT_WAYLAND_DISABLE_WINDOWDECORATION", "1")
hl.env("SDL_VIDEODRIVER", "wayland")
hl.env("ELECTRON_OZONE_PLATFORM_HINT", "auto")
hl.env("_JAVA_AWT_WM_NONREPARENTING", "1")
-- GTK4's default renderer (ngl/vulkan on GTK 4.14+) renders transparent
-- layer-shell surfaces as opaque black on wlroots — the whole bread shell
-- (breadbox launcher, breadclip popup, breadbar, breadhelp, bos-settings)
-- goes black-on-black, and it's worse under a VM's virtio-gpu where no real
-- GL/Vulkan context is available. cairo (software) always composites
-- transparency correctly; the shell is flat colour/text/icons so there's no
-- visible perf cost, and idle memory drops (~40%, no Mesa driver resident).
-- Set GSK_RENDERER=gl in the session to override for a GPU-heavy GTK4 app.
hl.env("GSK_RENDERER", "cairo")
-- Optional NVIDIA env from bos-nvidia-setup. Mesa machines have no file.
-- bos-nvidia-setup: optional proprietary env; no-op when the file is absent
do
local nvidia = (os.getenv("HOME") or "") .. "/.config/hypr/nvidia.lua"
local f = io.open(nvidia, "r")
if f then
f:close()
pcall(dofile, nvidia)
end
end
-- kitty sets its own background_opacity (see kitty.conf), so the global blur
-- above blurs behind the terminal while keeping text fully opaque.
-- ---------------------------------------------------------------------------
-- Standard BOS keybinds (SUPER = mod).
-- ---------------------------------------------------------------------------
-- Apps / window management
hl.bind(mod .. " + RETURN", hl.dsp.exec_cmd("kitty"))
hl.bind(mod .. " + BACKSPACE", hl.dsp.window.close())
hl.bind(mod .. " + SPACE", hl.dsp.exec_cmd("breadbox"))
hl.bind(mod .. " + E", hl.dsp.exec_cmd("nautilus"))
hl.bind(mod .. " + B", hl.dsp.exec_cmd("zen-browser"))
hl.bind(mod .. " + U", hl.dsp.exec_cmd("breadpad"))
hl.bind(mod .. " + M", hl.dsp.exec_cmd("breadman"))
hl.bind(mod .. " + comma", hl.dsp.exec_cmd("bos-settings"))
hl.bind(mod .. " + slash", hl.dsp.exec_cmd("bos-keybinds"))
hl.bind(mod .. " + L", hl.dsp.exec_cmd("loginctl lock-session"))
hl.bind(mod .. " + F", hl.dsp.window.fullscreen({ action = "toggle" }))
hl.bind(mod .. " + I", hl.dsp.window.float({ action = "toggle" }))
hl.bind(mod .. " + P", hl.dsp.window.pseudo({ action = "toggle" }))
hl.bind(mod .. " + R", hl.dsp.window.resize())
-- breadclip (its own gtk4-layer-shell popup — not a TUI, so no terminal
-- needed). Previously piped cliphist through fzf directly from the
-- compositor with no terminal attached, which was a silent no-op.
-- Bound on both V (breadclip's own suggested default, freed up now that
-- float toggle moved to I) and SHIFT+V (kept for muscle memory).
hl.bind(mod .. " + V", hl.dsp.exec_cmd("breadclip"))
hl.bind(mod .. " + SHIFT + V", hl.dsp.exec_cmd("breadclip"))
hl.bind(mod .. " + T", hl.dsp.layout("togglesplit"))
hl.bind(mod .. " + Tab", hl.dsp.focus({ urgent_or_last = true }))
hl.bind(mod .. " + N", hl.dsp.exit())
-- Screenshots (grim + slurp + wl-clipboard)
hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd([[bash -c 'mkdir -p ~/Pictures/Screenshots && grim -g "$(slurp)" ~/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png']]))
hl.bind(mod .. " + SHIFT + C", hl.dsp.exec_cmd([[bash -c 'grim -g "$(slurp)" - | wl-copy']]))
hl.bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd([[bash -c 'mkdir -p ~/Pictures/Screenshots && grim ~/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png']]))
-- Focus (directional)
hl.bind(mod .. " + left", hl.dsp.focus({ direction = "left" }))
hl.bind(mod .. " + right", hl.dsp.focus({ direction = "right" }))
hl.bind(mod .. " + up", hl.dsp.focus({ direction = "up" }))
hl.bind(mod .. " + down", hl.dsp.focus({ direction = "down" }))
-- Move window (directional, vim keys)
hl.bind(mod .. " + SHIFT + h", hl.dsp.window.move({ direction = "left" }))
hl.bind(mod .. " + SHIFT + j", hl.dsp.window.move({ direction = "down" }))
hl.bind(mod .. " + SHIFT + k", hl.dsp.window.move({ direction = "up" }))
hl.bind(mod .. " + SHIFT + l", hl.dsp.window.move({ direction = "right" }))
-- Resize active window (arrows)
hl.bind(mod .. " + SHIFT + right", hl.dsp.window.resize({ x = 30, y = 0, relative = true }), { repeating = true })
hl.bind(mod .. " + SHIFT + left", hl.dsp.window.resize({ x = -30, y = 0, relative = true }), { repeating = true })
hl.bind(mod .. " + SHIFT + up", hl.dsp.window.resize({ x = 0, y = -30, relative = true }), { repeating = true })
hl.bind(mod .. " + SHIFT + down", hl.dsp.window.resize({ x = 0, y = 30, relative = true }), { repeating = true })
-- Workspaces 110 (0 = workspace 10)
for i = 1, 10 do
local key = tostring(i % 10)
hl.bind(mod .. " + " .. key, hl.dsp.focus({ workspace = i }))
hl.bind(mod .. " + SHIFT + " .. key, hl.dsp.window.move({ workspace = i }))
end
-- Workspace cycling
hl.bind(mod .. " + bracketright", hl.dsp.focus({ workspace = "e+1" }))
hl.bind(mod .. " + bracketleft", hl.dsp.focus({ workspace = "e-1" }))
hl.bind(mod .. " + SHIFT + bracketright", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind(mod .. " + SHIFT + bracketleft", hl.dsp.window.move({ workspace = "e-1" }))
-- Mouse
hl.bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "e+1" }))
hl.bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "e-1" }))
hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true })
hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true })
-- Media / hardware keys (work locked, i.e. on the lock screen too)
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+"), { locked = true, repeating = true })
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"), { locked = true, repeating = true })
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true })
hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true })
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { locked = true, repeating = true })
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"), { locked = true, repeating = true })
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true })
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true })
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true })
hl.bind("XF86Calculator", hl.dsp.exec_cmd("gnome-calculator"))
-- Standard BOS keybinds — data-driven from binds.json (apps, windows,
-- screenshots, focus/move/resize, workspaces, mouse, media keys), loaded via
-- scripts/input/{binds,keybinds}.lua. breadhelp's keybind viewer reads
-- binds.json directly as its source of truth (no separately-maintained
-- cheatsheet to keep in sync), and a future bos-settings editor can
-- read/write the same file.
pcall(function()
local binds = dofile(script_dir .. "input/binds.lua")(config_home .. "binds.json")
dofile(script_dir .. "input/keybinds.lua")({
default_mods = binds.default_mods,
bindings = binds.bindings,
})
end)
-- 3-finger horizontal trackpad swipe → workspace switch (1:1 gesture)
hl.gesture({
fingers = 3,
direction = "horizontal",
action = "workspace",
})
-- ---------------------------------------------------------------------------
-- Autostart. polkit agent + the bread ecosystem + idle daemon + wallpaper.
-- Autostart. Core bootstrap sequence (polkit agent, dark theme, wallpaper
-- daemon, breadd's Wayland-env fix, breadclipd) stays hardcoded here — it's
-- timing/order-sensitive infrastructure, not something a settings UI should
-- expose for a user to disable or reorder. The extra, genuinely toggleable
-- apps (breadbar, hypridle, bos-netcheck, breadhelp, breadpaper/breadshot
-- listen) come from autostart.json via scripts/system/autostart.lua,
-- 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.)
-- ---------------------------------------------------------------------------
hl.on("hyprland.start", function()
local startup = {
local core_startup = {
-- Generate the shared bread GUI stylesheet first, so breadbar/breadbox/
-- bos-settings load it on start (they also live-reload if it changes).
"bread-theme generate",
-- Write ~/.config/hypr/layerrules.json from the active shell theme's
-- [compositor] table. scripts/ui/rules.lua (loaded above) already ran
-- with the hardcoded fallback; this refreshes the JSON for the next
-- `hyprctl reload` / theme switch. Harmless if bread-theme lacks the
-- verb on an older build.
"sh -c 'bread-theme layerrules 2>/dev/null || true'",
-- Global dark theme: GTK4/libadwaita + GTK3 theme + icon + cursor.
"gsettings set org.gnome.desktop.interface color-scheme prefer-dark",
"gsettings set org.gnome.desktop.interface gtk-theme Adwaita-dark",
@ -208,18 +160,28 @@ hl.on("hyprland.start", function()
"gsettings set org.gnome.desktop.interface cursor-theme Bibata-Modern-Ice",
"gsettings set org.gnome.desktop.interface cursor-size 24",
-- Clipboard history is breadclipd, a bakery-managed systemd --user
-- service (auto-started via skel — see build-local.sh's service bake)
-- rather than an exec-once here.
"/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1",
-- service (auto-started from /usr/lib/systemd/user — see
-- build-local.sh's service bake) rather than an exec-once here.
-- Prefer bread-polkit if it is on PATH (not baked; lockfile does not
-- 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",
-- Set the default wallpaper once the daemon is up (retry until
-- ready) via `breadpaper set`, not raw `awww img` — breadpaper also
-- generates the pywal palette and reloads bread-theme, and records
-- the path so `breadpaper get` (and its bos-settings panel) show
-- the real default instead of "No wallpaper set" on a fresh install.
[[bash -c 'until breadpaper set /usr/share/backgrounds/bos/bread-background.png 2>/dev/null; do sleep 0.3; done']],
-- breadd runs as a systemd user service (~/.config/systemd/user/breadd.service,
-- enabled in skel). It autostarts at login but before Hyprland exists, so
-- Set the default wallpaper once the daemon is up (retry until ready).
-- Raw `awww img`, NOT `breadpaper set` — breadpaper set also runs real
-- pywal against the image, which would clobber the curated black-base
-- colors.json baked into skel (.cache/wal/colors.json: #0c0c0c bg,
-- bread-toned browns reserved for accent slots only) with colors
-- actually extracted from bread-background.png — which is an all-beige
-- photo, so every bread-theme app (breadbar included) turns brown.
-- `breadpaper get` still works on a fresh install without ever running
-- pywal: .cache/wal/wal (pywal's own "last image" marker, which is all
-- breadpaper reads) is baked into skel too, right beside colors.json.
-- pywal only runs for real once the user picks a wallpaper themselves.
[[bash -c 'until awww img /usr/share/backgrounds/bos/bread-background.png 2>/dev/null; do sleep 0.3; done']],
-- breadd runs as a systemd user service (/usr/lib/systemd/user/breadd.service,
-- enabled --global so every account starts it). It autostarts at login
-- but before Hyprland exists, so
-- push the compositor's Wayland env into the user manager and restart breadd
-- to pick it up — that's how it gets HYPRLAND_INSTANCE_SIGNATURE to talk to Hyprland.
"dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE",
@ -233,16 +195,38 @@ hl.on("hyprland.start", function()
-- Start it directly instead. If more graphical-session.target
-- services show up later, add them here too.
"systemctl --user start breadclipd.service",
"breadbar",
-- breadbox-sync is a Type=oneshot systemd --user service
-- (WantedBy=default.target, no Hyprland IPC dependency) — it
-- already runs on login via the unit baked into skel; exec'ing it
-- again here would just start it twice.
"hypridle",
-- first-boot onboarding (self-gates after the first run)
"bos-welcome",
}
for _, cmd in ipairs(startup) do
for _, cmd in ipairs(core_startup) do
hl.dispatch(hl.dsp.exec_cmd(cmd))
end
-- breadbox-sync is a Type=oneshot systemd --user service
-- (WantedBy=default.target, no Hyprland IPC dependency) — it already
-- runs on login via the unit baked into /usr/lib/systemd/user,
-- independent of this list.
local ok, extra = pcall(function()
return dofile(script_dir .. "system/autostart.lua")()
end)
if not ok or type(extra) ~= "table" then
-- autostart.json/its loader broke — fall back to the same apps BOS
-- has always started, so a bad JSON edit degrades to "normal
-- desktop" rather than "no bar, no idle lock, no onboarding".
extra = {
"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
for _, cmd in ipairs(extra) do
hl.dispatch(hl.dsp.exec_cmd(cmd))
end
end)

View file

@ -0,0 +1,5 @@
{
"monitors": [
{ "output": "", "mode": "preferred", "position": "auto", "scale": "auto" }
]
}

View file

@ -0,0 +1,56 @@
-- scripts/display/monitors.lua — loads monitors.json (an array of monitor
-- rules) and applies each via hl.monitor(). Failsafe: an empty or entirely
-- invalid monitors.json is treated the same as a missing one — falling back
-- to the single generic wildcard rule — because applying *zero* monitor
-- rules risks an unconfigured/black-screen session, unlike settings.json or
-- autostart.json where "apply nothing extra" is a legitimate user choice.
local json = dofile(os.getenv("HOME") .. "/.config/hypr/scripts/lib/json.lua")
local DEFAULT_MONITORS = {
{ output = "", mode = "preferred", position = "auto", scale = "auto" },
}
local function valid_entry(e)
return type(e) == "table" and type(e.output) == "string"
end
local function load_monitors()
local path = os.getenv("HOME") .. "/.config/hypr/monitors.json"
local parsed = json.load(path)
if type(parsed) ~= "table" or type(parsed.monitors) ~= "table" then
return DEFAULT_MONITORS
end
local valid = {}
for _, entry in ipairs(parsed.monitors) do
if valid_entry(entry) then
valid[#valid + 1] = {
output = entry.output,
mode = (type(entry.mode) == "string" and entry.mode) or "preferred",
position = (type(entry.position) == "string" and entry.position) or "auto",
scale = entry.scale ~= nil and entry.scale or "auto",
mirror = type(entry.mirror) == "string" and entry.mirror or nil,
}
end
end
if #valid == 0 then
return DEFAULT_MONITORS
end
return valid
end
local monitors = load_monitors()
local applied_any = false
for _, m in ipairs(monitors) do
if pcall(hl.monitor, m) then
applied_any = true
end
end
-- Every entry failed to apply (e.g. Hyprland rejected values that still
-- passed our type checks) — guarantee a usable session rather than leaving
-- every monitor unconfigured.
if not applied_any then
pcall(hl.monitor, DEFAULT_MONITORS[1])
end

View file

@ -0,0 +1,35 @@
-- scripts/input/binds.lua — loads binds.json into { default_mods, bindings }.
-- Simpler than the multi-keyboard-layout version some personal configs use
-- (BOS ships one fixed layout, no per-layout bind switching needed): just
-- reads `default_mods` + the flat `bindings` array.
local json = dofile(os.getenv("HOME") .. "/.config/hypr/scripts/lib/json.lua")
local function normalize_mods(value, fallback)
local mods = {}
if type(value) == "table" then
for _, item in ipairs(value) do
if type(item) == "string" and item ~= "" then
mods[#mods + 1] = item
end
end
end
if #mods == 0 then
for _, mod in ipairs(fallback or {}) do
mods[#mods + 1] = mod
end
end
return mods
end
return function(configPath)
local parsed = json.load(configPath)
if type(parsed) ~= "table" then
return { default_mods = { "SUPER" }, bindings = {} }
end
return {
default_mods = normalize_mods(parsed.default_mods, { "SUPER" }),
bindings = type(parsed.bindings) == "table" and parsed.bindings or {},
}
end

View file

@ -0,0 +1,124 @@
-- scripts/input/keybinds.lua — turns binds.json entries into real hl.bind()
-- calls. Ported from a personal modular Hyprland config using the same
-- `hl` API; action_builders covers every dispatcher shape BOS's keybinds
-- actually use (exec, window management, focus/move/resize, workspaces,
-- mouse, media keys).
return function(ctx)
local function split_mods(value)
local mods = {}
for raw_mod in tostring(value):gmatch("[^+]+") do
local mod = raw_mod:gsub("^%s+", ""):gsub("%s+$", "")
if mod ~= "" then
mods[#mods + 1] = mod
end
end
return mods
end
-- `allow_empty`: an explicit `"mods": []` in binds.json (media keys,
-- which must bind with no modifier at all) must NOT fall back to
-- default_mods — only an *omitted* `mods` field should.
local function normalize_mods(value, fallback, allow_empty)
if value == nil then
return fallback
end
if type(value) ~= "table" then
return fallback
end
if #value == 0 then
return allow_empty and {} or fallback
end
local mods = {}
for _, item in ipairs(value) do
if type(item) == "string" then
for _, mod in ipairs(split_mods(item)) do
mods[#mods + 1] = mod
end
end
end
if #mods == 0 then
return fallback
end
return mods
end
local defaultMods = normalize_mods(ctx.default_mods, { "SUPER" }, false)
local bindings = ctx.bindings or {}
local function bind_string(mods, key)
if type(key) ~= "string" or key == "" then
return nil
end
if #mods == 0 then
return key
end
return table.concat(mods, " + ") .. " + " .. key
end
local action_builders = {
exec = function(entry)
return hl.dsp.exec_cmd(entry.command)
end,
close = function()
return hl.dsp.window.close()
end,
exit = function()
return hl.dsp.exit()
end,
float = function()
return hl.dsp.window.float({ action = "toggle" })
end,
fullscreen = function()
return hl.dsp.window.fullscreen({ action = "toggle" })
end,
pseudo = function()
return hl.dsp.window.pseudo({ action = "toggle" })
end,
layout = function(entry)
return hl.dsp.layout(entry.layout)
end,
focus = function(entry)
return hl.dsp.focus({ direction = entry.direction, workspace = entry.workspace })
end,
focus_last = function()
return hl.dsp.focus({ urgent_or_last = true })
end,
move = function(entry)
return hl.dsp.window.move({ workspace = entry.workspace })
end,
move_dir = function(entry)
return hl.dsp.window.move({ direction = entry.direction })
end,
resize = function()
return hl.dsp.window.resize()
end,
resize_dir = function(entry)
return hl.dsp.window.resize({ x = entry.x or 0, y = entry.y or 0, relative = true })
end,
drag = function()
return hl.dsp.window.drag()
end,
}
-- One bad entry (unknown action, missing field a builder needs) must
-- never take down every other bind — skip it and keep going instead of
-- asserting/erroring, which would abort binds.json loading entirely.
for _, entry in ipairs(bindings) do
local builder = action_builders[entry.action]
if builder then
local ok, result = pcall(function()
local mods = normalize_mods(entry.mods, defaultMods, true)
local bind = bind_string(mods, entry.key)
local action = builder(entry)
if bind and action then
hl.bind(bind, action, entry.options)
end
end)
if not ok then
print("breadhelp/hyprland: skipping bad bind entry (" .. tostring(entry.action) .. "/" .. tostring(entry.key) .. "): " .. tostring(result))
end
else
print("breadhelp/hyprland: skipping bind entry with unknown action: " .. tostring(entry.action))
end
end
end

View file

@ -0,0 +1,180 @@
-- scripts/lib/json.lua
local M = {}
function M.parse(str)
local s = str
local pos = 1
local len = #s
local function skipws()
while pos <= len and s:sub(pos,pos):match('%s') do pos = pos + 1 end
end
local function parse_string()
if s:sub(pos,pos) ~= '"' then error('expected string') end
pos = pos + 1
local out = {}
while pos <= len do
local c = s:sub(pos,pos)
if c == '"' then pos = pos + 1; return table.concat(out) end
if c == '\\' then
local n = s:sub(pos+1,pos+1)
if n == '"' then out[#out+1] = '"'; pos = pos + 2
elseif n == '\\' then out[#out+1] = '\\'; pos = pos + 2
elseif n == '/' then out[#out+1] = '/'; pos = pos + 2
elseif n == 'b' then out[#out+1] = '\b'; pos = pos + 2
elseif n == 'f' then out[#out+1] = '\f'; pos = pos + 2
elseif n == 'n' then out[#out+1] = '\n'; pos = pos + 2
elseif n == 'r' then out[#out+1] = '\r'; pos = pos + 2
elseif n == 't' then out[#out+1] = '\t'; pos = pos + 2
elseif n == 'u' then
local hex = s:sub(pos+2, pos+5)
local code = tonumber(hex, 16)
if code then out[#out+1] = utf8.char(code) end
pos = pos + 6
else
pos = pos + 2
end
else
out[#out+1] = c
pos = pos + 1
end
end
error('unclosed string')
end
local function parse_value()
skipws()
local c = s:sub(pos,pos)
if c == '"' then return parse_string()
elseif c == '{' then
return parse_object()
elseif c == '[' then
return parse_array()
elseif c:match('[%d%-]') then
local start = pos
while s:sub(pos,pos):match('[%d+%-.eE]') do pos = pos + 1 end
local num = tonumber(s:sub(start, pos-1))
return num
elseif s:sub(pos,pos+3) == 'null' then pos = pos + 4; return nil
elseif s:sub(pos,pos+3) == 'true' then pos = pos + 4; return true
elseif s:sub(pos,pos+4) == 'false' then pos = pos + 5; return false
else
error('unexpected value at ' .. pos)
end
end
function parse_array()
if s:sub(pos,pos) ~= '[' then error('expected [') end
pos = pos + 1
skipws()
local arr = {}
if s:sub(pos,pos) == ']' then pos = pos + 1; return arr end
while true do
skipws()
local val = parse_value()
table.insert(arr, val)
skipws()
local c = s:sub(pos,pos)
if c == ']' then pos = pos + 1; break
elseif c == ',' then pos = pos + 1; skipws()
else error('expected , or ]') end
end
return arr
end
function parse_object()
if s:sub(pos,pos) ~= '{' then error('expected {') end
pos = pos + 1
skipws()
local obj = {}
if s:sub(pos,pos) == '}' then pos = pos + 1; return obj end
while true do
skipws()
local key = parse_string()
skipws()
if s:sub(pos,pos) ~= ':' then error('expected :') end
pos = pos + 1
skipws()
local val = parse_value()
obj[key] = val
skipws()
local c = s:sub(pos,pos)
if c == '}' then pos = pos + 1; break
elseif c == ',' then pos = pos + 1; skipws()
else error('expected , or }') end
end
return obj
end
skipws()
return parse_value()
end
function M.load(path)
local ok, fh = pcall(io.open, path, "r")
if not ok or not fh then
return nil, "unable to open file"
end
local content = fh:read("*a")
fh:close()
local success, parsed = pcall(M.parse, content)
if not success then
return nil, parsed
end
return parsed
end
function M.encode(value, indent)
indent = indent or 0
local indent_str = string.rep(" ", indent)
local next_indent_str = string.rep(" ", indent + 1)
if value == nil then
return "null"
elseif type(value) == "boolean" then
return value and "true" or "false"
elseif type(value) == "number" then
return tostring(value)
elseif type(value) == "string" then
return '"' .. value:gsub('\\', '\\\\'):gsub('"', '\\"'):gsub('\n', '\\n'):gsub('\r', '\\r'):gsub('\t', '\\t') .. '"'
elseif type(value) == "table" then
local is_array = true
local max_idx = 0
for k in pairs(value) do
if type(k) ~= "number" then
is_array = false
break
end
max_idx = math.max(max_idx, k)
end
if is_array and max_idx == #value then
if max_idx == 0 then
return "[]"
end
local items = {}
for i = 1, max_idx do
table.insert(items, next_indent_str .. M.encode(value[i], indent + 1))
end
return "[\n" .. table.concat(items, ",\n") .. "\n" .. indent_str .. "]"
else
local items = {}
for k, v in pairs(value) do
table.insert(items, next_indent_str .. M.encode(tostring(k), 0) .. ": " .. M.encode(v, indent + 1))
end
if #items == 0 then
return "{}"
end
table.sort(items)
return "{\n" .. table.concat(items, ",\n") .. "\n" .. indent_str .. "}"
end
else
return "null"
end
end
return M

View file

@ -0,0 +1,55 @@
-- scripts/system/autostart.lua — loads the *extra* (user-toggleable)
-- autostart list from autostart.json. Returns just the enabled commands, in
-- order. The core bootstrap sequence (theme generation, dark-mode gsettings,
-- polkit agent, wallpaper daemon, breadd's Wayland-env fix, breadclipd) is
-- deliberately NOT exposed here — it's timing/order-sensitive infrastructure,
-- not something a settings UI should let a user disable, so it stays
-- hardcoded in hyprland.lua itself.
--
-- Failsafe: unlike monitors.json, an empty or all-disabled result here is a
-- legitimate user choice (they don't want breadbar/hypridle/etc), so this
-- only falls back to defaults on a missing/malformed file — never just
-- because the valid result happens to be empty.
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 = {
{ command = "breadbar", enabled = true },
{ command = "hypridle", enabled = true },
{ command = "bos-netcheck", enabled = true },
{ command = "bash -c 'command -v bos-first-boot >/dev/null && exec bos-first-boot'", enabled = true },
{ command = "breadhelp --autostart", enabled = true },
{ command = "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", enabled = true },
{ command = "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", enabled = true },
{ 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()
local path = os.getenv("HOME") .. "/.config/hypr/autostart.json"
local parsed = json.load(path)
local entries
if type(parsed) ~= "table" or type(parsed.extra) ~= "table" then
entries = DEFAULT_EXTRA
else
entries = parsed.extra
end
local commands = {}
for _, entry in ipairs(entries) do
if type(entry) == "table" and type(entry.command) == "string" and entry.command ~= "" then
local enabled = entry.enabled
if type(enabled) ~= "boolean" then
enabled = true
end
if enabled then
commands[#commands + 1] = entry.command
end
end
end
return commands
end

View file

@ -0,0 +1,136 @@
-- scripts/ui/rules.lua — layer-shell (compositor) rules for the bread shell.
--
-- Blur / ignore-alpha / per-namespace motion for breadbar (island + osd +
-- notif + panel + dismiss), the breadbox launcher, and the breadclip popup.
-- Window rules (float/centre for onboarding popups) live in hyprland.lua;
-- this file is layer rules only.
--
-- Theme-driven (bos-ui-demos THEME_SYSTEM_PLAN.md §9): `bread-theme
-- layerrules` writes ~/.config/hypr/layerrules.json from the active shell
-- theme's [compositor] table — blur / ignore_alpha / blur_popups /
-- animation / no_anim, keyed by layer-shell namespace. That table owns
-- *appearance* only; placement / workspace / focus are never touched here.
-- If the JSON is missing, unreadable, malformed, or anything goes wrong
-- while parsing it, apply_hardcoded_layer_rules() runs instead — the exact
-- set this file hardcoded before the theme system existed — so a broken or
-- absent theme file can never disable compositor blur.
local LAYERRULES_JSON = os.getenv("HOME") .. "/.config/hypr/layerrules.json"
local function apply_hardcoded_layer_rules()
hl.layer_rule({
name = "breadbar-island",
match = { namespace = "^breadbar$" },
blur = true,
ignore_alpha = 0.2,
blur_popups = true,
animation = "slide top",
})
hl.layer_rule({
name = "breadbar-osd",
match = { namespace = "^breadbar-osd$" },
blur = true,
ignore_alpha = 0.2,
animation = "slide bottom",
})
hl.layer_rule({
name = "breadbar-notif",
match = { namespace = "^breadbar-notif$" },
blur = true,
ignore_alpha = 0.2,
animation = "slide right",
})
hl.layer_rule({
name = "breadbar-panel",
match = { namespace = "^breadbar-panel$" },
blur = true,
ignore_alpha = 0.2,
animation = "slide right",
})
hl.layer_rule({
name = "breadbar-dismiss",
match = { namespace = "^breadbar-dismiss$" },
no_anim = true,
})
hl.layer_rule({
match = "breadbox",
blur = true,
ignore_alpha = 0.2,
})
end
-- Parses ~/.config/hypr/layerrules.json into a plain array of per-namespace
-- rule tables, sorted by namespace for a deterministic emission order.
-- Returns nil (not an error) for anything short of a well-formed, non-empty
-- JSON object, so the pcall wrapping this only guards against a genuine Lua
-- error (e.g. json.lua failing to load) rather than every malformed case.
local function parsed_layer_rules()
local json = dofile(os.getenv("HOME") .. "/.config/hypr/scripts/lib/json.lua")
local parsed = json.load(LAYERRULES_JSON)
if type(parsed) ~= "table" then
return nil
end
local namespaces = {}
for ns, rule in pairs(parsed) do
if type(ns) == "string" and type(rule) == "table" then
namespaces[#namespaces + 1] = ns
end
end
if #namespaces == 0 then
return nil
end
table.sort(namespaces)
local rules = {}
for _, ns in ipairs(namespaces) do
local r = parsed[ns]
rules[#rules + 1] = {
namespace = ns,
blur = r.blur == true,
ignore_alpha = r.ignore_alpha,
blur_popups = r.blur_popups == true,
animation = r.animation,
no_anim = r.no_anim == true,
}
end
return rules
end
-- Emits one hl.layer_rule per parsed namespace. Built as pure data by
-- parsed_layer_rules() first (no hl.layer_rule calls during parsing), so a
-- parse failure can never leave a partial JSON-derived rule set applied
-- before the caller falls back to apply_hardcoded_layer_rules().
local function apply_json_layer_rules(rules)
for _, r in ipairs(rules) do
hl.layer_rule({
name = r.namespace,
match = { namespace = "^" .. r.namespace .. "$" },
blur = r.blur,
ignore_alpha = r.ignore_alpha,
blur_popups = r.blur_popups,
animation = r.animation,
no_anim = r.no_anim,
})
end
end
local ok, rules = pcall(parsed_layer_rules)
if ok and rules then
apply_json_layer_rules(rules)
else
apply_hardcoded_layer_rules()
end
-- breadclip is not part of the shell theme's [compositor] table yet — always
-- hardcoded, regardless of the JSON above.
hl.layer_rule({
match = "breadclip",
blur = true,
ignore_alpha = 0.0,
})

View file

@ -0,0 +1,121 @@
-- scripts/ui/settings.lua — loads settings.json over hardcoded defaults and
-- applies via hl.config(). Failsafe in two layers:
-- 1. every leaf value is type-checked against its default individually, so
-- one bad field (wrong type, typo) falls back to just that field, not
-- the whole file;
-- 2. the actual hl.config() application is pcall'd — if Hyprland itself
-- rejects a well-typed-but-semantically-bad value, we fall back to
-- re-applying pure hardcoded defaults, so the session always comes up
-- with a normal, usable layout instead of erroring out mid-config-load.
local json = dofile(os.getenv("HOME") .. "/.config/hypr/scripts/lib/json.lua")
local DEFAULTS = {
gaps_in = 5,
gaps_out = 10,
border_size = 2,
active_border = "rgba(88c0d0ff)",
inactive_border = "rgba(4c566aff)",
layout = "dwindle",
resize_on_border = true,
rounding = 8,
blur_enabled = true,
blur_size = 6,
blur_passes = 2,
shadow_enabled = true,
shadow_range = 12,
shadow_render_power = 3,
kb_layout = "us",
follow_mouse = 1,
natural_scroll = true,
}
local function num(v, fallback)
if type(v) == "number" then return v end
return fallback
end
local function bool(v, fallback)
if type(v) == "boolean" then return v end
return fallback
end
local function str(v, fallback)
if type(v) == "string" and v ~= "" then return v end
return fallback
end
local function load_overrides()
local path = os.getenv("HOME") .. "/.config/hypr/settings.json"
local parsed = json.load(path)
if type(parsed) ~= "table" then
return {}
end
return parsed
end
local o = load_overrides()
local merged = {
gaps_in = num(o.gaps_in, DEFAULTS.gaps_in),
gaps_out = num(o.gaps_out, DEFAULTS.gaps_out),
border_size = num(o.border_size, DEFAULTS.border_size),
active_border = str(o.active_border, DEFAULTS.active_border),
inactive_border = str(o.inactive_border, DEFAULTS.inactive_border),
layout = str(o.layout, DEFAULTS.layout),
resize_on_border = bool(o.resize_on_border, DEFAULTS.resize_on_border),
rounding = num(o.rounding, DEFAULTS.rounding),
blur_enabled = bool(o.blur_enabled, DEFAULTS.blur_enabled),
blur_size = num(o.blur_size, DEFAULTS.blur_size),
blur_passes = num(o.blur_passes, DEFAULTS.blur_passes),
shadow_enabled = bool(o.shadow_enabled, DEFAULTS.shadow_enabled),
shadow_range = num(o.shadow_range, DEFAULTS.shadow_range),
shadow_render_power = num(o.shadow_render_power, DEFAULTS.shadow_render_power),
kb_layout = str(o.kb_layout, DEFAULTS.kb_layout),
follow_mouse = num(o.follow_mouse, DEFAULTS.follow_mouse),
natural_scroll = bool(o.natural_scroll, DEFAULTS.natural_scroll),
}
local function build_hl_config(v)
return {
general = {
gaps_in = v.gaps_in,
gaps_out = v.gaps_out,
border_size = v.border_size,
col = {
active_border = v.active_border,
inactive_border = v.inactive_border,
},
layout = v.layout,
resize_on_border = v.resize_on_border,
},
decoration = {
rounding = v.rounding,
active_opacity = 1.0,
inactive_opacity = 1.0,
blur = {
enabled = v.blur_enabled,
size = v.blur_size,
passes = v.blur_passes,
new_optimizations = true,
},
shadow = {
enabled = v.shadow_enabled,
range = v.shadow_range,
render_power = v.shadow_render_power,
},
},
input = {
kb_layout = v.kb_layout,
follow_mouse = v.follow_mouse,
touchpad = { natural_scroll = v.natural_scroll },
},
dwindle = { preserve_split = true },
animations = { enabled = true },
misc = { disable_hyprland_logo = true, disable_splash_rendering = true },
}
end
local ok = pcall(hl.config, build_hl_config(merged))
if not ok then
pcall(hl.config, build_hl_config(DEFAULTS))
end

View file

@ -0,0 +1,19 @@
{
"gaps_in": 5,
"gaps_out": 10,
"border_size": 2,
"active_border": "rgba(88c0d0ff)",
"inactive_border": "rgba(4c566aff)",
"layout": "dwindle",
"resize_on_border": true,
"rounding": 8,
"blur_enabled": true,
"blur_size": 6,
"blur_passes": 2,
"shadow_enabled": true,
"shadow_range": 12,
"shadow_render_power": 3,
"kb_layout": "us",
"follow_mouse": 1,
"natural_scroll": true
}

View file

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

View file

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

@ -81,7 +81,15 @@ alias ip='ip --color=auto'
alias update='bos-update'
alias pacman='sudo pacman'
# ~/.local/bin holds the bread* binaries baked in at build time.
# Package shortcuts — official repos via pacman, AUR via yay (alt-* prefix).
alias install='sudo pacman -S'
alias uninstall='sudo pacman -R'
alias srchpkg='sudo pacman -Ss'
alias alt-install='yay -S'
alias alt-uninstall='yay -R'
alias alt-srchpkg='yay -Ss'
# Per-user tools. Bakery desktop apps live in /usr/local/bin (already on PATH).
export PATH="$HOME/.local/bin:$PATH"
# Powerlevel10k prompt configuration.

View file

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

View file

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

@ -0,0 +1,12 @@
# Bakery systemd --user units. `systemctl --global enable` (post-install and
# live setup) applies these so a later `useradd -m` starts them on first login.
# Bake rewrites this list from the units actually copied into the image.
#
# breadclipd is WantedBy=graphical-session.target. BOS does not activate that
# target (no uwsm); Hyprland still `systemctl --user start`s it after the
# compositor is up. --global enable still records it for every account.
enable breadd.service
enable breadbox-sync.service
enable breadclipd.service
enable breadcrumbs.service
enable breadmill.service

View file

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

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

View file

@ -1,4 +0,0 @@
#!/bin/bash
# Show the BOS keybind cheatsheet in a floating terminal (bound to SUPER+/).
# The bos-keybinds window class is floated/centred by a Hyprland window rule.
exec kitty --class bos-keybinds --title "BOS Keybinds" -- less -R /usr/share/bos/keybinds.txt

View file

@ -7,9 +7,17 @@
# bos-launch-calamares). Runs once at boot, before the tty1 autologin getty.
set -e
# Bakery user units live in /usr/lib/systemd/user. --global enable writes
# /etc/systemd/user/*.wants/ so liveuser (and any later account) starts
# them on first login. Idempotent; bins are already in /usr/local.
if [[ -x /usr/local/bin/bos-enable-bakery-user-units ]]; then
/usr/local/bin/bos-enable-bakery-user-units \
|| echo "WARN: enabling bakery user units globally failed"
fi
# useradd -m copies /etc/skel, so the live user gets the real BOS desktop
# (breadd + breadbar + breadbox + keybinds) — proper live-media functionality,
# not an installer kiosk.
# (hypr + bread config + bakery state) — proper live-media functionality,
# not an installer kiosk. Binaries are /usr/local, not skel.
if ! id liveuser &>/dev/null; then
useradd -m -s /usr/bin/zsh liveuser
for g in wheel video input audio storage power; do

View file

@ -0,0 +1,35 @@
#!/bin/bash
# Every-login network connectivity check. Split out of the old bos-welcome
# script, which conflated this with one-time onboarding (now breadhelp's
# job) — this half must keep running unconditionally on every login, before
# any GUI toolkit is worth spinning up, and must never be gated by a marker.
set -u
# Never run in the live/installer session — only on an installed system.
[[ "$(id -un)" == "liveuser" ]] && exit 0
# A fresh install usually boots with no connection (Wi-Fi isn't configured
# during install), and the first `bos-update`/pacman run then fails with
# confusing DNS/"could not resolve host" errors. If NetworkManager reports
# we're not fully online, open nmtui so the user can join a network before
# anything else. Best-effort: missing nmcli/nmtui/kitty, or the user quitting
# nmtui, must never block the rest of login.
command -v nmcli &>/dev/null || exit 0
conn="$(nmcli networking connectivity check 2>/dev/null)"
# NetworkManager may still be associating right at compositor start — give it
# a few short retries before concluding we're actually offline, so a machine
# with working Wi-Fi doesn't get a spurious nmtui popup.
tries=0
while [[ "$conn" != "full" && "$tries" -lt 3 ]]; do
sleep 1
conn="$(nmcli networking connectivity check 2>/dev/null)"
tries=$((tries + 1))
done
if [[ "$conn" != "full" ]]; then
notify-send -u normal "BOS" "No internet yet — opening network setup so updates work." 2>/dev/null || true
if command -v nmtui &>/dev/null; then
kitty --class bos-netsetup --title "Connect to a network" -- nmtui connect 2>/dev/null || true
fi
fi

View file

@ -0,0 +1,150 @@
#!/bin/bash
# bos-nvidia-setup — optional proprietary NVIDIA driver + Hyprland env.
#
# Installs nvidia + nvidia-utils only (never cuda). Writes
# ~/.config/hypr/nvidia.lua, which skel hyprland.lua dofiles only when
# the file exists — Mesa machines stay unchanged. Existing installs get
# the same include patched in if it is missing.
#
# Click-to-install from Settings, or run by hand. Not invoked from
# bos-first-boot. Idempotent. Prints "reboot required".
#
# Must run on an installed system. Elevates via pkexec, then sudo.
set -uo pipefail
usage() {
cat <<'EOF'
Usage: bos-nvidia-setup [--home DIR]
Install nvidia + nvidia-utils (not cuda) and write the Hyprland NVIDIA
env drop-in for this user. Reboot after.
--home DIR user home that owns ~/.config/hypr (required under pkexec
if PKEXEC_UID / SUDO_USER cannot be resolved)
EOF
}
TARGET_HOME=""
while [[ $# -gt 0 ]]; do
case "$1" in
--home)
TARGET_HOME="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "bos-nvidia-setup: unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
if [[ "$(id -un)" == "liveuser" || -d /run/archiso ]]; then
echo "bos-nvidia-setup is for an installed system, not the live ISO." >&2
exit 1
fi
if [[ "$(id -u)" -ne 0 ]]; then
home="${TARGET_HOME:-${HOME:-}}"
if [[ -z "$home" ]]; then
echo "bos-nvidia-setup: cannot determine home; pass --home" >&2
exit 1
fi
self="$(command -v bos-nvidia-setup 2>/dev/null || true)"
[[ -n "$self" ]] || self="$(readlink -f "$0" 2>/dev/null || printf '%s' "$0")"
if command -v pkexec >/dev/null 2>&1; then
exec pkexec "$self" --home "$home"
fi
if command -v sudo >/dev/null 2>&1; then
exec sudo "$self" --home "$home"
fi
echo "bos-nvidia-setup: need root (pkexec or sudo)" >&2
exit 1
fi
if [[ -z "$TARGET_HOME" ]]; then
if [[ -n "${PKEXEC_UID:-}" ]]; then
TARGET_HOME="$(getent passwd "$PKEXEC_UID" | cut -d: -f6 || true)"
elif [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != root ]]; then
TARGET_HOME="$(getent passwd "$SUDO_USER" | cut -d: -f6 || true)"
fi
fi
if [[ -z "$TARGET_HOME" || "$TARGET_HOME" == /root || ! -d "$TARGET_HOME" ]]; then
echo "bos-nvidia-setup: cannot determine user home (pass --home)" >&2
exit 1
fi
HYPR_DIR="$TARGET_HOME/.config/hypr"
NVIDIA_LUA="$HYPR_DIR/nvidia.lua"
HYPR_LUA="$HYPR_DIR/hyprland.lua"
# Hyprland 0.56 (Aquamarine). Wiki (https://wiki.hypr.land/Nvidia/):
# LIBVA_DRIVER_NAME + __GLX_VENDOR_LIBRARY_NAME. NVD_BACKEND is the
# current VA-API hint. No WLR_* (not wlroots). No GBM_BACKEND (not
# required; older docs cargo-culted it and it can break Firefox).
NVIDIA_LUA_BODY='-- Written by bos-nvidia-setup. hyprland.lua dofiles this only when it exists.
-- Hyprland 0.56 (Aquamarine) — no WLR_* variables.
-- https://wiki.hypr.land/Nvidia/
hl.env("LIBVA_DRIVER_NAME", "nvidia")
hl.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia")
hl.env("NVD_BACKEND", "direct")
'
# Self-contained so it is safe to append to a hand-edited hyprland.lua.
HYPR_INCLUDE='-- bos-nvidia-setup: optional proprietary env; no-op when the file is absent
do
local nvidia = (os.getenv("HOME") or "") .. "/.config/hypr/nvidia.lua"
local f = io.open(nvidia, "r")
if f then
f:close()
pcall(dofile, nvidia)
end
end
'
own_as_user() {
local path="$1"
[[ -e "$path" ]] || return 0
local owner
owner="$(stat -c '%u:%g' "$TARGET_HOME" 2>/dev/null || true)"
[[ -n "$owner" ]] || return 0
chown "$owner" "$path" 2>/dev/null || true
}
echo "==> Installing nvidia + nvidia-utils (not cuda)"
if ! command -v pacman >/dev/null 2>&1; then
echo "bos-nvidia-setup: pacman not found" >&2
exit 1
fi
if ! pacman -S --needed --noconfirm -- nvidia nvidia-utils; then
echo "bos-nvidia-setup: pacman install failed" >&2
exit 1
fi
echo "==> Writing $NVIDIA_LUA"
mkdir -p "$HYPR_DIR" || {
echo "bos-nvidia-setup: cannot create $HYPR_DIR" >&2
exit 1
}
printf '%s' "$NVIDIA_LUA_BODY" >"$NVIDIA_LUA" || {
echo "bos-nvidia-setup: cannot write $NVIDIA_LUA" >&2
exit 1
}
own_as_user "$NVIDIA_LUA"
if [[ -f "$HYPR_LUA" ]] && ! grep -q 'nvidia\.lua' "$HYPR_LUA"; then
echo "==> Including nvidia.lua from $HYPR_LUA"
if [[ -n "$(tail -c1 "$HYPR_LUA" 2>/dev/null || true)" ]]; then
printf '\n' >>"$HYPR_LUA"
fi
printf '%s\n' "$HYPR_INCLUDE" >>"$HYPR_LUA"
own_as_user "$HYPR_LUA"
fi
echo "reboot required"
exit 0

View file

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

View file

@ -2,11 +2,10 @@
# BOS graphical session launcher, run by greetd on the INSTALLED system after
# the user authenticates (see /etc/greetd/config.toml).
#
# greetd does not start a login shell, so /etc/profile.d is never sourced — which
# means ~/.local/bin (where bakery installs the bread ecosystem: breadd, breadbar,
# breadbox-sync, …) would be missing from PATH and the Hyprland `exec-once`
# launches would fail. Source the login profile here so PATH is correct, set the
# Wayland session hints, then hand off to Hyprland.
# greetd does not start a login shell, so /etc/profile.d is never sourced.
# Bakery desktop apps live in /usr/local/bin (already on Arch PATH). Source
# the login profile here so ~/.local/bin (per-user tools) is also on PATH,
# set the Wayland session hints, then hand off to Hyprland.
#
# Launched via start-hyprland (ships with the hyprland package) rather than the
# raw Hyprland binary — Hyprland upstream no longer recommends exec'ing it

View file

@ -2,19 +2,41 @@
# bos-update — update all of BOS in one go.
#
# BOS packages come from two channels, so a full update touches both:
# 1. pacman — Arch base/desktop + the [breadway] repo (bos-settings, etc.).
# Every transaction is snapshotted by snap-pac, so you can roll
# back from the GRUB "snapshots" submenu or BOS Settings.
# 2. bakery — the bread ecosystem apps in ~/.local/bin (whatever `bakery list`
# reports as installed — bread, breadbar, breadbox, breadcrumbs,
# breadpad, breadman, bread-theme, breadpaper, breadmon,
# breadsearch, breadclip, breadshot, ...).
# 1. pacman — Arch base/desktop + the [breadway] repo (breadlock + AUR
# republishes: calamares, zen-browser-bin, bibata, yay-bin,
# powerlevel10k). [breadway] does NOT provide bos-settings
# or other bakery desktop apps. Every transaction is
# snapshotted by snap-pac; recover via the GRUB "snapshots"
# submenu (grub-btrfs), not `snapper rollback`.
# 2. bakery — the bread ecosystem apps in /usr/local (whatever `bakery list`
# 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.
set -uo pipefail
bold() { printf '\033[1m%s\033[0m\n' "$1"; }
# Timed snapper pre snapshot before either channel. snap-pac already
# snapshots root around pacman; bakery now writes /usr/local (on @), so
# that root snapshot includes the desktop apps. This extra snapshot is
# still best-effort — a home config if the installer created one (user
# bakery state), plus a root timeline around the whole update. Never
# fail the update if snapper is missing or the create errors.
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)"
if command -v pacman >/dev/null; then
sudo pacman -Syu || echo "WARN: pacman update failed"
@ -25,10 +47,22 @@ fi
echo
bold "==> Bread ecosystem (bakery update --all)"
if command -v bakery >/dev/null; then
bakery update --all || echo "WARN: bakery update failed"
# /usr/local is root-owned. Never run bakery as the user against it;
# 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
echo "bakery not found; skipping"
fi
echo
bold "==> BOS is up to date."
echo
bold "Recovery"
echo "If this update goes badly: reboot → GRUB “snapshots” submenu."
echo "snapper rollback will not change what GRUB boots (rootflags=subvol=@)."

View file

@ -1,48 +0,0 @@
#!/bin/bash
# First-run welcome + first-run/every-login network check. Launched from the
# Hyprland autostart; the bos-welcome window class is floated/centred by a
# Hyprland window rule.
set -u
# Never run in the live/installer session — only on an installed system.
[[ "$(id -un)" == "liveuser" ]] && exit 0
welcomed_marker="${XDG_CONFIG_HOME:-$HOME/.config}/bos/.welcomed"
mkdir -p "$(dirname "$welcomed_marker")"
# Network check. A fresh install usually boots with no connection (Wi-Fi
# isn't configured during install), and the first `bos-update`/pacman run
# then fails with confusing DNS/"could not resolve host" errors. If
# NetworkManager reports we're not fully online, open nmtui so the user can
# join a network before anything else. This runs on EVERY login, not just
# the first, and isn't gated by any marker — it keeps re-prompting until the
# machine is actually online, then naturally stops (the "full" check below
# short-circuits). Best-effort: missing nmcli/nmtui/kitty, or the user
# quitting nmtui, must never block the welcome text below.
if command -v nmcli &>/dev/null; then
conn="$(nmcli networking connectivity check 2>/dev/null)"
# NetworkManager may still be associating right at compositor start —
# give it a few short retries before concluding we're actually offline,
# so a machine with working Wi-Fi doesn't get a spurious nmtui popup.
tries=0
while [[ "$conn" != "full" && "$tries" -lt 3 ]]; do
sleep 1
conn="$(nmcli networking connectivity check 2>/dev/null)"
tries=$((tries + 1))
done
if [[ "$conn" != "full" ]]; then
notify-send -u normal "BOS" "No internet yet — opening network setup so updates work." 2>/dev/null || true
if command -v nmtui &>/dev/null; then
kitty --class bos-netsetup --title "Connect to a network" -- nmtui connect 2>/dev/null || true
fi
fi
fi
# Welcome text: shown once ever, independent of network status above (an
# offline machine still gets useful onboarding text — it just also keeps
# getting the network prompt on future logins until it connects).
[[ -f "$welcomed_marker" ]] && exit 0
touch "$welcomed_marker"
exec kitty --class bos-welcome --title "Welcome to BOS" -- less -R /usr/share/bos/welcome.txt

View file

@ -1,53 +0,0 @@
██████ ██████ ███████ keyboard shortcuts
██ ██ ██ ██ ██ SUPER is the Windows/Cmd key
██████ ██ ██ ███████
══════════════════════════════════════════════════════════
APPS & WINDOWS
SUPER + Return terminal (kitty)
SUPER + Space app launcher (breadbox)
SUPER + E files (nautilus)
SUPER + B browser (zen)
SUPER + U notes / reminders (breadpad)
SUPER + M notes / task manager (breadman)
SUPER + , BOS Settings
SUPER + / this keybind cheatsheet
SUPER + L lock screen
SUPER + Backspace close window
SUPER + F fullscreen
SUPER + I toggle floating
SUPER + P toggle pseudotile
SUPER + R resize mode
SUPER + V / Shift + V clipboard history (breadclip)
SUPER + T toggle split direction
SUPER + Tab last window
SUPER + N exit Hyprland (log out)
SCREENSHOTS
SUPER + Shift + S select region -> file
SUPER + Shift + C select region -> clipboard
SUPER + Shift + P whole screen -> file
FOCUS & MOVE
SUPER + arrows move focus
SUPER + Shift + h/j/k/l move window
SUPER + Shift + arrows resize window
WORKSPACES
SUPER + 1..0 switch to workspace 1..10
SUPER + Shift + 1..0 move window to workspace
SUPER + [ / ] previous / next workspace
SUPER + Shift + [ / ] move window prev / next workspace
SUPER + scroll cycle workspaces
MOUSE
SUPER + left-drag move window
SUPER + right-drag resize window
MEDIA & HARDWARE KEYS
volume / brightness / play-pause / next / prev (work on lock screen)
calculator key opens gnome-calculator
──────────────────────────────────────────────────────────
Press q to close. Configure everything in BOS Settings (SUPER + ,).

View file

@ -1,24 +0,0 @@
Welcome to BOS — the Bread Operating System
══════════════════════════════════════════════════════════
You're running a complete Hyprland desktop with the bread
ecosystem preinstalled. A few things to get you started:
• SUPER + / show the keybind cheatsheet (any time)
• SUPER + , open BOS Settings — configure bread, the
bar, launcher, Wi-Fi profiles, notes,
snapshots and package updates, all in one
place (no config files needed)
• SUPER + Space the app launcher (breadbox)
• SUPER + Return a terminal
The bar at the top (breadbar) shows workspaces, the clock,
system stats, and your tray. Notifications appear top-right.
Your system is snapshotted on every package change — if an
update breaks something, roll back from BOS Settings or pick
a snapshot from the GRUB menu at boot.
──────────────────────────────────────────────────────────
Press q to close. This message won't show again.

63
iso/bread-lockfile.toml Normal file
View file

@ -0,0 +1,63 @@
# 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,9 +1,26 @@
# Base system
base
base-devel
linux
linux-firmware
linux-headers
# linux-firmware metapackage pulls every mandatory vendor blob (incl. nvidia).
# List the subpackages we actually need so nvidia (~103 MiB, nouveau-only —
# 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
# 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.
@ -30,12 +47,21 @@ efibootmgr
btrfs-progs
dosfstools
mtools
# LUKS full-disk encryption — Calamares' partition module has encryption
# support built in and enabled by default, but needs cryptsetup actually
# present (live, to create the container; installed, to unlock at boot via
# mkinitcpio's encrypt hook) or the checkbox leads to an unbootable system.
cryptsetup
# Secure Boot key enrollment/signing (self-signed — see post-install.sh).
# Ships its own pacman hook (zz-sbctl.hook) that re-signs the kernel/
# bootloader automatically on every future update once enrolled.
sbctl
# squashfs-tools: provides unsquashfs, which Calamares' unpackfs module uses
# to extract airootfs.sfs onto the target during install.
squashfs-tools
# rsync: unpackfs copies the unpacked rootfs onto the target with rsync.
rsync
# Live-ISO boot (archiso bootmodes: bios.syslinux + uefi.systemd-boot)
# Live-ISO boot (archiso bootmodes: bios.syslinux + uefi.grub)
# mkinitcpio-archiso provides the initramfs hooks that find and mount
# airootfs.sfs and switch root into it — without it the live ISO drops
# to emergency mode on boot.
@ -52,6 +78,8 @@ snapper
snap-pac
grub-btrfs
inotify-tools
# Home backup (Settings → Backup). Snapper is root (`@`) only; restic covers $HOME.
restic
# Wayland / Hyprland
hyprland
@ -90,11 +118,17 @@ bluez-utils
# blueman: GUI Bluetooth manager (pair/connect devices; breadbar shows status only).
blueman
# GTK4 runtime
# GTK4 runtime (breadbar, breadbox, breadclip, breadhelp, and other bakery apps)
gtk4
gtk4-layer-shell
librsvg
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
# skel settings.ini silently falls back to the light theme for GTK3 apps.
gnome-themes-extra
@ -116,7 +150,9 @@ wayland-protocols
# Fonts
noto-fonts
noto-fonts-cjk
# noto-fonts-cjk is ~299 MiB installed / ~196 MiB on the ISO and only useful
# to CJK-locale users. Install on first run for zh/ja/ko.
# noto-fonts-cjk
noto-fonts-emoji
ttf-jetbrains-mono
# Nerd font variant — icons in terminal tools (eza --icons, fastfetch, yazi)
@ -142,13 +178,12 @@ file-roller
# GUI applications a general desktop is expected to have out of the box.
# gnome-text-editor: graphical editor (terminal editors aside); gnome-calculator:
# calculator; loupe: Wayland-native image viewer (default for image files);
# zathura(+pdf-mupdf): lightweight Wayland PDF viewer (BOS had no PDF reader).
# 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+zathura-pdf-mupdf would pull libmupdf (~56 MiB) as a never-default viewer.
gnome-text-editor
gnome-calculator
loupe
zathura
zathura-pdf-mupdf
# Media player — BOS ships gstreamer codecs but otherwise has no player app.
vlc
# Web browser (served from the [Breadway] repo; AUR zen-browser-bin republished
@ -161,20 +196,24 @@ mailcap
# (calamares 3.4.x is already Qt6; there is no separate calamares-qt6 package)
calamares
# AUR helper — yay-bin is AUR-only (no AUR helper ships in the official
# repos), so it's republished to [breadway] the same way (see
# packaging/yay-bin). Lets users reach the wider AUR beyond bakery's bread
# ecosystem + [breadway]'s own small set of republished packages.
yay-bin
# Bread ecosystem.
#
# The bread apps themselves (bakery, bread, breadbar, breadbox, breadcrumbs,
# breadpad) are NOT pacman packages here — they are bakery-managed binaries
# baked into /etc/skel/.local/bin at build time (see build-local.sh), so every
# user gets the exact versions from this laptop's bakery install with no
# network/DNS needed at install or runtime. Their runtime system deps are pulled
# in elsewhere in this list (gtk4, gtk4-layer-shell, iw, libpulse, librsvg,
# networkmanager, openssl, zlib, systemd-libs) — keep those even though no bread
# package depends on them.
#
# bos-settings is a BOS-specific pacman package (not part of the bakery index),
# so it stays here, served from the [breadway] repo.
bos-settings
# breadlock is the only bread* pacman package here (it needs a root-owned
# /etc/pam.d/breadlock). Everything else — bakery, bread/breadd/bread-emit/
# bread-module-host, breadbar, breadbox, breadcrumbs, breadpad, breadpaper,
# bread-theme, breadmon, breadsearch, breadclip, breadshot, bos-settings,
# breadhelp — is bakery-managed and baked into /usr/local at ISO build
# time from iso/bread-lockfile.toml (see build-local.sh). breadcast and
# breadarr are not shipped. bos-settings/breadhelp desktop entries are
# also committed under iso/airootfs/etc/skel/.local/share/applications/. Runtime
# deps stay listed even though no bread package depends on them via pacman
# (gtk4, gtk4-layer-shell, webkit2gtk-4.1, iw, libpulse, librsvg, …).
# Input / screen utilities
brightnessctl
@ -186,6 +225,8 @@ slurp
wl-clipboard
playerctl
# 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
python-pywal
# Boot splash (BOS logo + spinner instead of kernel text).
@ -272,6 +313,15 @@ system-config-printer
# remote post-install (needs network); the runtime is shipped ready.
flatpak
# Graphical alternatives to terminal-only tools, so users who want more
# graphical control aren't funneled to a shell for everyday things.
# gnome-disk-utility: partition/format/SMART-health GUI for gnome-disks.
# gufw: GUI front-end for the ufw firewall bos already enables by default.
# mission-center: graphical task manager (CPU/mem/disk/net + process list).
gnome-disk-utility
gufw
mission-center
# Firewall — ufw, enabled deny-incoming in post-install.sh (mDNS allowed so
# printer discovery still works).
ufw
@ -298,6 +348,3 @@ qt6ct
# hyprland.lua) needs these or Qt apps fall back to (blurry) XWayland.
qt5-wayland
qt6-wayland
# Dev tools (for bos-settings standalone install)
rustup

View file

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

View file

@ -8,7 +8,12 @@ iso_application="Bread Operating System"
iso_version="$(date +%Y.%m.%d)"
install_dir="arch"
buildmodes=('iso')
bootmodes=('bios.syslinux' 'uefi.systemd-boot')
# systemd-boot can only read files from the ESP it was launched from, so
# 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"
pacman_conf="pacman.conf"
airootfs_image_type="squashfs"
@ -22,7 +27,10 @@ file_permissions=(
["/usr/local/bin/bos-copy-kernel"]="0:0:755"
["/usr/local/bin/bos-resolve-airootfs"]="0:0:755"
["/usr/local/bin/bos-session"]="0:0:755"
["/usr/local/bin/bos-keybinds"]="0:0:755"
["/usr/local/bin/bos-welcome"]="0:0:755"
["/usr/local/bin/bos-netcheck"]="0:0:755"
["/usr/local/bin/bos-update"]="0:0:755"
["/usr/local/bin/bos-rescue"]="0:0:755"
["/usr/local/bin/bos-first-boot"]="0:0:755"
["/usr/local/bin/bos-nvidia-setup"]="0:0:755"
["/usr/local/bin/bos-enable-bakery-user-units"]="0:0:755"
)

View file

@ -1,38 +0,0 @@
# Maintainer: Breadway <rileyhorsham@gmail.com>
pkgname=bos-settings
pkgver=0.1.0
pkgrel=1
pkgdesc="System settings app for Bread OS"
arch=('x86_64')
url="https://github.com/Breadway/bos"
license=('MIT')
# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's
# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read,
# causing undefined-symbol errors. Disable LTO.
options=(!lto !debug)
depends=('gtk4' 'glib2' 'hicolor-icon-theme')
optdepends=(
'snapper: snapshot management view'
)
makedepends=('rust' 'cargo')
source=("${pkgname}-${pkgver}.tar.gz")
sha256sums=('SKIP')
build() {
cd "${srcdir}/${pkgname}-${pkgver}"
cargo build --release --locked -p bos-settings
}
check() {
cd "${srcdir}/${pkgname}-${pkgver}"
cargo test --release --locked -p bos-settings
}
package() {
cd "${srcdir}/${pkgname}-${pkgver}"
install -Dm755 target/release/bos-settings "${pkgdir}/usr/bin/bos-settings"
install -Dm644 packaging/arch/bos-settings.desktop \
"${pkgdir}/usr/share/applications/bos-settings.desktop"
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}

View file

@ -1,25 +1,19 @@
Arch packaging
==============
`PKGBUILD` builds and installs `bos-settings` from source.
This directory only holds `PKGBUILD`s for third-party AUR packages BOS
republishes to the `[breadway]` pacman repo (`calamares`, `bibata`,
`powerlevel10k`, `yay-bin`, `python-pywal`) — not the user's own code. See each
subdirectory's `.forgejo/workflows/<name>.yml` (in this repo) for how each
one publishes on a push to `packaging/<name>/**`.
## Local build
```bash
makepkg -si
```
## Before publishing to [breadway] repo
1. Tag a release on GitHub.
2. Update `pkgver` 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 | snapshot management view |
Every bread-ecosystem app (bakery, bread, breadbar, breadbox, breadcrumbs,
breadpad, breadpaper, breadmon, breadsearch, breadclip, breadshot,
bos-settings, breadhelp, ...) is bakery-managed, not pacman-packaged — see
`iso/bread-lockfile.toml` (`required_bins` + `optional_bins`), which
`build-local.sh` uses as the name list when baking this machine's bakery
install into the ISO's `/etc/skel`.
`breadlock` is the sole deliberate exception (it needs a root-owned
`/etc/pam.d/breadlock` PAM service file, which bakery — by design — has no
privileged-install path for) and stays on pacman only; see
`bread-ecosystem/docs/release-channels.md` for the full policy.

View file

@ -16,7 +16,13 @@ options=('!strip')
source=("${pkgname%-bin}-$pkgver.tar.xz::$url/releases/download/v$pkgver/Bibata.tar.xz")
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() {
install -d "$pkgdir/usr/share/icons"
cp -r Bibata* "$pkgdir/usr/share/icons"
for v in "${_variants[@]}"; do
cp -r "$v" "$pkgdir/usr/share/icons/"
done
}

View file

@ -1,4 +1,4 @@
# Maintainer: Breadway <rileyhorsham@gmail.com>
# Maintainer: Breadway <plasticbread849@gmail.com>
# In-house copy of the AUR calamares PKGBUILD (Calamares is AUR-only; not in
# Arch's official repos). Built by CI and published to the [breadway] repo.
# Source of truth: https://aur.archlinux.org/packages/calamares

View file

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

@ -0,0 +1,40 @@
# BOS in-house rebuild of yay-bin (AUR-only upstream — no AUR helper is in
# the official Arch repos, including yay itself). Republished to the
# [breadway] repo so the ISO build can pull it via pacman (same pattern as
# bibata-cursor-theme and calamares). Prebuilt release tarball — no build step.
# Upstream maintainer: Jguer <pkgbuilds at jguer.space>
pkgname=yay-bin
pkgver=13.0.1
pkgrel=1
pkgdesc="Yet another yogurt. Pacman wrapper and AUR helper written in go. Pre-compiled."
arch=('x86_64')
url="https://github.com/Jguer/yay"
license=('GPL-3.0-or-later')
depends=(
'pacman>6.1'
'git'
)
optdepends=(
'sudo: privilege elevation'
'doas: privilege elevation'
)
provides=('yay')
conflicts=('yay')
source=("https://github.com/Jguer/yay/releases/download/v${pkgver}/${pkgname/-bin/}_${pkgver}_x86_64.tar.gz")
sha256sums=('1fdfcb5f7f387bc858d3a5754bdf4e4575bfbddac9560535a716d0ed7189c057')
package() {
_output="${srcdir}/${pkgname/-bin/}_${pkgver}_${CARCH}"
install -Dm755 "${_output}/${pkgname/-bin/}" "${pkgdir}/usr/bin/${pkgname/-bin/}"
install -Dm644 "${_output}/yay.8" "${pkgdir}/usr/share/man/man8/yay.8"
install -Dm644 "${_output}/bash" "${pkgdir}/usr/share/bash-completion/completions/yay"
install -Dm644 "${_output}/zsh" "${pkgdir}/usr/share/zsh/site-functions/_yay"
install -Dm644 "${_output}/fish" "${pkgdir}/usr/share/fish/vendor_completions.d/yay.fish"
LANGS="ca cs de en es eu fr_FR he id it_IT ja ko pl_PL pt_BR pt ru_RU ru sv tr uk zh_CN zh_TW"
for lang in ${LANGS}; do
install -Dm644 "${_output}/${lang}.mo" "${pkgdir}/usr/share/locale/${lang}/LC_MESSAGES/yay.mo"
done
}

308
scripts/ci-publish-signed-repo.sh Executable file
View file

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

497
scripts/ci-stage-bakery.py Executable file
View file

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

231
scripts/ci-verify-bake.sh Executable file
View file

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

Some files were not shown because too many files have changed in this diff Show more