Compare commits

...

7 commits

Author SHA1 Message Date
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
35 changed files with 2843 additions and 318 deletions

View file

@ -7,8 +7,13 @@ name: Build and release ISO
# (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)
# RELEASE_TOKEN — Forgejo API token with write:repository scope
# MIRROR_TOKEN — GitHub personal access token with repo scope (already used by mirror.yml)
# GPG_PRIVATE_KEY — armoured secret key for the dedicated "BOS Release Signing"
# identity (releases@breadway.dev); public half is committed
# at KEYS.asc for verification. No passphrase (CI-only key,
# access controlled via the Forgejo secret store, not a
# passphrase nobody could type non-interactively anyway).
on:
push:
@ -129,14 +134,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 +183,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 \
-H "Authorization: token ${FORGEJO_TOKEN}" \
"http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets" \
| python3 -c "
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
curl -fsS -X DELETE \
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 ${name} asset"
fi
curl -fsS -X POST \
-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"
fi
-F "attachment=@${file};filename=${name}" \
"http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets"
echo "Uploaded: ${name}"
}
curl -fsS -X POST \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-F "attachment=@${ISO};filename=${ISO_NAME}" \
"http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets"
echo "Uploaded: ${ISO_NAME}"
upload_asset "/bos-out/${ISO_NAME}"
upload_asset "/bos-out/SHA256SUMS"
upload_asset "/bos-out/SHA256SUMS.asc"
- name: Create GitHub release
env:
@ -196,7 +228,7 @@ 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}" \

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"

4
Cargo.lock generated
View file

@ -28,7 +28,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "bos-settings"
version = "0.4.1"
version = "0.6.0"
dependencies = [
"async-channel",
"bread-theme",
@ -43,7 +43,7 @@ dependencies = [
[[package]]
name = "bread-theme"
version = "0.2.3"
source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.8#77417d552130281ff787e07d52541eb25e9d533b"
source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.10#17d1bb85801b9a8c195b64c02d288cd662c9c780"
dependencies = [
"dirs",
"gtk4",

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-----

100
README.md
View file

@ -20,17 +20,23 @@ wiring up dotfiles, no per-tool bakery installs.
- **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.
- **Login**: greetd + breadgreet (bread-ecosystem's own greeter, 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's bread ecosystem + `[breadway]`.
- **Hardware**: pipewire audio, NetworkManager, BlueZ + blueman, CUPS printing
with avahi mDNS discovery, TLP power management, fwupd firmware updates.
- **Resilience**: btrfs + snapper + snap-pac + grub-btrfs snapshots on every
pacman transaction; zram swap; ufw firewall (deny-incoming, mDNS allowed).
- **Security**: full-disk encryption (LUKS, via Calamares' built-in support —
cryptsetup + the matching mkinitcpio/GRUB wiring ship so an encrypted
install actually boots) and self-signed Secure Boot (via `sbctl`, enrolled
automatically at install time when the firmware is in Setup Mode).
## Repo layout
@ -73,11 +79,30 @@ to the Tailscale-reachable Forgejo registry for the build.
### 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).
`bos-settings` itself publishes the same way on a `v*` tag.
### 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). To verify a download:
```sh
gpg --import KEYS.asc
gpg --verify SHA256SUMS.asc SHA256SUMS
sha256sum -c SHA256SUMS
```
## Testing in a VM
@ -95,21 +120,40 @@ avoid memory pressure.
## bos-settings
`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:
A GTK4 settings app aiming for GNOME-Settings-style parity: not just editing
config files, but live system state and control, so day-to-day machine
administration doesn't require a terminal.
| 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 |
Bread-ecosystem TOML configs are edited **non-destructively**: `toml_edit`
parses the file, 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). Panels with a daemon behind them
(bread, breadbox, breadcrumbs, breadsearch, breadclip) also get live
systemd status + Start/Stop/Restart/Logs via a shared `service_control`
widget, not just the config file.
| Panel | What it does |
|-------|--------------|
| 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 / boot-into (grub-btrfs) / delete |
Build standalone:
@ -166,9 +210,19 @@ cheatsheet in-session; first boot shows a short welcome (once).
`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.

View file

@ -1,6 +1,6 @@
[package]
name = "bos-settings"
version = "0.4.1"
version = "0.6.0"
edition = "2021"
[dependencies]
@ -8,7 +8,7 @@ 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"] }
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"

View file

@ -14,6 +14,32 @@ use std::cell::RefCell;
const APP_CSS: &str = "\
.view-content { padding: 24px; }\n\
.view-content > label.title { margin-bottom: 16px; }\n\
/* Sidebar row sub-labels (the underlying binary/config name under a row's \
human label) smaller than the shared sheet's default dim-label size. */\n\
.caption { font-size: 11px; }\n\
/* bread-theme's shared sheet only overrides background-color on \
suggested/destructive buttons, not background-image so Adwaita's \
built-in gradient (bright blue/red) paints over our flat colour \
underneath it. Belongs upstream in bread-theme; patched locally here \
until that's worth its own release. */\n\
button.suggested-action, button.destructive-action { background-image: none; }\n\
/* Same upstream gap for scale (Sound's volume sliders) — the shared sheet \
has no `scale` rules at all, so they render in Adwaita's default blue. */\n\
scale trough { background-color: alpha(@on-surface, 0.15); border-radius: 999px; min-height: 6px; background-image: none; }\n\
scale highlight { background-color: @accent; background-image: none; border-radius: 999px; }\n\
scale slider { background-color: @on-surface; border-radius: 999px; }\n\
/* Destructive actions must not follow the wallpaper palette: @red is \
pywal's color1, which can land on gold/yellow/anything depending on the \
wallpaper (it did, this session) making Delete/Remove look like a \
primary action instead of a dangerous one. Fixed regardless of palette. */\n\
button.destructive-action { background-color: #c0392b; color: #ffffff; }\n\
button.destructive-action:hover { background-color: #d64535; }\n\
/* Adwaita's default switch slider (the knob) carries a box-shadow used for \
its 3D bevel look bread-theme's override only sets background-color, \
so that shadow still renders as a pale ring around the knob on top of \
our flat colour. */\n\
switch slider { box-shadow: none; outline: none; border: none; background-image: none; }\n\
switch { box-shadow: none; outline: none; border: none; background-image: none; }\n\
";
thread_local! {

View file

@ -1,45 +1,89 @@
use gtk4::prelude::*;
use gtk4::{Box as GBox, Label, ListBox, ListBoxRow, Orientation};
use gtk4::{Box as GBox, Image, Label, ListBox, ListBoxRow, Orientation};
pub struct SidebarItem {
/// Must match the `Stack` page name registered in `window.rs`.
pub id: &'static str,
pub label: &'static str,
/// Dim second line — the underlying binary/config name, for items whose
/// human label doesn't already make that obvious.
pub sublabel: Option<&'static str>,
/// A `-symbolic` icon name from the system icon theme (Papirus-Dark ships
/// the full Adwaita-compatible symbolic set this app relies on).
pub icon: &'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" },
];
const fn item(id: &'static str, label: &'static str, icon: &'static str) -> SidebarItem {
SidebarItem { id, label, sublabel: None, icon }
}
const fn item_sub(
id: &'static str,
label: &'static str,
sublabel: &'static str,
icon: &'static str,
) -> SidebarItem {
SidebarItem { id, label, sublabel: Some(sublabel), icon }
}
// Grouped by task, not by "app vs system internals" — a user thinks "I want
// to change my Wi-Fi" or "I want to change my wallpaper", not "which of
// these is a bread-ecosystem app". breadcrumbs (Wi-Fi profiles) moves out of
// the old "Apps" bucket into System for the same reason.
pub const SYSTEM_ITEMS: &[SidebarItem] = &[
SidebarItem { id: "snapshots", label: "Snapshots" },
SidebarItem { id: "packages", label: "Packages" },
SidebarItem { id: "hyprland", label: "Hyprland" },
item("network", "Network", "network-wireless-symbolic"),
item_sub("breadcrumbs", "Wi-Fi Profiles", "breadcrumbs", "network-workgroup-symbolic"),
item("firewall", "Firewall", "security-high-symbolic"),
item("sound", "Sound", "audio-volume-high-symbolic"),
item("power", "Power", "battery-good-symbolic"),
item("datetime", "Date & Time", "preferences-system-time-symbolic"),
item_sub("hyprland", "Display", "hyprland.lua", "video-display-symbolic"),
item("users", "Users", "system-users-symbolic"),
];
pub fn build() -> (GBox, ListBox) {
pub const PERSONALIZATION_ITEMS: &[SidebarItem] = &[
item_sub("breadpaper", "Wallpaper", "breadpaper", "preferences-desktop-wallpaper-symbolic"),
item_sub("breadbar", "Bar", "breadbar", "view-grid-symbolic"),
item_sub("breadbox", "Launcher", "breadbox", "view-app-grid-symbolic"),
item_sub("breadclip", "Clipboard", "breadclipd", "edit-paste-symbolic"),
item_sub("breadpad", "Notes", "breadpad", "text-editor-symbolic"),
item_sub("breadsearch", "File Search", "breadsearch", "system-search-symbolic"),
item_sub("bread", "Daemon", "breadd", "applications-system-symbolic"),
];
pub const MAINTENANCE_ITEMS: &[SidebarItem] = &[
item("packages", "Packages", "package-x-generic-symbolic"),
item("aur", "AUR", "system-search-symbolic"),
item("firmware", "Firmware", "software-update-available-symbolic"),
item("snapshots", "Snapshots", "document-open-recent-symbolic"),
];
pub const ABOUT_ITEMS: &[SidebarItem] = &[item("about", "About", "help-about-symbolic")];
/// `default_id` must match whatever page `window.rs` sets as the `Stack`'s
/// initial visible child — previously these were two independent hardcoded
/// "about" literals in different files with no link between them, so
/// changing one without the other silently desynced the sidebar highlight
/// from the actually-displayed page.
pub fn build(default_id: &str) -> (GBox, ListBox) {
let vbox = GBox::new(Orientation::Vertical, 0);
vbox.add_css_class("sidebar");
vbox.set_width_request(190);
vbox.set_width_request(210);
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);
append_section(&list, "Personalization", PERSONALIZATION_ITEMS);
append_section(&list, "Maintenance", MAINTENANCE_ITEMS);
append_section(&list, None, ABOUT_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" => {
Some(row) if row.widget_name() == default_id => {
list.select_row(Some(&row));
break;
}
@ -51,24 +95,47 @@ pub fn build() -> (GBox, ListBox) {
(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);
fn append_section(list: &ListBox, title: impl Into<Option<&'static str>>, items: &[SidebarItem]) {
if let Some(title) = title.into() {
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 {
for entry in items {
let row = ListBoxRow::new();
row.set_widget_name(item.id);
let lbl = Label::new(Some(item.label));
row.set_widget_name(entry.id);
let hbox = GBox::new(Orientation::Horizontal, 10);
hbox.set_margin_top(4);
hbox.set_margin_bottom(4);
let icon = Image::from_icon_name(entry.icon);
icon.set_pixel_size(16);
hbox.append(&icon);
let labels = GBox::new(Orientation::Vertical, 0);
let lbl = Label::new(Some(entry.label));
lbl.set_xalign(0.0);
lbl.set_margin_top(2);
lbl.set_margin_bottom(2);
row.set_child(Some(&lbl));
labels.append(&lbl);
if let Some(sub) = entry.sublabel {
let sub_lbl = Label::new(Some(sub));
sub_lbl.add_css_class("dim-label");
sub_lbl.set_xalign(0.0);
// Match the sidebar's smaller "section-header" scale rather than
// the shared sheet's default dim-label size, so it reads as a
// caption under the row label, not a second full-size label.
sub_lbl.add_css_class("caption");
labels.append(&sub_lbl);
}
hbox.append(&labels);
row.set_child(Some(&hbox));
list.append(&row);
}
}

View file

@ -0,0 +1,172 @@
//! Read-only system info, plus the one thing worth making writable: hostname.
//! BOS is a rolling release (no fixed version number to show — `os-release`
//! ships `BUILD_ID=rolling` on purpose), so there's no "BOS 1.2.3" readout
//! here the way a point-release distro's About panel would have one.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Entry, Label, Orientation};
use std::fs;
use std::process::Command;
use crate::ui::widgets as w;
fn os_pretty_name() -> String {
fs::read_to_string("/etc/os-release")
.ok()
.and_then(|s| {
s.lines()
.find_map(|l| l.strip_prefix("PRETTY_NAME=").map(|v| v.trim_matches('"').to_string()))
})
.unwrap_or_else(|| "BOS".to_string())
}
fn hostname() -> String {
fs::read_to_string("/etc/hostname")
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "unknown".to_string())
}
fn kernel() -> String {
Command::new("uname")
.arg("-r")
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
fn cpu() -> String {
let model = fs::read_to_string("/proc/cpuinfo")
.ok()
.and_then(|s| {
s.lines()
.find_map(|l| l.strip_prefix("model name").map(|v| v.trim_start_matches([':', ' ', '\t']).to_string()))
})
.unwrap_or_else(|| "unknown".to_string());
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
if cores > 0 {
format!("{model} ({cores} threads)")
} else {
model
}
}
fn memory() -> String {
let kb = fs::read_to_string("/proc/meminfo")
.ok()
.and_then(|s| {
s.lines()
.find(|l| l.starts_with("MemTotal:"))
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|v| v.parse::<u64>().ok())
});
match kb {
Some(kb) => format!("{:.1} GiB", kb as f64 / 1024.0 / 1024.0),
None => "unknown".to_string(),
}
}
fn gpu() -> String {
let Ok(output) = Command::new("lspci").output() else {
return "unknown".to_string();
};
let text = String::from_utf8_lossy(&output.stdout);
text.lines()
// "Display controller" covers integrated GPUs some laptop chipsets
// (this dev laptop's AMD Radeon 860M included) report under instead
// of "VGA compatible controller" — without it those show "unknown".
.find(|l| {
l.contains("VGA compatible controller")
|| l.contains("3D controller")
|| l.contains("Display controller")
})
.and_then(|l| l.split(": ").nth(1))
.unwrap_or("unknown")
.to_string()
}
fn disk_usage() -> String {
let Ok(output) = Command::new("df").args(["-h", "--output=used,size,pcent", "/"]).output() else {
return "unknown".to_string();
};
let text = String::from_utf8_lossy(&output.stdout);
text.lines()
.nth(1)
.map(|l| {
let cols: Vec<&str> = l.split_whitespace().collect();
match cols.as_slice() {
[used, size, pcent] => format!("{used} of {size} used ({pcent})"),
_ => l.trim().to_string(),
}
})
.unwrap_or_else(|| "unknown".to_string())
}
fn uptime() -> String {
Command::new("uptime")
.arg("-p")
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("About");
content.append(&w::info_row("Operating system", &os_pretty_name()));
content.append(&w::info_row("Kernel", &kernel()));
content.append(&w::info_row("CPU", &cpu()));
content.append(&w::info_row("GPU", &gpu()));
content.append(&w::info_row("Memory", &memory()));
content.append(&w::info_row("Disk (/)", &disk_usage()));
content.append(&w::info_row("Uptime", &uptime()));
content.append(&w::section("Hostname"));
content.append(&w::hint(
"Changes the machine's network name. Takes effect immediately; \
needs your password (polkit).",
));
let hn_row = GBox::new(Orientation::Horizontal, 12);
let entry = Entry::new();
entry.set_text(&hostname());
entry.set_hexpand(true);
let apply_btn = Button::with_label("Apply");
let status = Label::new(None);
status.add_css_class("dim-label");
{
let entry = entry.clone();
let status = status.clone();
apply_btn.connect_clicked(move |_| {
let name = entry.text().to_string();
if name.trim().is_empty() {
status.set_text("Hostname can't be empty");
return;
}
let log_buf = gtk4::TextBuffer::new(None);
let status2 = status.clone();
status.set_text("Applying…");
w::stream_command_then(
&["pkexec", "hostnamectl", "set-hostname", name.trim()],
log_buf.clone(),
move || {
let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false);
if text.trim().is_empty() {
status2.set_text("Applied");
} else {
status2.set_text(&format!("Error: {}", text.trim()));
}
},
);
});
}
hn_row.append(&entry);
hn_row.append(&apply_btn);
content.append(&hn_row);
content.append(&status);
outer
}

View file

@ -0,0 +1,169 @@
//! AUR search via yay — graphical discovery for the wider AUR beyond
//! bakery's bread ecosystem and [breadway]'s own republished packages.
//!
//! Search and browsing are fully graphical; actually installing a package
//! opens a terminal running `yay -S <pkg>` instead of a silent `--noconfirm`
//! install. That's deliberate, not a shortcut we didn't get around to:
//! AUR packages run arbitrary maintainer-supplied build scripts, and yay's
//! interactive PKGBUILD diff review (plus the sudo password prompt) is the
//! actual safety mechanism against a malicious/compromised AUR package —
//! automating it away in the name of "no terminal" would remove the one
//! step that exists to catch that.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow};
use std::process::Command;
use crate::ui::widgets as w;
#[derive(Clone)]
struct AurResult {
name: String,
version: String,
description: String,
}
fn search(query: &str) -> Vec<AurResult> {
let Ok(output) = Command::new("yay").args(["-Ss", "--aur", query]).output() else {
return Vec::new();
};
let text = String::from_utf8_lossy(&output.stdout);
let mut results = Vec::new();
let mut lines = text.lines().peekable();
while let Some(header) = lines.next() {
// "aur/name version (+votes score) [Orphaned]" — name/version are
// always the first two whitespace-separated fields after the repo/.
let Some(rest) = header.strip_prefix("aur/") else { continue };
let mut parts = rest.split_whitespace();
let Some(name) = parts.next() else { continue };
let version = parts.next().unwrap_or("").to_string();
let description = lines.next().unwrap_or("").trim().to_string();
results.push(AurResult { name: name.to_string(), version, description });
}
results
}
fn install_in_terminal(pkg: &str) {
let _ = Command::new("kitty").args(["-e", "yay", "-S", pkg]).spawn();
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("AUR");
content.append(&w::hint(
"Search the Arch User Repository via yay. Installing opens a \
terminal AUR packages run arbitrary build scripts, and reviewing \
what yay is about to do (and entering your password) is a real \
safety step, not just a formality.",
));
let search_row = GBox::new(Orientation::Horizontal, 8);
let search_entry = Entry::new();
search_entry.set_hexpand(true);
search_entry.set_placeholder_text(Some("Search the AUR…"));
let search_btn = Button::with_label("Search");
search_btn.add_css_class("suggested-action");
search_row.append(&search_entry);
search_row.append(&search_btn);
content.append(&search_row);
let status = w::hint("Search for a package to see results here.");
content.append(&status);
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_min_content_height(320);
scroll.set_child(Some(&list));
content.append(&scroll);
let run_search = {
let list = list.clone();
let status = status.clone();
let search_entry = search_entry.clone();
move |btn: Option<Button>| {
let query = search_entry.text().to_string();
if query.trim().is_empty() {
return;
}
if let Some(b) = &btn {
b.set_sensitive(false);
}
status.set_text("Searching…");
let (tx, rx) = async_channel::bounded::<Vec<AurResult>>(1);
std::thread::spawn(move || {
let _ = tx.send_blocking(search(&query));
});
let list = list.clone();
let status = status.clone();
let btn = btn.clone();
glib::spawn_future_local(async move {
if let Ok(results) = rx.recv().await {
while let Some(child) = list.first_child() {
list.remove(&child);
}
if results.is_empty() {
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"system-search-symbolic",
"No results",
"Try a different search term.",
)));
list.append(&row);
status.set_text("No results.");
} else {
status.set_text(&format!("{} result(s)", results.len()));
for r in results.into_iter().take(50) {
let row = ListBoxRow::new();
row.set_selectable(false);
let vbox = GBox::new(Orientation::Vertical, 2);
vbox.add_css_class("card");
vbox.set_margin_top(3);
vbox.set_margin_bottom(3);
let top = GBox::new(Orientation::Horizontal, 12);
let name_lbl = Label::new(Some(&format!("{} {}", r.name, r.version)));
name_lbl.set_hexpand(true);
name_lbl.set_xalign(0.0);
let install_btn = Button::with_label("Install");
{
let pkg = r.name.clone();
install_btn.connect_clicked(move |_| install_in_terminal(&pkg));
}
top.append(&name_lbl);
top.append(&install_btn);
vbox.append(&top);
let desc_lbl = Label::new(Some(&r.description));
desc_lbl.add_css_class("dim-label");
desc_lbl.set_xalign(0.0);
desc_lbl.set_wrap(true);
desc_lbl.set_max_width_chars(64);
vbox.append(&desc_lbl);
row.set_child(Some(&vbox));
list.append(&row);
}
}
}
if let Some(b) = &btn {
b.set_sensitive(true);
}
});
}
};
{
let run_search = run_search.clone();
search_btn.connect_clicked(move |btn| run_search(Some(btn.clone())));
}
{
let run_search = run_search.clone();
search_entry.connect_activate(move |_| run_search(None));
}
outer
}

View file

@ -19,7 +19,9 @@ 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");
let (outer, c) = w::view_scaffold("Daemon");
c.append(&w::service_control("breadd.service", true, true));
c.append(&w::section("Daemon"));
c.append(&w::dropdown_row(

View file

@ -2,6 +2,8 @@ use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Label, Orientation, ScrolledWindow, TextView};
use std::path::PathBuf;
use crate::ui::widgets as w;
fn css_path() -> PathBuf {
crate::config::config_dir().join("breadbar/style.css")
}
@ -10,37 +12,42 @@ 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.",
let (outer, content) = w::view_scaffold("Bar");
content.append(&w::hint(
"CSS overrides for breadbar. Leave empty to use the default bread theme. \
Reloads live on save no need to restart the bar.",
));
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);
text_view.set_top_margin(12);
text_view.set_bottom_margin(12);
text_view.set_left_margin(12);
text_view.set_right_margin(12);
// A borderless full-bleed textview reads as a rendering bug, not an
// editor — give it the same card framing every other panel's content
// gets, and cap its height so it doesn't compete with the button row
// for the one screen's worth of space.
let editor_card = GBox::new(Orientation::Vertical, 0);
editor_card.add_css_class("card");
editor_card.set_vexpand(true);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_min_content_height(360);
scroll.set_child(Some(&text_view));
vbox.append(&scroll);
editor_card.append(&scroll);
content.append(&editor_card);
let btn_row = GBox::new(Orientation::Horizontal, 12);
btn_row.set_margin_top(12);
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");
@ -55,7 +62,18 @@ pub fn build() -> GBox {
}
match std::fs::write(&path, text.as_str()) {
Ok(()) => {
status_lbl.set_text("Saved");
// breadbar has no systemd unit (it's launched directly by
// hyprland.lua's exec-once) — SIGHUP is its own documented
// live-reload mechanism (see this file's own header
// comment: "reload live: kill -HUP $(pidof breadbar)").
// -x pkill exits non-zero if breadbar isn't running,
// which is fine — the file's still saved either way.
let reloaded = std::process::Command::new("pkill")
.args(["-HUP", "-x", "breadbar"])
.status()
.map(|s| s.success())
.unwrap_or(false);
status_lbl.set_text(if reloaded { "Saved & reloaded" } else { "Saved" });
let lbl = status_lbl.clone();
glib::timeout_add_seconds_local(3, move || {
lbl.set_text("");
@ -69,7 +87,7 @@ pub fn build() -> GBox {
btn_row.append(&save_btn);
btn_row.append(&status_lbl);
vbox.append(&btn_row);
outer.append(&btn_row);
vbox
outer
}

View file

@ -15,6 +15,7 @@ use gtk4::{
use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
use crate::config;
use crate::ui::widgets as w;
#[derive(Clone, Default)]
struct Context {
@ -62,6 +63,17 @@ fn rebuild_list(list: &ListBox, model: &Rc<RefCell<Vec<Context>>>) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
if model.borrow().is_empty() {
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"view-app-grid-symbolic",
"No launcher contexts yet",
"Add one to control which apps/categories breadbox surfaces first.",
)));
list.append(&row);
return;
}
for (i, ctx) in model.borrow().iter().enumerate() {
let row = ListBoxRow::new();
row.set_selectable(false);
@ -128,21 +140,13 @@ pub fn build() -> GBox {
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 (outer, content) = w::view_scaffold("Launcher");
content.append(&w::service_control("breadbox-sync.service", false, true));
let title = Label::new(Some("breadbox"));
title.add_css_class("title");
title.set_xalign(0.0);
vbox.append(&title);
let subtitle = Label::new(Some(
content.append(&w::section("Contexts"));
content.append(&w::hint(
"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);
@ -151,12 +155,14 @@ pub fn build() -> GBox {
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);
content.append(&scroll);
// Add-row button lives with the list it adds to, halign Start like
// breadcrumbs' "Add network"/"Add profile" — Save stays alone in its
// own row at the bottom, consistent with every other panel.
let add_btn = Button::with_label("Add context");
add_btn.set_halign(gtk4::Align::Start);
add_btn.set_margin_top(8);
{
let model = model.clone();
let list = list.clone();
@ -168,12 +174,14 @@ pub fn build() -> GBox {
rebuild_list(&list, &model);
});
}
content.append(&add_btn);
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_lbl = Label::new(None);
status_lbl.add_css_class("dim-label");
{
let doc = doc.clone();
let model = model.clone();
@ -194,11 +202,9 @@ pub fn build() -> GBox {
}
});
}
btn_row.append(&add_btn);
btn_row.append(&save_btn);
btn_row.append(&status_lbl);
vbox.append(&btn_row);
outer.append(&btn_row);
vbox
outer
}

View file

@ -0,0 +1,33 @@
//! breadclip — clipboard history popup + its background daemon (breadclipd).
//! No config file to edit: breadclip takes no persistent settings. This
//! panel exists purely so the daemon backing it is visible/controllable
//! from bos-settings instead of only from a terminal — previously breadclip
//! had no presence in Settings at all despite running its own service.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button};
use crate::ui::widgets as w;
pub fn build() -> GBox {
let (outer, c) = w::view_scaffold("Clipboard");
c.append(&w::hint(
"Keeps a history of copied text/images and shows it as a popup. \
breadclipd (below) is the background daemon that watches the \
clipboard breadclip itself is just the popup UI, launched on \
demand by the keybind or the button below.",
));
let open_btn = Button::with_label("Open history (SUPER+V)");
open_btn.add_css_class("suggested-action");
open_btn.set_halign(gtk4::Align::Start);
open_btn.connect_clicked(|_| {
let _ = std::process::Command::new("breadclip").spawn();
});
c.append(&open_btn);
c.append(&w::service_control("breadclipd.service", false, false));
outer
}

View file

@ -11,7 +11,7 @@ use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::{
Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch,
Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, Switch,
};
use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
@ -334,20 +334,8 @@ pub fn build() -> GBox {
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);
let (outer, content) = w::view_scaffold("Wi-Fi Profiles");
content.append(&w::service_control("breadcrumbs.service", false, true));
// [settings] — edited in place on the shared doc
content.append(&w::section("Settings"));

View file

@ -20,7 +20,7 @@ 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");
let (outer, c) = w::view_scaffold("Notes");
c.append(&w::section("Capture"));
c.append(&w::dropdown_row(

View file

@ -32,18 +32,21 @@ fn current_wallpaper() -> Option<PathBuf> {
fn refresh_preview(preview: &Image, path_lbl: &Label) {
match current_wallpaper() {
Some(path) => {
path_lbl.set_text(&path.display().to_string());
let filename = path.file_name().map(|f| f.to_string_lossy().to_string());
path_lbl.set_text(filename.as_deref().unwrap_or("(unknown filename)"));
path_lbl.set_tooltip_text(Some(&path.display().to_string()));
preview.set_from_file(Some(&path));
}
None => {
path_lbl.set_text("No wallpaper set");
path_lbl.set_tooltip_text(None);
preview.set_icon_name(Some("image-missing"));
}
}
}
pub fn build() -> GBox {
let (outer, c) = w::view_scaffold("breadpaper");
let (outer, c) = w::view_scaffold("Wallpaper");
c.append(&w::hint(
"Sets the desktop wallpaper, generates a matching pywal palette, and \
@ -51,23 +54,30 @@ pub fn build() -> GBox {
the whole desktop's accent colors.",
));
let preview_card = GBox::new(Orientation::Vertical, 8);
preview_card.add_css_class("card");
preview_card.set_halign(gtk4::Align::Center);
preview_card.set_margin_top(8);
preview_card.set_margin_bottom(8);
let preview = Image::new();
preview.set_pixel_size(320);
preview.set_margin_top(8);
preview.set_margin_bottom(8);
c.append(&preview);
preview_card.append(&preview);
let path_lbl = Label::new(None);
path_lbl.set_wrap(true);
path_lbl.add_css_class("dim-label");
c.append(&path_lbl);
preview_card.append(&path_lbl);
c.append(&preview_card);
refresh_preview(&preview, &path_lbl);
let btn_row = GBox::new(Orientation::Horizontal, 8);
btn_row.set_margin_top(8);
btn_row.set_halign(gtk4::Align::Center);
let choose_btn = Button::with_label("Choose image...");
choose_btn.add_css_class("suggested-action");
let status = Label::new(None);
status.add_css_class("dim-label");

View file

@ -18,7 +18,8 @@ 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");
let (outer, c) = w::view_scaffold("File Search");
c.append(&w::service_control("breadmill.service", false, true));
c.append(&w::section("Power"));
c.append(&w::hint(
@ -34,22 +35,24 @@ pub fn build() -> GBox {
));
c.append(&w::section("Model"));
// breadsearch v0.2.1+ publishes breadmill built with --features full
// (npu + rocm + cuda all in one binary, ort load-dynamic/dlopen — no
// separate build needed per backend). Selecting a GPU/NPU backend here
// only takes effect if the matching ONNX Runtime is actually present on
// this machine; breadmill logs which EP really registered at startup.
// breadsearch v0.2.3+ publishes breadmill built with --features full
// (npu + rocm + cuda + openvino all in one binary, ort load-dynamic/
// dlopen — no separate build needed per backend). Selecting a GPU/NPU
// backend here only takes effect if the matching ONNX Runtime is
// actually present on this machine; breadmill logs which EP really
// registered at startup.
c.append(&w::dropdown_row(
"Compute backend",
&doc,
&["model", "backend"],
&["cpu", "npu", "rocm", "cuda"],
&["cpu", "npu", "rocm", "cuda", "openvino"],
"cpu",
));
c.append(&w::hint(
"npu = AMD Ryzen AI (XDNA), rocm = AMD GPU (via MIGraphX), cuda = \
NVIDIA GPU. Each needs the matching ONNX Runtime installed and \
visible to breadmill (system package, or ORT_DYLIB_PATH) check \
NVIDIA GPU, openvino = Intel iGPU/Arc GPU. Each needs the matching \
ONNX Runtime installed and visible to breadmill (system package, \
or ORT_DYLIB_PATH) check \
`journalctl --user -u breadmill` after restarting for a \
\"Successfully registered\" line confirming it actually took. \
ROCm additionally needs ORT_MIGRAPHX_MODEL_CACHE_PATH set (bakery's \
@ -107,10 +110,6 @@ pub fn build() -> GBox {
200,
));
c.append(&w::hint(
"Changes take effect after: systemctl --user restart breadmill",
));
outer.append(&w::save_button(&doc, path));
outer
}

View file

@ -0,0 +1,107 @@
//! Timezone + NTP, over `timedatectl`. `systemd-timesyncd` is enabled by
//! default (see `post-install.sh`), so NTP sync is on out of the box — this
//! panel is mostly for picking a timezone and confirming sync is healthy.
use gtk4::prelude::*;
use gtk4::{Box as GBox, DropDown, Expression, Label, StringList, Switch};
use std::process::Command;
use crate::ui::widgets as w;
fn show_property(prop: &str) -> String {
Command::new("timedatectl")
.args(["show", &format!("--property={prop}")])
.output()
.ok()
.and_then(|o| {
String::from_utf8_lossy(&o.stdout)
.trim()
.strip_prefix(&format!("{prop}="))
.map(str::to_string)
})
.unwrap_or_default()
}
fn list_timezones() -> Vec<String> {
Command::new("timedatectl")
.arg("list-timezones")
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).lines().map(str::to_string).collect())
.unwrap_or_default()
}
fn current_time_label() -> String {
Command::new("date")
.arg("+%A, %d %B %Y %H:%M")
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default()
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Date & Time");
content.append(&w::info_row("Current time", &current_time_label()));
content.append(&w::section("Timezone"));
let zones = list_timezones();
let current_tz = show_property("Timezone");
if zones.is_empty() {
content.append(&w::hint("Couldn't list timezones (is timedatectl available?)."));
} else {
let labels: Vec<&str> = zones.iter().map(String::as_str).collect();
let model = StringList::new(&labels);
let dd = DropDown::new(Some(model), Expression::NONE);
let sel = zones.iter().position(|z| *z == current_tz).unwrap_or(0);
dd.set_selected(sel as u32);
let status = Label::new(None);
status.add_css_class("dim-label");
{
let zones = zones.clone();
let status = status.clone();
dd.connect_selected_notify(move |dd| {
let Some(tz) = zones.get(dd.selected() as usize) else { return };
status.set_text("Applying…");
let log_buf = gtk4::TextBuffer::new(None);
let status2 = status.clone();
w::stream_command_then(
&["pkexec", "timedatectl", "set-timezone", tz],
log_buf.clone(),
move || {
let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false);
status2.set_text(if text.trim().is_empty() {
"Applied"
} else {
"Error — check the timezone name"
});
},
);
});
}
content.append(&w::row("Timezone", &dd));
content.append(&status);
}
content.append(&w::section("Network time"));
let ntp_sw = Switch::new();
ntp_sw.set_active(show_property("NTP") == "yes");
ntp_sw.connect_active_notify(|s| {
let val = if s.is_active() { "true" } else { "false" };
let _ = Command::new("pkexec").args(["timedatectl", "set-ntp", val]).spawn();
});
content.append(&w::row("Synchronize automatically", &ntp_sw));
let synced = show_property("NTPSynchronized") == "yes";
content.append(&w::hint(if synced {
"Synchronized."
} else {
"Not synchronized yet (needs network)."
}));
outer
}

View file

@ -0,0 +1,246 @@
//! ufw firewall rules — BOS enables ufw by default (deny incoming, allow
//! outgoing, mDNS allowed for printer discovery; see post-install.sh) but
//! previously offered no graphical way to add/remove a rule for something
//! like a local dev server or a LAN game, only a terminal.
//!
//! `ufw status` itself requires root — confirmed against the installed
//! ufw script, which exits with "ERROR: You need to be root to run this
//! script" for a plain status check, not just for changes. Every other
//! panel's read-only state is free to query eagerly in `build()`, but doing
//! that here would mean a polkit password prompt on every single
//! bos-settings launch (every view is constructed immediately at startup —
//! see window.rs). So this panel starts blank and loads state only when the
//! user clicks Refresh, deferring the one unavoidable prompt to an explicit
//! action instead of forcing it on app open.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch};
use std::cell::Cell;
use std::rc::Rc;
use crate::ui::widgets as w;
#[derive(Clone)]
struct Rule {
number: String,
text: String,
}
struct Status {
active: bool,
rules: Vec<Rule>,
}
/// One `pkexec ufw status numbered` call, parsed for both the active/inactive
/// line and the numbered rules — a single privileged read instead of two.
fn fetch_status() -> Option<Status> {
let output = std::process::Command::new("pkexec")
.args(["ufw", "status", "numbered"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let active = text.lines().next().is_some_and(|l| l.trim() == "Status: active");
let rules = text
.lines()
.filter_map(|l| {
let l = l.trim_start();
if !l.starts_with('[') {
return None;
}
let (num, rest) = l.split_once(']')?;
let number = num.trim_start_matches('[').trim().to_string();
Some(Rule { number, text: rest.trim().to_string() })
})
.collect();
Some(Status { active, rules })
}
fn render_rules(list: &ListBox, rules: &[Rule], programmatic: &Rc<Cell<bool>>) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
if rules.is_empty() {
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"security-high-symbolic",
"No rules",
"Only the default policy (deny incoming, allow outgoing) applies.",
)));
list.append(&row);
return;
}
for rule in rules {
let row = ListBoxRow::new();
row.set_selectable(false);
let hbox = GBox::new(Orientation::Horizontal, 16);
hbox.add_css_class("card");
hbox.set_margin_top(3);
hbox.set_margin_bottom(3);
let text_lbl = Label::new(Some(&rule.text));
text_lbl.set_hexpand(true);
text_lbl.set_xalign(0.0);
text_lbl.set_wrap(true);
let remove_btn = Button::with_label("Remove");
remove_btn.add_css_class("destructive-action");
{
let list = list.clone();
let number = rule.number.clone();
let programmatic = programmatic.clone();
remove_btn.connect_clicked(move |_| {
let log_buf = gtk4::TextBuffer::new(None);
let list2 = list.clone();
let programmatic2 = programmatic.clone();
w::stream_command_then(
&["pkexec", "ufw", "--force", "delete", &number],
log_buf,
move || refresh(&list2, None, &programmatic2),
);
});
}
hbox.append(&text_lbl);
hbox.append(&remove_btn);
row.set_child(Some(&hbox));
list.append(&row);
}
}
/// Re-fetch status on a background thread and update the list (+ switch, if
/// given) on completion. `enabled_sw` is `None` when called from a row
/// action (delete/add) where the enabled state can't have changed.
///
/// `programmatic` guards against `set_active` below re-triggering the
/// switch's own `connect_active_notify` handler (which calls `ufw enable`/
/// `disable`) — without it, the very first Refresh after ufw turns out to
/// already be active would immediately fire an unwanted `ufw enable` the
/// moment `set_active(true)` flips a switch that just became sensitive.
fn refresh(list: &ListBox, enabled_sw: Option<&Switch>, programmatic: &Rc<Cell<bool>>) {
let (tx, rx) = async_channel::bounded::<Option<Status>>(1);
std::thread::spawn(move || {
let _ = tx.send_blocking(fetch_status());
});
let list = list.clone();
let enabled_sw = enabled_sw.cloned();
let programmatic = programmatic.clone();
glib::spawn_future_local(async move {
if let Ok(Some(status)) = rx.recv().await {
render_rules(&list, &status.rules, &programmatic);
if let Some(sw) = &enabled_sw {
programmatic.set(true);
sw.set_sensitive(true);
sw.set_active(status.active);
programmatic.set(false);
}
}
});
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Firewall");
content.append(&w::hint(
"Reading and changing firewall state needs your password (polkit) \
ufw requires root even just to check status. Click Refresh to \
load the current state.",
));
let programmatic = Rc::new(Cell::new(false));
let enabled_sw = Switch::new();
enabled_sw.set_sensitive(false);
content.append(&w::row("Firewall enabled", &enabled_sw));
content.append(&w::hint(
"Default policy: deny incoming, allow outgoing. mDNS (5353/udp) is \
allowed by default so printer/network discovery keeps working.",
));
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
{
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"security-high-symbolic",
"Status not loaded",
"Click Refresh below to check the firewall's current state.",
)));
list.append(&row);
}
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_min_content_height(260);
scroll.set_child(Some(&list));
content.append(&scroll);
let refresh_btn = Button::with_label("Refresh status");
{
let list = list.clone();
let enabled_sw = enabled_sw.clone();
let programmatic = programmatic.clone();
refresh_btn.connect_clicked(move |_| refresh(&list, Some(&enabled_sw), &programmatic));
}
content.append(&refresh_btn);
{
let list = list.clone();
let programmatic = programmatic.clone();
enabled_sw.connect_active_notify(move |s| {
// Skip both the pre-refresh insensitive state and any
// programmatic set_active() from refresh() itself — only a
// real user click should call out to ufw enable/disable.
if !s.is_sensitive() || programmatic.get() {
return;
}
let verb = if s.is_active() { "enable" } else { "disable" };
let log_buf = gtk4::TextBuffer::new(None);
let list2 = list.clone();
let programmatic2 = programmatic.clone();
w::stream_command_then(&["pkexec", "ufw", "--force", verb], log_buf, move || {
refresh(&list2, None, &programmatic2);
});
});
}
content.append(&w::section("Add rule"));
content.append(&w::hint(
"e.g. \"8080/tcp\", \"22/tcp\", or a service name like \"OpenSSH\".",
));
let add_row = GBox::new(Orientation::Horizontal, 8);
let add_entry = Entry::new();
add_entry.set_hexpand(true);
add_entry.set_placeholder_text(Some("port/proto or service name"));
let add_btn = Button::with_label("Allow");
{
let list = list.clone();
let add_entry = add_entry.clone();
let programmatic = programmatic.clone();
add_btn.connect_clicked(move |_| {
let rule = add_entry.text().to_string();
if rule.trim().is_empty() {
return;
}
let log_buf = gtk4::TextBuffer::new(None);
let list2 = list.clone();
let add_entry2 = add_entry.clone();
let programmatic2 = programmatic.clone();
w::stream_command_then(
&["pkexec", "ufw", "allow", rule.trim()],
log_buf,
move || {
add_entry2.set_text("");
refresh(&list2, None, &programmatic2);
},
);
});
}
add_row.append(&add_entry);
add_row.append(&add_btn);
content.append(&add_row);
outer
}

View file

@ -0,0 +1,159 @@
//! fwupd firmware updates — thin GUI over `fwupdmgr`, following the same
//! "stream command output, refresh the list on completion" pattern as
//! Packages. fwupd itself (the daemon + CLI) is always installed;
//! `fwupd-refresh.timer` already keeps the metadata current in the
//! background, this panel is just for actually seeing/applying updates.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, TextView};
use crate::ui::widgets as w;
use crate::ui::widgets::stream_command_then;
#[derive(Clone)]
struct FwDevice {
name: String,
version: String,
}
fn list_updatable() -> Vec<FwDevice> {
let Ok(output) = std::process::Command::new("fwupdmgr")
.args(["get-devices", "--json"])
.output()
else {
return Vec::new();
};
let Ok(root) = serde_json::from_slice::<serde_json::Value>(&output.stdout) else {
return Vec::new();
};
let Some(devices) = root.get("Devices").and_then(|d| d.as_array()) else {
return Vec::new();
};
devices
.iter()
.filter(|d| {
d.get("Flags")
.and_then(|f| f.as_array())
.is_some_and(|flags| flags.iter().any(|f| f.as_str() == Some("updatable")))
})
.filter_map(|d| {
Some(FwDevice {
name: d.get("Name")?.as_str()?.to_string(),
version: d.get("Version").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(),
})
})
.collect()
}
fn populate(list: &ListBox) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
let devices = list_updatable();
if devices.is_empty() {
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"software-update-available-symbolic",
"No updatable firmware devices found",
"Not every device supports firmware updates through fwupd.",
)));
list.append(&row);
return;
}
for dev in devices {
let row = ListBoxRow::new();
row.set_selectable(false);
let hbox = GBox::new(Orientation::Horizontal, 16);
hbox.add_css_class("card");
hbox.set_margin_top(3);
hbox.set_margin_bottom(3);
let name_lbl = Label::new(Some(&dev.name));
name_lbl.set_hexpand(true);
name_lbl.set_xalign(0.0);
let ver_lbl = Label::new(Some(&dev.version));
ver_lbl.add_css_class("dim-label");
ver_lbl.set_xalign(1.0);
hbox.append(&name_lbl);
hbox.append(&ver_lbl);
row.set_child(Some(&hbox));
list.append(&row);
}
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Firmware");
content.append(&w::hint(
"Firmware updates for hardware that supports them (UEFI, some \
peripherals). fwupd-refresh.timer already keeps update metadata \
current in the background.",
));
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
populate(&list);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_child(Some(&list));
content.append(&scroll);
let log_buf = gtk4::TextBuffer::new(None);
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);
log_view.set_visible(false);
let btn_row = GBox::new(Orientation::Horizontal, 8);
btn_row.set_margin_top(12);
let check_btn = Button::with_label("Check for updates");
{
let log_buf = log_buf.clone();
let log_view = log_view.clone();
let list = list.clone();
check_btn.connect_clicked(move |_| {
log_buf.set_text("");
log_view.set_visible(true);
let list2 = list.clone();
stream_command_then(&["fwupdmgr", "refresh"], log_buf.clone(), move || {
populate(&list2);
});
});
}
let update_btn = Button::with_label("Update all");
update_btn.add_css_class("suggested-action");
{
let log_buf = log_buf.clone();
let log_view = log_view.clone();
let list = list.clone();
update_btn.connect_clicked(move |_| {
log_buf.set_text("");
log_view.set_visible(true);
let list2 = list.clone();
stream_command_then(&["fwupdmgr", "update", "-y"], log_buf.clone(), move || {
populate(&list2);
});
});
}
let refresh_btn = Button::with_label("Refresh list");
{
let list = list.clone();
refresh_btn.connect_clicked(move |_| {
populate(&list);
});
}
btn_row.append(&check_btn);
btn_row.append(&update_btn);
btn_row.append(&refresh_btn);
content.append(&btn_row);
content.append(&log_view);
outer
}

View file

@ -1,8 +1,10 @@
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Label, Orientation};
use gtk4::{Box as GBox, Button};
use std::process::Command;
fn get_monitors() -> Vec<String> {
use crate::ui::widgets as w;
fn get_monitors() -> Vec<(String, String)> {
let Ok(output) = Command::new("hyprctl").args(["monitors", "-j"]).output() else {
return Vec::new();
};
@ -17,7 +19,7 @@ fn get_monitors() -> Vec<String> {
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"))
Some((name.to_string(), format!("{w}x{h} @ {refresh:.0}Hz")))
})
.collect()
}
@ -38,58 +40,46 @@ fn open_in_terminal(path: &std::path::Path) {
}
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 (outer, content) = w::view_scaffold("Display");
content.append(&w::section("Connected monitors"));
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);
content.append(&w::hint("No monitors detected (is Hyprland running?)"));
} else {
for mon in &monitors {
let lbl = Label::new(Some(mon));
lbl.set_xalign(0.0);
lbl.add_css_class("monospace");
vbox.append(&lbl);
for (name, mode) in &monitors {
content.append(&w::info_row(name, mode));
}
}
content.append(&w::section("Configuration"));
content.append(&w::hint(
"Monitor layout, keyboard/input, and workspace rules are configured \
directly in hyprland.lua there's no live editor for them here yet.",
));
// 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);
content.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);
content.append(&keybinds_btn);
vbox
outer
}

View file

@ -1,10 +1,20 @@
pub mod about;
pub mod aur;
pub mod bread;
pub mod breadbar;
pub mod breadbox;
pub mod breadclip;
pub mod breadcrumbs;
pub mod breadpad;
pub mod breadpaper;
pub mod breadsearch;
pub mod datetime;
pub mod firewall;
pub mod firmware;
pub mod hyprland;
pub mod network;
pub mod packages;
pub mod power;
pub mod snapshots;
pub mod sound;
pub mod users;

View file

@ -0,0 +1,328 @@
//! Wi-Fi + Ethernet over `nmcli`. NetworkManager lets the active session user
//! manage connections via polkit already, so the common paths (scan, connect,
//! toggle radio) need no `pkexec`. VPN import, 802.1x, and other edge cases
//! are punted to `nm-connection-editor` via the Advanced button rather than
//! reimplemented here.
//!
//! The scan is deliberately NOT run in `build()` — every view is constructed
//! eagerly at app launch (see `window.rs`), and a Wi-Fi scan takes seconds;
//! doing it here would add that latency to every bos-settings launch. It
//! only runs when the user clicks Scan.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch};
use std::cell::RefCell;
use std::collections::HashSet;
use std::process::Command;
use std::rc::Rc;
use crate::ui::widgets as w;
#[derive(Clone)]
struct WifiNetwork {
ssid: String,
signal: i32,
secured: bool,
active: bool,
}
fn radio_enabled() -> bool {
Command::new("nmcli")
.args(["radio", "wifi"])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "enabled")
.unwrap_or(false)
}
fn ethernet_status() -> Option<String> {
let out = Command::new("nmcli").args(["-t", "-f", "DEVICE,TYPE,STATE"]).arg("dev").output().ok()?;
let text = String::from_utf8_lossy(&out.stdout);
text.lines().find_map(|l| {
let mut cols = l.splitn(3, ':');
let (dev, ty, state) = (cols.next()?, cols.next()?, cols.next()?);
(ty == "ethernet").then(|| format!("{dev}: {state}"))
})
}
fn known_connection_names() -> HashSet<String> {
let Ok(out) = Command::new("nmcli").args(["-t", "-f", "NAME"]).arg("con").arg("show").output() else {
return HashSet::new();
};
String::from_utf8_lossy(&out.stdout).lines().map(str::to_string).collect()
}
/// Scan + list Wi-Fi networks, deduplicated by SSID (keeping the strongest
/// signal — the same AP shows once per band/BSSID otherwise).
fn scan_wifi() -> Vec<WifiNetwork> {
let _ = Command::new("nmcli").args(["dev", "wifi", "rescan"]).output();
std::thread::sleep(std::time::Duration::from_secs(2));
let Ok(out) = Command::new("nmcli")
.args(["-t", "-f", "SSID,SIGNAL,SECURITY,IN-USE", "dev", "wifi", "list"])
.output()
else {
return Vec::new();
};
let text = String::from_utf8_lossy(&out.stdout);
let mut by_ssid: std::collections::HashMap<String, WifiNetwork> = std::collections::HashMap::new();
for line in text.lines() {
let mut cols = line.splitn(4, ':');
let (ssid, signal, security, in_use) = (cols.next(), cols.next(), cols.next(), cols.next());
let Some(ssid) = ssid.filter(|s| !s.is_empty()) else { continue };
let signal: i32 = signal.and_then(|s| s.parse().ok()).unwrap_or(0);
let net = WifiNetwork {
ssid: ssid.to_string(),
signal,
secured: security.map(|s| !s.is_empty()).unwrap_or(false),
active: in_use == Some("*"),
};
by_ssid
.entry(ssid.to_string())
.and_modify(|existing| if net.signal > existing.signal { *existing = net.clone() })
.or_insert(net);
}
let mut list: Vec<_> = by_ssid.into_values().collect();
list.sort_by(|a, b| b.signal.cmp(&a.signal));
list
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Network");
content.append(&w::section("Wi-Fi"));
let radio_sw = Switch::new();
radio_sw.set_active(radio_enabled());
radio_sw.connect_active_notify(|s| {
let val = if s.is_active() { "on" } else { "off" };
let _ = Command::new("nmcli").args(["radio", "wifi", val]).spawn();
});
content.append(&w::row("Wi-Fi radio", &radio_sw));
let status = Label::new(None);
status.add_css_class("dim-label");
status.set_xalign(0.0);
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
// Starts with an empty_state row ("Not scanned yet") rather than being
// hidden — the "no results yet" and "scanned, found nothing" states are
// the same idea and should look the same, not one a hint label and the
// other an empty_state card.
{
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"network-wireless-symbolic",
"Not scanned yet",
"Press Scan to see nearby networks.",
)));
list.append(&row);
}
let scroll = ScrolledWindow::new();
scroll.set_min_content_height(220);
scroll.set_child(Some(&list));
content.append(&scroll);
// Password entry, shown only while connecting to a secured, unknown
// network — set visible/hidden rather than a modal dialog, to keep this
// panel's async flow in one place instead of a nested dialog callback.
let pw_row = GBox::new(Orientation::Horizontal, 8);
let pw_entry = Entry::new();
pw_entry.set_visibility(false);
pw_entry.set_hexpand(true);
pw_entry.set_placeholder_text(Some("Password"));
let pw_connect_btn = Button::with_label("Connect");
pw_row.append(&pw_entry);
pw_row.append(&pw_connect_btn);
pw_row.set_visible(false);
content.append(&pw_row);
content.append(&status);
let pending_ssid: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
let connect_open_or_known = {
let status = status.clone();
move |ssid: String| {
status.set_text(&format!("Connecting to {ssid}"));
let log_buf = gtk4::TextBuffer::new(None);
let status2 = status.clone();
let ssid2 = ssid.clone();
let known = known_connection_names();
let args: Vec<String> = if known.contains(&ssid) {
vec!["nmcli".into(), "con".into(), "up".into(), ssid.clone()]
} else {
vec!["nmcli".into(), "dev".into(), "wifi".into(), "connect".into(), ssid.clone()]
};
let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
w::stream_command_then(&args_ref, log_buf.clone(), move || {
let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false);
if text.to_lowercase().contains("error") {
status2.set_text(&format!("Failed to connect to {ssid2}: {}", text.trim()));
} else {
status2.set_text(&format!("Connected to {ssid2}"));
}
});
}
};
{
let pw_row = pw_row.clone();
let pw_entry = pw_entry.clone();
let pending_ssid = pending_ssid.clone();
let status = status.clone();
pw_connect_btn.connect_clicked(move |_| {
let Some(ssid) = pending_ssid.borrow_mut().take() else { return };
let password = pw_entry.text().to_string();
pw_row.set_visible(false);
pw_entry.set_text("");
status.set_text(&format!("Connecting to {ssid}"));
let log_buf = gtk4::TextBuffer::new(None);
let status2 = status.clone();
let ssid2 = ssid.clone();
w::stream_command_then(
&["nmcli", "dev", "wifi", "connect", &ssid, "password", &password],
log_buf.clone(),
move || {
let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false);
if text.to_lowercase().contains("error") {
status2.set_text(&format!("Failed to connect to {ssid2}: wrong password?"));
} else {
status2.set_text(&format!("Connected to {ssid2}"));
}
},
);
});
}
let populate_list = {
let list = list.clone();
let pw_row = pw_row.clone();
let pending_ssid = pending_ssid.clone();
let connect_open_or_known = connect_open_or_known.clone();
move |networks: Vec<WifiNetwork>| {
while let Some(child) = list.first_child() {
list.remove(&child);
}
if networks.is_empty() {
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"network-wireless-offline-symbolic",
"No networks found",
"Try Scan again, or check Wi-Fi radio is on.",
)));
list.append(&row);
return;
}
let known = known_connection_names();
for net in networks {
let row = ListBoxRow::new();
let hbox = GBox::new(Orientation::Horizontal, 12);
hbox.set_margin_top(4);
hbox.set_margin_bottom(4);
hbox.set_margin_start(8);
hbox.set_margin_end(8);
let name = if net.active {
format!("{} (connected)", net.ssid)
} else {
net.ssid.clone()
};
let name_lbl = Label::new(Some(&name));
name_lbl.set_hexpand(true);
name_lbl.set_xalign(0.0);
if net.active {
name_lbl.add_css_class("heading");
}
let signal_icon_name = match net.signal.clamp(0, 100) {
0..=24 => "network-wireless-signal-weak-symbolic",
25..=49 => "network-wireless-signal-ok-symbolic",
50..=74 => "network-wireless-signal-good-symbolic",
_ => "network-wireless-signal-excellent-symbolic",
};
let signal_icon = gtk4::Image::from_icon_name(signal_icon_name);
signal_icon.add_css_class("dim-label");
let meta_lbl = Label::new(Some(&format!("{}%", net.signal.clamp(0, 100))));
meta_lbl.add_css_class("dim-label");
hbox.append(&name_lbl);
if net.secured {
let lock_icon = gtk4::Image::from_icon_name("channel-secure-symbolic");
lock_icon.add_css_class("dim-label");
hbox.append(&lock_icon);
}
hbox.append(&signal_icon);
hbox.append(&meta_lbl);
if !net.active {
let connect_btn = Button::with_label("Connect");
let ssid = net.ssid.clone();
let secured = net.secured;
let already_known = known.contains(&net.ssid);
let pw_row = pw_row.clone();
let pending_ssid = pending_ssid.clone();
let connect_open_or_known = connect_open_or_known.clone();
connect_btn.connect_clicked(move |_| {
if secured && !already_known {
*pending_ssid.borrow_mut() = Some(ssid.clone());
pw_row.set_visible(true);
} else {
pw_row.set_visible(false);
connect_open_or_known(ssid.clone());
}
});
hbox.append(&connect_btn);
}
row.set_child(Some(&hbox));
list.append(&row);
}
}
};
let scan_btn = Button::with_label("Scan");
scan_btn.set_halign(gtk4::Align::Start);
{
let status = status.clone();
let populate_list = populate_list.clone();
scan_btn.connect_clicked(move |btn| {
btn.set_sensitive(false);
status.set_text("Scanning…");
let (tx, rx) = async_channel::bounded::<Vec<WifiNetwork>>(1);
std::thread::spawn(move || {
let nets = scan_wifi();
let _ = tx.send_blocking(nets);
});
let btn = btn.clone();
let status = status.clone();
let populate_list = populate_list.clone();
glib::spawn_future_local(async move {
if let Ok(nets) = rx.recv().await {
let count = nets.len();
populate_list(nets);
status.set_text(&format!("Found {count} network(s)"));
}
btn.set_sensitive(true);
});
});
}
content.append(&scan_btn);
if let Some(eth) = ethernet_status() {
content.append(&w::section("Ethernet"));
content.append(&w::info_row("Status", &eth));
}
content.append(&w::section("Advanced"));
content.append(&w::hint("VPN, 802.1x, and static IP configuration aren't covered here."));
let adv_btn = Button::with_label("Open connection editor");
adv_btn.set_halign(gtk4::Align::Start);
adv_btn.connect_clicked(|_| {
let _ = Command::new("nm-connection-editor").spawn();
});
content.append(&adv_btn);
outer
}

View file

@ -1,13 +1,11 @@
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;
use crate::ui::widgets::{stream_command, stream_command_then};
fn read_installed() -> HashMap<String, String> {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
@ -43,61 +41,7 @@ fn read_installed() -> HashMap<String, String> {
.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) {
fn populate_packages(list: &ListBox, log_buf: &gtk4::TextBuffer, log_view: &TextView) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
@ -106,13 +50,11 @@ fn populate_packages(list: &ListBox, log_buf: &gtk4::TextBuffer) {
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));
row.set_child(Some(&w::empty_state(
"package-x-generic-symbolic",
"No bakery packages found",
"~/.local/state/bakery/installed.json is missing or empty.",
)));
list.append(&row);
return;
}
@ -124,10 +66,9 @@ fn populate_packages(list: &ListBox, log_buf: &gtk4::TextBuffer) {
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);
hbox.add_css_class("card");
hbox.set_margin_top(3);
hbox.set_margin_bottom(3);
let name_lbl = Label::new(Some(name));
name_lbl.set_hexpand(true);
@ -140,11 +81,14 @@ fn populate_packages(list: &ListBox, log_buf: &gtk4::TextBuffer) {
let update_btn = Button::with_label("Update");
{
let log_buf = log_buf.clone();
let log_view = log_view.clone();
let list = list.clone();
update_btn.connect_clicked(move |_| {
log_buf.set_text("");
log_view.set_visible(true);
let list2 = list.clone();
let log_buf2 = log_buf.clone();
let log_view2 = log_view.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
@ -152,7 +96,7 @@ fn populate_packages(list: &ListBox, log_buf: &gtk4::TextBuffer) {
// 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);
populate_packages(&list2, &log_buf2, &log_view2);
});
});
}
@ -166,35 +110,32 @@ fn populate_packages(list: &ListBox, log_buf: &gtk4::TextBuffer) {
}
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 (outer, content) = w::view_scaffold("Packages");
content.append(&w::hint(
"Bread ecosystem packages installed via bakery, and system packages via pacman below.",
));
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);
// Hidden until a command actually produces output — an always-visible
// empty log box below a short package list was the single biggest
// "dead space" offender in the app.
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);
log_view.set_visible(false);
populate_packages(&list, &log_buf, &log_view);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_child(Some(&list));
content.append(&scroll);
let btn_row = GBox::new(Orientation::Horizontal, 8);
btn_row.set_margin_top(12);
@ -206,23 +147,27 @@ pub fn build() -> GBox {
{
let log_buf = log_buf.clone();
let log_view = log_view.clone();
check_btn.connect_clicked(move |_| {
log_buf.set_text("");
log_view.set_visible(true);
stream_command(&["bakery", "list"], log_buf.clone());
});
}
{
let log_buf = log_buf.clone();
let log_view = log_view.clone();
update_all_btn.connect_clicked(move |_| {
log_buf.set_text("");
log_view.set_visible(true);
stream_command(&["bakery", "update", "--all"], log_buf.clone());
});
}
btn_row.append(&check_btn);
btn_row.append(&update_all_btn);
vbox.append(&btn_row);
content.append(&btn_row);
// ---------------------------------------------------------------------
// System packages (pacman) — the other update channel. bakery only
@ -232,8 +177,8 @@ pub fn build() -> GBox {
// 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(
content.append(&w::section("System packages (pacman)"));
content.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.",
@ -244,15 +189,17 @@ pub fn build() -> GBox {
let pacman_update_btn = Button::with_label("Update system (pacman -Syu)");
{
let log_buf = log_buf.clone();
let log_view = log_view.clone();
pacman_update_btn.connect_clicked(move |_| {
log_buf.set_text("");
log_view.set_visible(true);
stream_command(&["pkexec", "pacman", "-Syu", "--noconfirm"], log_buf.clone());
});
}
pacman_btn_row.append(&pacman_update_btn);
vbox.append(&pacman_btn_row);
content.append(&pacman_btn_row);
vbox.append(&log_view);
content.append(&log_view);
vbox
outer
}

View file

@ -0,0 +1,193 @@
//! Battery/power status (upower), brightness (brightnessctl), and TLP's
//! current profile. Deliberately no AC/Battery/Performance *switcher* —
//! TLP's whole model on BOS is automatic profile selection by power source
//! (see packages.x86_64: power-profiles-daemon is intentionally not
//! installed, it conflicts with tlp), so this panel is read-only for TLP
//! and only exposes controls for things that are actually user choices:
//! brightness, and charge thresholds where the hardware supports them.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Scale};
use std::process::Command;
use crate::ui::widgets as w;
fn upower_device(kind: &str) -> Option<String> {
let out = Command::new("upower").arg("-e").output().ok()?;
String::from_utf8_lossy(&out.stdout)
.lines()
.find(|l| l.to_lowercase().contains(kind))
.map(str::to_string)
}
fn upower_field(device: &str, field: &str) -> Option<String> {
let out = Command::new("upower").args(["-i", device]).output().ok()?;
let text = String::from_utf8_lossy(&out.stdout);
text.lines()
.find(|l| l.trim_start().starts_with(field))
.and_then(|l| l.split(':').nth(1))
.map(|v| v.trim().to_string())
}
fn battery_summary() -> Vec<(String, String)> {
let Some(bat) = upower_device("bat") else {
return vec![("Battery".to_string(), "No battery detected".to_string())];
};
let mut rows = Vec::new();
if let Some(state) = upower_field(&bat, "state") {
rows.push(("Status".to_string(), state));
}
if let Some(pct) = upower_field(&bat, "percentage") {
rows.push(("Charge".to_string(), pct));
}
if let Some(t) = upower_field(&bat, "time to empty").or_else(|| upower_field(&bat, "time to full")) {
rows.push(("Time remaining".to_string(), t));
}
let full: Option<f64> = upower_field(&bat, "energy-full").and_then(|v| {
v.split_whitespace().next()?.parse().ok()
});
let design: Option<f64> = upower_field(&bat, "energy-full-design").and_then(|v| {
v.split_whitespace().next()?.parse().ok()
});
if let (Some(full), Some(design)) = (full, design) {
if design > 0.0 {
rows.push(("Battery health".to_string(), format!("{:.0}% of design capacity", full / design * 100.0)));
}
}
rows
}
fn power_source() -> String {
match upower_device("ac").and_then(|ac| upower_field(&ac, "online")) {
Some(v) if v == "yes" => "AC power".to_string(),
Some(_) => "Battery".to_string(),
None => "Unknown".to_string(),
}
}
fn tlp_profile() -> Option<String> {
let out = Command::new("tlp-stat").arg("-s").output().ok()?;
let text = String::from_utf8_lossy(&out.stdout);
text.lines()
.find(|l| l.trim_start().starts_with("TLP profile"))
.and_then(|l| l.split('=').nth(1))
.map(|v| v.trim().to_string())
}
fn brightness_device() -> Option<String> {
let out = Command::new("brightnessctl").output().ok()?;
String::from_utf8_lossy(&out.stdout)
.lines()
.find(|l| l.starts_with("Device"))
.and_then(|l| l.split('\'').nth(1))
.map(str::to_string)
}
fn brightness_pct() -> Option<u32> {
let out = Command::new("brightnessctl").output().ok()?;
let text = String::from_utf8_lossy(&out.stdout);
text.lines()
.find(|l| l.contains("Current brightness"))
.and_then(|l| l.split('(').nth(1))
.and_then(|v| v.trim_end_matches("%)").parse().ok())
}
/// Charge-threshold sysfs paths, only Some when the running kernel driver
/// actually exposes them (ideapad_laptop/thinkpad_acpi on some models;
/// nothing on this dev laptop's Yoga Slim 7 — this is genuinely
/// hardware-dependent, not something every install will have).
fn charge_threshold_paths() -> Option<(std::path::PathBuf, std::path::PathBuf)> {
let base = std::path::Path::new("/sys/class/power_supply");
let entries = std::fs::read_dir(base).ok()?;
for entry in entries.flatten() {
let start = entry.path().join("charge_control_start_threshold");
let end = entry.path().join("charge_control_end_threshold");
if start.exists() && end.exists() {
return Some((start, end));
}
}
None
}
fn read_threshold(path: &std::path::Path) -> i64 {
std::fs::read_to_string(path)
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(100)
}
pub fn build() -> GBox {
let (outer, c) = w::view_scaffold("Power");
c.append(&w::section("Battery"));
for (label, value) in battery_summary() {
c.append(&w::info_row(&label, &value));
}
c.append(&w::info_row("Power source", &power_source()));
c.append(&w::section("Brightness"));
if let Some(pct) = brightness_pct() {
let scale = Scale::with_range(gtk4::Orientation::Horizontal, 1.0, 100.0, 1.0);
scale.set_value(pct as f64);
scale.set_hexpand(true);
scale.set_draw_value(true);
if let Some(device) = brightness_device() {
scale.connect_value_changed(move |s| {
let pct = format!("{}%", s.value() as i64);
let _ = Command::new("brightnessctl")
.args(["--device", &device, "set", &pct])
.spawn();
});
}
c.append(&w::row("Screen brightness", &scale));
} else {
c.append(&w::hint("No controllable backlight found."));
}
if let Some((start_path, end_path)) = charge_threshold_paths() {
c.append(&w::section("Charge limits"));
c.append(&w::hint(
"Some laptops let you cap charging below 100% to slow battery \
wear on a machine that's mostly plugged in. Not every model \
supports this shown here because yours reported it does.",
));
let start_adj = gtk4::Adjustment::new(read_threshold(&start_path) as f64, 0.0, 100.0, 1.0, 5.0, 0.0);
let start_spin = gtk4::SpinButton::new(Some(&start_adj), 1.0, 0);
{
let path = start_path.clone();
start_spin.connect_value_changed(move |s| {
let val = (s.value() as i64).to_string();
let _ = Command::new("pkexec")
.args(["tee", &path.display().to_string()])
.arg(&val)
.output();
});
}
c.append(&w::row("Start charging below (%)", &start_spin));
let end_adj = gtk4::Adjustment::new(read_threshold(&end_path) as f64, 1.0, 100.0, 1.0, 5.0, 0.0);
let end_spin = gtk4::SpinButton::new(Some(&end_adj), 1.0, 0);
{
let path = end_path.clone();
end_spin.connect_value_changed(move |s| {
let val = (s.value() as i64).to_string();
let _ = Command::new("pkexec")
.args(["tee", &path.display().to_string()])
.arg(&val)
.output();
});
}
c.append(&w::row("Stop charging at (%)", &end_spin));
}
c.append(&w::section("TLP"));
c.append(&w::hint(
"TLP automatically applies a power-saving profile on battery and a \
performance profile on AC there's no manual profile switch here \
by design (power-profiles-daemon isn't installed; it conflicts \
with tlp).",
));
c.append(&w::info_row("Current profile", tlp_profile().as_deref().unwrap_or("unknown")));
outer
}

View file

@ -4,6 +4,8 @@ use gtk4::{
};
use std::process::Command;
use crate::ui::widgets as w;
#[derive(Clone)]
struct SnapshotRow {
number: String,
@ -50,7 +52,9 @@ fn list_snapshots() -> Vec<SnapshotRow> {
.collect()
}
fn populate_list(list: &ListBox) {
/// Returns whether the list ended up empty, so callers can disable the
/// selection-dependent buttons instead of leaving them clickable no-ops.
fn populate_list(list: &ListBox) -> bool {
while let Some(child) = list.first_child() {
list.remove(&child);
}
@ -58,13 +62,14 @@ fn populate_list(list: &ListBox) {
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));
row.set_child(Some(&w::empty_state(
"document-open-recent-symbolic",
"No snapshots yet",
"Snapshots are created automatically on every pacman transaction \
(snapper may not be configured yet).",
)));
list.append(&row);
return;
return true;
}
for snap in &snapshots {
let row = ListBoxRow::new();
@ -94,33 +99,24 @@ fn populate_list(list: &ListBox) {
row.set_child(Some(&hbox));
list.append(&row);
}
false
}
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(
let (outer, content) = w::view_scaffold("Snapshots");
content.append(&w::hint(
"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 empty = populate_list(&list);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_child(Some(&list));
vbox.append(&scroll);
content.append(&scroll);
let btn_row = GBox::new(Orientation::Horizontal, 8);
btn_row.set_margin_top(12);
@ -129,11 +125,17 @@ pub fn build() -> GBox {
let rollback_btn = Button::with_label("Boot into selected...");
let delete_btn = Button::with_label("Delete selected");
delete_btn.add_css_class("destructive-action");
rollback_btn.set_sensitive(!empty);
delete_btn.set_sensitive(!empty);
{
let list = list.clone();
let rollback_btn = rollback_btn.clone();
let delete_btn = delete_btn.clone();
refresh_btn.connect_clicked(move |_| {
populate_list(&list);
let empty = populate_list(&list);
rollback_btn.set_sensitive(!empty);
delete_btn.set_sensitive(!empty);
});
}
@ -178,11 +180,14 @@ pub fn build() -> GBox {
{
let list = list.clone();
let rollback_btn = rollback_btn.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 rollback_btn = rollback_btn.clone();
let delete_btn = btn.clone();
let window = btn
.root()
.and_then(|r| r.downcast::<gtk4::Window>().ok());
@ -197,6 +202,8 @@ pub fn build() -> GBox {
let window2 = window.clone();
let list2 = list.clone();
let rollback_btn2 = rollback_btn.clone();
let delete_btn2 = delete_btn.clone();
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
if result != Ok(1) { return }
@ -218,10 +225,14 @@ pub fn build() -> GBox {
let list = list2.clone();
let window = window2.clone();
let rollback_btn = rollback_btn2.clone();
let delete_btn = delete_btn2.clone();
glib::spawn_future_local(async move {
let ok = rx.recv().await.unwrap_or(false);
if ok {
populate_list(&list);
let empty = populate_list(&list);
rollback_btn.set_sensitive(!empty);
delete_btn.set_sensitive(!empty);
} else {
let err = AlertDialog::builder()
.message("Delete failed")
@ -239,7 +250,7 @@ pub fn build() -> GBox {
btn_row.append(&refresh_btn);
btn_row.append(&rollback_btn);
btn_row.append(&delete_btn);
vbox.append(&btn_row);
outer.append(&btn_row);
vbox
outer
}

View file

@ -0,0 +1,167 @@
//! Output/input volume and device selection over PipeWire's pulse
//! compatibility layer (`pactl`) — the same surface `hyprland.lua`'s media
//! keys already use via `wpctl`. `pactl` is used here instead of `wpctl`
//! because it can enumerate devices with human-readable descriptions and
//! switch the default in one command; `wpctl` cannot easily do either.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, DropDown, Expression, Orientation, Scale, StringList, Switch};
use serde::Deserialize;
use std::process::Command;
use crate::ui::widgets as w;
#[derive(Deserialize, Clone)]
struct Device {
name: String,
description: String,
mute: bool,
volume: std::collections::HashMap<String, VolumeChannel>,
}
#[derive(Deserialize, Clone)]
struct VolumeChannel {
value_percent: String,
}
impl Device {
fn percent(&self) -> f64 {
self.volume
.values()
.next()
.and_then(|v| v.value_percent.trim_end_matches('%').trim().parse::<f64>().ok())
.unwrap_or(0.0)
}
}
fn list_devices(kind: &str) -> Vec<Device> {
let Ok(output) = Command::new("pactl").args(["-f", "json", "list", kind]).output() else {
return Vec::new();
};
serde_json::from_slice(&output.stdout).unwrap_or_default()
}
fn default_device_name(kind: &str) -> Option<String> {
let flag = if kind == "sinks" { "get-default-sink" } else { "get-default-source" };
Command::new("pactl")
.arg(flag)
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
}
/// Build one section (Output or Input): device dropdown + volume slider + mute
/// switch. `kind` is "sinks" or "sources"; `set_default_flag` is the pactl
/// verb ("set-default-sink"/"set-default-source"); `vol_flag`/`mute_flag`
/// likewise.
fn build_section(
title: &str,
kind: &'static str,
set_default_flag: &'static str,
vol_flag: &'static str,
mute_flag: &'static str,
) -> GBox {
let section = GBox::new(Orientation::Vertical, 4);
section.append(&w::section(title));
let devices = list_devices(kind);
if devices.is_empty() {
section.append(&w::hint("No devices found."));
return section;
}
let current_name = default_device_name(kind);
let selected_idx = current_name
.as_ref()
.and_then(|n| devices.iter().position(|d| &d.name == n))
.unwrap_or(0);
let current = devices[selected_idx].clone();
let labels: Vec<&str> = devices.iter().map(|d| d.description.as_str()).collect();
let model = StringList::new(&labels);
let dd = DropDown::new(Some(model), Expression::NONE);
dd.set_selected(selected_idx as u32);
section.append(&w::row("Device", &dd));
let scale = Scale::with_range(Orientation::Horizontal, 0.0, 150.0, 1.0);
scale.set_value(current.percent());
scale.set_size_request(180, -1);
scale.set_draw_value(true);
scale.set_value_pos(gtk4::PositionType::Right);
section.append(&w::row("Volume", &scale));
let mute_sw = Switch::new();
mute_sw.set_active(current.mute);
section.append(&w::row("Mute", &mute_sw));
// Device switch: fire-and-forget pactl call, then resync the slider/mute
// switch to whatever the newly-default device's actual state is.
{
let devices = devices.clone();
let scale = scale.clone();
let mute_sw = mute_sw.clone();
dd.connect_selected_notify(move |dd| {
let Some(d) = devices.get(dd.selected() as usize) else { return };
let _ = Command::new("pactl").args([set_default_flag, &d.name]).spawn();
scale.set_value(d.percent());
mute_sw.set_active(d.mute);
});
}
// Volume: target whichever device is currently selected in the dropdown,
// not necessarily the system default at the time this closure was built.
{
let devices = devices.clone();
let dd = dd.clone();
scale.connect_value_changed(move |s| {
let Some(d) = devices.get(dd.selected() as usize) else { return };
let pct = format!("{}%", s.value() as i64);
let _ = Command::new("pactl").args([vol_flag, &d.name, &pct]).spawn();
});
}
{
let devices = devices.clone();
let dd = dd.clone();
mute_sw.connect_active_notify(move |s| {
let Some(d) = devices.get(dd.selected() as usize) else { return };
let val = if s.is_active() { "1" } else { "0" };
let _ = Command::new("pactl").args([mute_flag, &d.name, val]).spawn();
});
}
section
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Sound");
content.append(&build_section(
"Output",
"sinks",
"set-default-sink",
"set-sink-volume",
"set-sink-mute",
));
content.append(&build_section(
"Input",
"sources",
"set-default-source",
"set-source-volume",
"set-source-mute",
));
content.append(&w::section("Advanced"));
content.append(&w::hint(
"Per-app volume, port selection, and profile switching aren't covered here.",
));
let mixer_btn = Button::with_label("Open advanced mixer (pavucontrol)");
mixer_btn.set_halign(gtk4::Align::Start);
mixer_btn.connect_clicked(|_| {
let _ = Command::new("pavucontrol").spawn();
});
content.append(&mixer_btn);
outer
}

View file

@ -0,0 +1,293 @@
//! User account management — add/remove users, change passwords. Everything
//! here needs root (useradd/userdel/chpasswd), so every action goes through
//! `pkexec` on a background thread (GTK widgets aren't Send), matching the
//! async_channel + glib::spawn_future_local handoff used elsewhere for
//! destructive/root actions (see snapshots.rs).
use gtk4::prelude::*;
use gtk4::{AlertDialog, Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow};
use std::io::Write;
use std::process::{Command, Stdio};
use crate::ui::widgets as w;
#[derive(Clone)]
struct Account {
username: String,
full_name: String,
}
fn list_accounts() -> Vec<Account> {
let Ok(text) = std::fs::read_to_string("/etc/passwd") else {
return Vec::new();
};
text.lines()
.filter_map(|line| {
let f: Vec<&str> = line.split(':').collect();
if f.len() < 7 {
return None;
}
let uid: u32 = f[2].parse().ok()?;
let shell = f[6];
// Real human accounts: normal UID range, a real login shell (not
// nologin/false — excludes system/service accounts like
// greeter, avahi, etc).
if !(1000..60000).contains(&uid) || shell.ends_with("nologin") || shell.ends_with("/false") {
return None;
}
Some(Account {
username: f[0].to_string(),
full_name: f[4].split(',').next().unwrap_or("").to_string(),
})
})
.collect()
}
fn current_user() -> String {
std::env::var("USER").unwrap_or_default()
}
/// Run a root command that needs a line of input on stdin (chpasswd's own
/// "user:password" format) on a background thread, reporting success back
/// via a channel. `pkexec` inherits the spawning process's stdin only when
/// explicitly piped, so this pipes it through rather than relying on that.
fn run_with_stdin(args: &[&str], input: String, on_done: impl FnOnce(bool) + 'static) {
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let (tx, rx) = async_channel::bounded::<bool>(1);
std::thread::spawn(move || {
let ok = (|| -> std::io::Result<bool> {
let mut child = Command::new(&args[0])
.args(&args[1..])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(input.as_bytes())?;
}
Ok(child.wait()?.success())
})()
.unwrap_or(false);
let _ = tx.send_blocking(ok);
});
glib::spawn_future_local(async move {
let ok = rx.recv().await.unwrap_or(false);
on_done(ok);
});
}
fn populate(list: &ListBox) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
let me = current_user();
for acc in list_accounts() {
let row = ListBoxRow::new();
row.set_selectable(false);
let card = GBox::new(Orientation::Vertical, 6);
card.add_css_class("card");
card.set_margin_top(3);
card.set_margin_bottom(3);
let top = GBox::new(Orientation::Horizontal, 12);
let label_text = if acc.full_name.is_empty() {
acc.username.clone()
} else {
format!("{} ({})", acc.username, acc.full_name)
};
let name_lbl = Label::new(Some(&label_text));
name_lbl.set_hexpand(true);
name_lbl.set_xalign(0.0);
let change_pw_btn = Button::with_label("Change password");
let remove_btn = Button::with_label("Remove");
remove_btn.add_css_class("destructive-action");
// Don't let the panel remove the account it's currently running as
// — that's a self-lockout, not a normal account-management action.
remove_btn.set_sensitive(acc.username != me);
top.append(&name_lbl);
top.append(&change_pw_btn);
top.append(&remove_btn);
card.append(&top);
// Inline password row, hidden until "Change password" is clicked —
// same pattern as Network's secured-connection password prompt.
let pw_row = GBox::new(Orientation::Horizontal, 8);
let pw_entry = Entry::new();
pw_entry.set_visibility(false);
pw_entry.set_hexpand(true);
pw_entry.set_placeholder_text(Some("New password"));
let pw_apply_btn = Button::with_label("Apply");
pw_row.append(&pw_entry);
pw_row.append(&pw_apply_btn);
pw_row.set_visible(false);
card.append(&pw_row);
let status = Label::new(None);
status.add_css_class("dim-label");
status.set_xalign(0.0);
card.append(&status);
{
let pw_row = pw_row.clone();
change_pw_btn.connect_clicked(move |_| {
pw_row.set_visible(!pw_row.is_visible());
});
}
{
let username = acc.username.clone();
let pw_entry = pw_entry.clone();
let pw_row = pw_row.clone();
let status = status.clone();
pw_apply_btn.connect_clicked(move |_| {
let password = pw_entry.text().to_string();
if password.is_empty() {
return;
}
let input = format!("{username}:{password}\n");
let status2 = status.clone();
let pw_entry2 = pw_entry.clone();
let pw_row2 = pw_row.clone();
status.set_text("Applying…");
run_with_stdin(&["pkexec", "chpasswd"], input, move |ok| {
if ok {
status2.set_text("Password changed");
pw_entry2.set_text("");
pw_row2.set_visible(false);
} else {
status2.set_text("Failed to change password");
}
});
});
}
{
let list = list.clone();
let username = acc.username.clone();
remove_btn.connect_clicked(move |btn| {
let window = btn.root().and_then(|r| r.downcast::<gtk4::Window>().ok());
let dialog = AlertDialog::builder()
.message(format!("Remove user {username}?"))
.detail("Deletes the account and its home directory. This cannot be undone.")
.buttons(["Cancel", "Remove"])
.cancel_button(0)
.default_button(0)
.build();
let list2 = list.clone();
let username2 = username.clone();
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
if result != Ok(1) {
return;
}
let log_buf = gtk4::TextBuffer::new(None);
let list3 = list2.clone();
w::stream_command_then(
&["pkexec", "userdel", "-r", &username2],
log_buf,
move || populate(&list3),
);
});
});
}
row.set_child(Some(&card));
list.append(&row);
}
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Users");
content.append(&w::hint(
"Real login accounts on this machine (system/service accounts \
aren't shown). Your own account can't be removed from here.",
));
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
populate(&list);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_min_content_height(260);
scroll.set_child(Some(&list));
content.append(&scroll);
content.append(&w::section("Add user"));
let username_entry = Entry::new();
username_entry.set_placeholder_text(Some("username"));
content.append(&w::row("Username", &username_entry));
let fullname_entry = Entry::new();
fullname_entry.set_placeholder_text(Some("Full name (optional)"));
content.append(&w::row("Full name", &fullname_entry));
let password_entry = Entry::new();
password_entry.set_visibility(false);
password_entry.set_placeholder_text(Some("password"));
content.append(&w::row("Password", &password_entry));
let add_status = Label::new(None);
add_status.add_css_class("dim-label");
add_status.set_xalign(0.0);
let add_btn = Button::with_label("Add user");
add_btn.add_css_class("suggested-action");
add_btn.set_halign(gtk4::Align::Start);
add_btn.set_margin_top(8);
{
let list = list.clone();
let username_entry = username_entry.clone();
let fullname_entry = fullname_entry.clone();
let password_entry = password_entry.clone();
let add_status = add_status.clone();
add_btn.connect_clicked(move |_| {
let username = username_entry.text().to_string();
let full_name = fullname_entry.text().to_string();
let password = password_entry.text().to_string();
if username.trim().is_empty() || password.is_empty() {
add_status.set_text("Username and password are required.");
return;
}
add_status.set_text("Adding…");
let mut useradd_args = vec!["pkexec", "useradd", "-m", "-s", "/bin/bash"];
if !full_name.trim().is_empty() {
useradd_args.push("-c");
useradd_args.push(full_name.trim());
}
useradd_args.push(username.trim());
let list2 = list.clone();
let username2 = username.trim().to_string();
let password2 = password.clone();
let add_status2 = add_status.clone();
let username_entry2 = username_entry.clone();
let fullname_entry2 = fullname_entry.clone();
let password_entry2 = password_entry.clone();
let log_buf = gtk4::TextBuffer::new(None);
w::stream_command_then(&useradd_args, log_buf, move || {
let input = format!("{username2}:{password2}\n");
let list3 = list2.clone();
let add_status3 = add_status2.clone();
let username_entry3 = username_entry2.clone();
let fullname_entry3 = fullname_entry2.clone();
let password_entry3 = password_entry2.clone();
run_with_stdin(&["pkexec", "chpasswd"], input, move |ok| {
if ok {
add_status3.set_text("User added.");
username_entry3.set_text("");
fullname_entry3.set_text("");
password_entry3.set_text("");
populate(&list3);
} else {
add_status3.set_text("User created, but setting the password failed.");
populate(&list3);
}
});
});
});
}
content.append(&add_btn);
content.append(&add_status);
outer
}

View file

@ -11,7 +11,7 @@ use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::{
Adjustment, Box as GBox, Button, DropDown, Entry, Expression, Label, Orientation,
Adjustment, AlertDialog, Box as GBox, Button, DropDown, Entry, Expression, Label, Orientation,
SpinButton, StringList, Switch,
};
use toml_edit::DocumentMut;
@ -31,8 +31,20 @@ fn field_label(text: &str) -> Label {
lbl
}
fn row(label: &str, control: &impl IsA<gtk4::Widget>) -> GBox {
/// A label + control row, the same layout every `*_row` helper below uses.
/// Styled as its own small card (background + padding + rounded corners) so
/// a page of settings reads as a list of distinct rows, not a flat column of
/// labels with the control floating far away at the window's edge — which is
/// what plain edge-aligned rows look like once [`view_scaffold`] stops
/// letting the content column stretch to the full window width.
/// Exposed so panels that front live system state (not a `Doc`) — About,
/// Sound, Date & Time, Network — can still lay out rows consistently with
/// the TOML-editor panels.
pub fn row(label: &str, control: &impl IsA<gtk4::Widget>) -> GBox {
let row = GBox::new(Orientation::Horizontal, 16);
row.add_css_class("card");
row.set_margin_top(3);
row.set_margin_bottom(3);
row.append(&field_label(label));
control.set_halign(gtk4::Align::End);
control.set_valign(gtk4::Align::Center);
@ -40,6 +52,15 @@ fn row(label: &str, control: &impl IsA<gtk4::Widget>) -> GBox {
row
}
/// A label + read-only value label, for panels that only display state
/// (About's CPU/memory/disk readouts, etc).
pub fn info_row(label: &str, value: &str) -> GBox {
let value_lbl = Label::new(Some(value));
value_lbl.add_css_class("dim-label");
value_lbl.set_selectable(true);
row(label, &value_lbl)
}
/// A bold section heading with spacing above it.
pub fn section(text: &str) -> Label {
let lbl = Label::new(Some(text));
@ -56,10 +77,53 @@ pub fn hint(text: &str) -> Label {
lbl.add_css_class("dim-label");
lbl.set_xalign(0.0);
lbl.set_wrap(true);
// set_wrap alone doesn't cap a label's *natural* width — a long enough
// hint reports a natural width of its whole unwrapped string, which
// `view_scaffold`'s content column (sized to its widest child, floored
// not capped at CONTENT_MAX_WIDTH) then inherits, blowing the entire
// panel full-width. This is what makes breadsearch/breadclip render
// edge-to-edge while every other panel sits in a stable ~760px column.
lbl.set_max_width_chars(64);
lbl.set_margin_bottom(4);
lbl
}
/// Widest a settings column is allowed to grow. Without a cap, `content`
/// inherits hexpand from its rows' hexpand-ing labels and the ScrolledWindow
/// stretches it to the full window width — on a maximized/ultrawide window
/// that leaves the control on every row stranded ~1500px from its label.
const CONTENT_MAX_WIDTH: i32 = 760;
/// A centered placeholder for a panel's empty state (no snapshots yet, no
/// scan results yet, etc) — a dim icon + title + hint, instead of a single
/// small sentence lost in an otherwise-empty scroll area.
pub fn empty_state(icon_name: &str, title: &str, hint_text: &str) -> GBox {
let wrapper = GBox::new(Orientation::Vertical, 6);
wrapper.set_valign(gtk4::Align::Center);
wrapper.set_halign(gtk4::Align::Center);
wrapper.set_vexpand(true);
wrapper.set_margin_top(32);
wrapper.set_margin_bottom(32);
let icon = gtk4::Image::from_icon_name(icon_name);
icon.set_pixel_size(48);
icon.add_css_class("dim-label");
wrapper.append(&icon);
let title_lbl = Label::new(Some(title));
title_lbl.add_css_class("heading");
wrapper.append(&title_lbl);
let hint_lbl = Label::new(Some(hint_text));
hint_lbl.add_css_class("dim-label");
hint_lbl.set_justify(gtk4::Justification::Center);
hint_lbl.set_wrap(true);
hint_lbl.set_max_width_chars(48);
wrapper.append(&hint_lbl);
wrapper
}
/// 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)`.
@ -70,9 +134,22 @@ pub fn view_scaffold(title: &str) -> (GBox, GBox) {
let title_lbl = Label::new(Some(title));
title_lbl.add_css_class("title");
title_lbl.set_xalign(0.0);
// Same width+center treatment as `content` below, so the title's left
// edge lines up with the column it's heading instead of sitting flush
// against the panel edge while the column centers ~440px to its right.
title_lbl.set_halign(gtk4::Align::Center);
title_lbl.set_size_request(CONTENT_MAX_WIDTH, -1);
outer.append(&title_lbl);
let content = GBox::new(Orientation::Vertical, 8);
let content = GBox::new(Orientation::Vertical, 4);
// Explicit hexpand(false) overrides the auto-computed value GTK would
// otherwise derive from the hexpand-ing labels inside every row, and
// Center distributes the dead space evenly either side on a wide window
// instead of stranding it all on the right of a left-hugging column.
content.set_hexpand(false);
content.set_halign(gtk4::Align::Center);
content.set_size_request(CONTENT_MAX_WIDTH, -1);
let scroll = gtk4::ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_hscrollbar_policy(gtk4::PolicyType::Never);
@ -205,6 +282,200 @@ pub fn csv_row(label: &str, doc: &Doc, path: Path, placeholder: &str) -> GBox {
row(label, &entry)
}
/// Run `args` as a subprocess, streaming stdout+stderr into `log_buf` line by
/// line, then call `on_done` once the process exits. GTK widgets aren't
/// `Send`, so the child runs on its own thread and results come back over an
/// `async_channel` to a `glib::spawn_future_local` task that touches the
/// widget. Shared by every panel that shells out to a CLI tool and wants live
/// output (Packages, Network, Firewall, Firmware, ...).
pub 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 std::process::Command::new(&args[0])
.args(&args[1..])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
let _ = sender.send_blocking(format!("Error: {e}"));
return;
}
};
// 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 std::io::BufRead::lines(std::io::BufReader::new(stderr)).flatten() {
let _ = tx2.send_blocking(line);
}
});
for line in std::io::BufRead::lines(std::io::BufReader::new(stdout)).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();
});
}
/// [`stream_command_then`] with no completion callback.
pub fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) {
stream_command_then(args, log_buf, || {});
}
fn systemctl_active(unit: &str) -> bool {
std::process::Command::new("systemctl")
.args(["--user", "is-active", "--quiet", unit])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn systemctl_enabled(unit: &str) -> bool {
std::process::Command::new("systemctl")
.args(["--user", "is-enabled", "--quiet", unit])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// Live systemd `--user` unit status plus Start/Stop/Restart/Logs controls —
/// every bread-ecosystem panel whose app is actually a daemon (not just a
/// config file) gets this, instead of only ever being able to edit the TOML
/// and hope the running process picks it up.
///
/// `critical` guards Stop behind a confirm dialog — for a unit like `breadd`
/// (the whole desktop's event backbone), a bare click currently stops it as
/// casually as an optional sync helper; everything else can stay one-click.
///
/// `has_config` adds a one-line note that Restart (not Save) is what applies
/// config changes below — previously only breadsearch's panel said this,
/// inconsistently, in its own hint text; bread/breadbox/breadcrumbs edit a
/// running daemon's TOML and never mentioned the running process won't
/// notice until restarted.
pub fn service_control(unit: &'static str, critical: bool, has_config: bool) -> GBox {
let wrapper = GBox::new(Orientation::Vertical, 4);
wrapper.append(&section("Service"));
let status_lbl = Label::new(None);
status_lbl.add_css_class("dim-label");
status_lbl.set_selectable(true);
wrapper.append(&row(unit, &status_lbl));
let enabled_lbl = Label::new(None);
enabled_lbl.add_css_class("dim-label");
wrapper.append(&row("Starts at login", &enabled_lbl));
let btn_row = GBox::new(Orientation::Horizontal, 8);
btn_row.set_margin_top(4);
let toggle_btn = Button::new();
let restart_btn = Button::with_label("Restart");
let logs_btn = Button::with_label("View logs");
// Rc<dyn Fn()> rather than a plain closure: three different button
// handlers all need to re-run this after their action completes, and
// plain closures aren't Clone.
let refresh: Rc<dyn Fn()> = {
let status_lbl = status_lbl.clone();
let enabled_lbl = enabled_lbl.clone();
let toggle_btn = toggle_btn.clone();
Rc::new(move || {
let active = systemctl_active(unit);
status_lbl.set_text(if active { "Running" } else { "Stopped" });
toggle_btn.set_label(if active { "Stop" } else { "Start" });
enabled_lbl.set_text(if systemctl_enabled(unit) { "Yes" } else { "No" });
})
};
refresh();
let do_toggle: Rc<dyn Fn()> = {
let refresh = refresh.clone();
Rc::new(move || {
let verb = if systemctl_active(unit) { "stop" } else { "start" };
let log_buf = gtk4::TextBuffer::new(None);
let refresh = refresh.clone();
stream_command_then(&["systemctl", "--user", verb, unit], log_buf, move || {
refresh();
});
})
};
{
let do_toggle = do_toggle.clone();
toggle_btn.connect_clicked(move |btn| {
if critical && systemctl_active(unit) {
let window = btn.root().and_then(|r| r.downcast::<gtk4::Window>().ok());
let dialog = AlertDialog::builder()
.message(format!("Stop {unit}?"))
.detail(
"This is a core part of the desktop's event handling — \
stopping it may affect other bread apps until it's \
restarted.",
)
.buttons(["Cancel", "Stop"])
.cancel_button(0)
.default_button(0)
.build();
let do_toggle = do_toggle.clone();
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
if result == Ok(1) {
do_toggle();
}
});
} else {
do_toggle();
}
});
}
{
let refresh = refresh.clone();
restart_btn.connect_clicked(move |_| {
let log_buf = gtk4::TextBuffer::new(None);
let refresh = refresh.clone();
stream_command_then(&["systemctl", "--user", "restart", unit], log_buf, move || {
refresh();
});
});
}
logs_btn.connect_clicked(move |_| {
let _ = std::process::Command::new("kitty")
.args(["-e", "journalctl", "--user", "-u", unit, "-f"])
.spawn();
});
btn_row.append(&toggle_btn);
btn_row.append(&restart_btn);
btn_row.append(&logs_btn);
wrapper.append(&btn_row);
if has_config {
wrapper.append(&hint(
"Save (below) only writes the config file — click Restart above \
for the running service to pick up the change.",
));
}
wrapper
}
/// 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);

View file

@ -4,6 +4,12 @@ use gtk4::{Application, ApplicationWindow, Orientation, Paned, Stack};
use super::sidebar;
use super::views;
// About is a settings app's most GNOME-Settings-like landing page —
// identifies the machine, no config editor thrown at you first. Defined
// once here and threaded through to both the `Stack` and the sidebar's
// initial selection, so the two can't independently drift out of sync.
const DEFAULT_PAGE: &str = "about";
pub fn build_ui(app: &Application) {
let window = ApplicationWindow::builder()
.application(app)
@ -15,30 +21,38 @@ pub fn build_ui(app: &Application) {
crate::theme::load(&WidgetExt::display(&window));
let hpaned = Paned::new(Orientation::Horizontal);
hpaned.set_position(190);
hpaned.set_position(210);
hpaned.set_shrink_start_child(false);
hpaned.set_resize_start_child(false);
let (sidebar_box, list) = sidebar::build();
let (sidebar_box, list) = sidebar::build(DEFAULT_PAGE);
let stack = Stack::new();
stack.set_hexpand(true);
stack.set_vexpand(true);
stack.add_named(&views::about::build(), Some("about"));
stack.add_named(&views::network::build(), Some("network"));
stack.add_named(&views::sound::build(), Some("sound"));
stack.add_named(&views::datetime::build(), Some("datetime"));
stack.add_named(&views::power::build(), Some("power"));
stack.add_named(&views::firewall::build(), Some("firewall"));
stack.add_named(&views::users::build(), Some("users"));
stack.add_named(&views::snapshots::build(), Some("snapshots"));
stack.add_named(&views::packages::build(), Some("packages"));
stack.add_named(&views::firmware::build(), Some("firmware"));
stack.add_named(&views::aur::build(), Some("aur"));
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::breadclip::build(), Some("breadclip"));
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");
stack.set_visible_child_name(DEFAULT_PAGE);
{
let stack = stack.clone();

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

@ -10,6 +10,19 @@ 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.
# ---------------------------------------------------------------------------
@ -52,11 +65,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 +120,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 +152,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 +176,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

View file

@ -30,6 +30,15 @@ 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
@ -161,6 +170,12 @@ 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,
@ -272,6 +287,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

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
}