Compare commits

..

No commits in common. "22a8db09a6f2b4be6cc19f9d5f6207fe49e8a6b4" and "41e7aaf9fcf08d62d4932faf02b0ae2d4d84a5c7" have entirely different histories.

35 changed files with 320 additions and 2845 deletions

View file

@ -7,13 +7,8 @@ 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)
# 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).
# RELEASE_TOKEN — Forgejo API token with write:repository scope
# MIRROR_TOKEN — GitHub personal access token with repo scope (already used by mirror.yml)
on:
push:
@ -134,35 +129,14 @@ jobs:
bash build-local.sh
ls -lh /bos-out/*.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
- name: Create Forgejo release and upload ISO
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
@ -183,41 +157,35 @@ jobs:
\"tag_name\": \"${TAG}\",
\"name\": \"BOS ${TAG}\",
\"prerelease\": false,
\"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.\"
\"body\": \"ISO image attached below.\\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}"
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 "
# 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 "
import json,sys
assets=json.load(sys.stdin)
match=[a['id'] for a in assets if a['name']=='${name}']
match=[a['id'] for a in assets if a['name']=='${ISO_NAME}']
print(match[0] if match else '')
" 2>/dev/null || true)
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}" \
-F "attachment=@${file};filename=${name}" \
"http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets"
echo "Uploaded: ${name}"
}
upload_asset "/bos-out/${ISO_NAME}"
upload_asset "/bos-out/SHA256SUMS"
upload_asset "/bos-out/SHA256SUMS.asc"
if [ -n "${ASSET_ID}" ]; then
curl -fsS -X DELETE \
-H "Authorization: token ${FORGEJO_TOKEN}" \
"http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
echo "Removed existing ${ISO_NAME} asset"
fi
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}"
- name: Create GitHub release
env:
@ -228,7 +196,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), 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.' \
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.' \
"${FORGEJO_URL}" "${VERSION}" > /tmp/gh-release-notes.md
gh release create "${TAG}" \

View file

@ -1,36 +0,0 @@
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.6.0"
version = "0.4.1"
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.10#17d1bb85801b9a8c195b64c02d288cd662c9c780"
source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.8#77417d552130281ff787e07d52541eb25e9d533b"
dependencies = [
"dirs",
"gtk4",

View file

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

100
README.md
View file

@ -20,23 +20,17 @@ 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 + breadgreet (bread-ecosystem's own greeter, under `cage`)
→ Hyprland session.
- **Login**: greetd + tuigreet → 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
@ -79,30 +73,11 @@ to the Tailscale-reachable Forgejo registry for the build.
### Why some packages are in-house
`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
```
`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.
## Testing in a VM
@ -120,40 +95,21 @@ avoid memory pressure.
## bos-settings
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.
`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:
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 |
| 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 |
Build standalone:
@ -210,19 +166,9 @@ 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**: 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).
- **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.
- **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.6.0"
version = "0.4.1"
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.10", features = ["gtk"] }
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"

View file

@ -14,32 +14,6 @@ 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,89 +1,45 @@
use gtk4::prelude::*;
use gtk4::{Box as GBox, Image, Label, ListBox, ListBoxRow, Orientation};
use gtk4::{Box as GBox, 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,
}
const fn item(id: &'static str, label: &'static str, icon: &'static str) -> SidebarItem {
SidebarItem { id, label, sublabel: None, icon }
}
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_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] = &[
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"),
SidebarItem { id: "snapshots", label: "Snapshots" },
SidebarItem { id: "packages", label: "Packages" },
SidebarItem { id: "hyprland", label: "Hyprland" },
];
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) {
pub fn build() -> (GBox, ListBox) {
let vbox = GBox::new(Orientation::Vertical, 0);
vbox.add_css_class("sidebar");
vbox.set_width_request(210);
vbox.set_width_request(190);
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::Single);
list.add_css_class("sidebar");
append_section(&list, "Apps", APPS_ITEMS);
append_section(&list, "System", SYSTEM_ITEMS);
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() == default_id => {
Some(row) if row.widget_name() == "bread" => {
list.select_row(Some(&row));
break;
}
@ -95,47 +51,24 @@ pub fn build(default_id: &str) -> (GBox, ListBox) {
(vbox, list)
}
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);
}
fn append_section(list: &ListBox, title: &str, items: &[SidebarItem]) {
let header_row = ListBoxRow::new();
header_row.set_selectable(false);
header_row.set_activatable(false);
let header_lbl = Label::new(Some(title));
header_lbl.add_css_class("section-header");
header_lbl.set_xalign(0.0);
header_row.set_child(Some(&header_lbl));
list.append(&header_row);
for entry in items {
for item in items {
let row = ListBoxRow::new();
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));
row.set_widget_name(item.id);
let lbl = Label::new(Some(item.label));
lbl.set_xalign(0.0);
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));
lbl.set_margin_top(2);
lbl.set_margin_bottom(2);
row.set_child(Some(&lbl));
list.append(&row);
}
}

View file

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

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

View file

@ -2,8 +2,6 @@ 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")
}
@ -12,42 +10,37 @@ pub fn build() -> GBox {
let path = css_path();
let existing_css = std::fs::read_to_string(&path).unwrap_or_default();
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.",
let vbox = GBox::new(Orientation::Vertical, 12);
vbox.add_css_class("view-content");
let title = Label::new(Some("breadbar"));
title.add_css_class("title");
title.set_xalign(0.0);
vbox.append(&title);
let subtitle = Label::new(Some(
"CSS overrides for breadbar. Leave empty to use the default bread theme.",
));
subtitle.set_xalign(0.0);
subtitle.set_margin_bottom(8);
subtitle.set_wrap(true);
vbox.append(&subtitle);
let buf = gtk4::TextBuffer::new(None);
buf.set_text(&existing_css);
let text_view = TextView::with_buffer(&buf);
text_view.set_monospace(true);
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));
editor_card.append(&scroll);
content.append(&editor_card);
vbox.append(&scroll);
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");
@ -62,18 +55,7 @@ pub fn build() -> GBox {
}
match std::fs::write(&path, text.as_str()) {
Ok(()) => {
// 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" });
status_lbl.set_text("Saved");
let lbl = status_lbl.clone();
glib::timeout_add_seconds_local(3, move || {
lbl.set_text("");
@ -87,7 +69,7 @@ pub fn build() -> GBox {
btn_row.append(&save_btn);
btn_row.append(&status_lbl);
outer.append(&btn_row);
vbox.append(&btn_row);
outer
vbox
}

View file

@ -15,7 +15,6 @@ 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 {
@ -63,17 +62,6 @@ 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);
@ -140,13 +128,21 @@ 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 (outer, content) = w::view_scaffold("Launcher");
content.append(&w::service_control("breadbox-sync.service", false, true));
let vbox = GBox::new(Orientation::Vertical, 12);
vbox.add_css_class("view-content");
content.append(&w::section("Contexts"));
content.append(&w::hint(
let title = Label::new(Some("breadbox"));
title.add_css_class("title");
title.set_xalign(0.0);
vbox.append(&title);
let subtitle = Label::new(Some(
"Launcher contexts — each lists, in priority order, the apps/categories surfaced first.",
));
subtitle.set_xalign(0.0);
subtitle.set_wrap(true);
subtitle.set_margin_bottom(8);
vbox.append(&subtitle);
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
@ -155,14 +151,12 @@ pub fn build() -> GBox {
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_child(Some(&list));
content.append(&scroll);
vbox.append(&scroll);
let btn_row = GBox::new(Orientation::Horizontal, 8);
btn_row.set_margin_top(8);
// 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();
@ -174,14 +168,12 @@ 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();
@ -202,9 +194,11 @@ pub fn build() -> GBox {
}
});
}
btn_row.append(&add_btn);
btn_row.append(&save_btn);
btn_row.append(&status_lbl);
outer.append(&btn_row);
vbox.append(&btn_row);
outer
vbox
}

View file

@ -1,33 +0,0 @@
//! 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, Switch,
Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch,
};
use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
@ -334,8 +334,20 @@ 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, content) = w::view_scaffold("Wi-Fi Profiles");
content.append(&w::service_control("breadcrumbs.service", false, true));
let outer = GBox::new(Orientation::Vertical, 8);
outer.add_css_class("view-content");
let title = Label::new(Some("breadcrumbs"));
title.add_css_class("title");
title.set_xalign(0.0);
outer.append(&title);
let content = GBox::new(Orientation::Vertical, 8);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_hscrollbar_policy(gtk4::PolicyType::Never);
scroll.set_child(Some(&content));
outer.append(&scroll);
// [settings] — edited in place on the shared doc
content.append(&w::section("Settings"));

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

View file

@ -32,21 +32,18 @@ fn current_wallpaper() -> Option<PathBuf> {
fn refresh_preview(preview: &Image, path_lbl: &Label) {
match current_wallpaper() {
Some(path) => {
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()));
path_lbl.set_text(&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("Wallpaper");
let (outer, c) = w::view_scaffold("breadpaper");
c.append(&w::hint(
"Sets the desktop wallpaper, generates a matching pywal palette, and \
@ -54,30 +51,23 @@ 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_card.append(&preview);
preview.set_margin_top(8);
preview.set_margin_bottom(8);
c.append(&preview);
let path_lbl = Label::new(None);
path_lbl.set_wrap(true);
path_lbl.add_css_class("dim-label");
preview_card.append(&path_lbl);
c.append(&preview_card);
c.append(&path_lbl);
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,8 +18,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("File Search");
c.append(&w::service_control("breadmill.service", false, true));
let (outer, c) = w::view_scaffold("breadsearch");
c.append(&w::section("Power"));
c.append(&w::hint(
@ -35,24 +34,22 @@ pub fn build() -> GBox {
));
c.append(&w::section("Model"));
// 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.
// 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.
c.append(&w::dropdown_row(
"Compute backend",
&doc,
&["model", "backend"],
&["cpu", "npu", "rocm", "cuda", "openvino"],
&["cpu", "npu", "rocm", "cuda"],
"cpu",
));
c.append(&w::hint(
"npu = AMD Ryzen AI (XDNA), rocm = AMD GPU (via MIGraphX), cuda = \
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 \
NVIDIA 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 \
@ -110,6 +107,10 @@ 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

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

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

@ -1,159 +0,0 @@
//! 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,10 +1,8 @@
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button};
use gtk4::{Box as GBox, Button, Label, Orientation};
use std::process::Command;
use crate::ui::widgets as w;
fn get_monitors() -> Vec<(String, String)> {
fn get_monitors() -> Vec<String> {
let Ok(output) = Command::new("hyprctl").args(["monitors", "-j"]).output() else {
return Vec::new();
};
@ -19,7 +17,7 @@ fn get_monitors() -> Vec<(String, String)> {
let w = m.get("width")?.as_u64()?;
let h = m.get("height")?.as_u64()?;
let refresh = m.get("refreshRate")?.as_f64()?;
Some((name.to_string(), format!("{w}x{h} @ {refresh:.0}Hz")))
Some(format!("{name} {w}x{h} @ {refresh:.0}Hz"))
})
.collect()
}
@ -40,46 +38,58 @@ fn open_in_terminal(path: &std::path::Path) {
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Display");
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);
content.append(&w::section("Connected monitors"));
let monitors = get_monitors();
if monitors.is_empty() {
content.append(&w::hint("No monitors detected (is Hyprland running?)"));
let lbl = Label::new(Some("No monitors detected (is Hyprland running?)"));
lbl.set_xalign(0.0);
vbox.append(&lbl);
} else {
for (name, mode) in &monitors {
content.append(&w::info_row(name, mode));
for mon in &monitors {
let lbl = Label::new(Some(mon));
lbl.set_xalign(0.0);
lbl.add_css_class("monospace");
vbox.append(&lbl);
}
}
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));
}
content.append(&open_btn);
vbox.append(&open_btn);
// Keybinds are defined inline in hyprland.lua (no separate file); point
// this at the shipped cheat sheet instead of a keybinds.conf that has
// never existed on BOS.
let keybinds_btn = Button::with_label("View keybinds cheat sheet");
keybinds_btn.set_margin_top(8);
keybinds_btn.set_halign(gtk4::Align::Start);
{
let kb_path = std::path::PathBuf::from("/usr/share/bos/keybinds.txt");
keybinds_btn.connect_clicked(move |_| open_in_terminal(&kb_path));
}
content.append(&keybinds_btn);
vbox.append(&keybinds_btn);
outer
vbox
}

View file

@ -1,20 +1,10 @@
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

@ -1,328 +0,0 @@
//! 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,11 +1,13 @@
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());
@ -41,7 +43,61 @@ fn read_installed() -> HashMap<String, String> {
.collect()
}
fn populate_packages(list: &ListBox, log_buf: &gtk4::TextBuffer, log_view: &TextView) {
fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) {
stream_command_then(args, log_buf, || {});
}
/// Same as stream_command, but runs `on_done` once the process's output
/// stream ends (i.e. the child has exited) — the channel closes when both
/// the stdout- and stderr-forwarding threads drop their sender, which only
/// happens after `child.wait()` returns.
fn stream_command_then(args: &[&str], log_buf: gtk4::TextBuffer, on_done: impl FnOnce() + 'static) {
let (sender, receiver) = async_channel::bounded::<String>(256);
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
std::thread::spawn(move || {
let mut child = match Command::new(&args[0])
.args(&args[1..])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
let _ = sender.send_blocking(format!("Error: {e}"));
return;
}
};
// Merge stderr into the channel too.
// Both are Some because we spawned with Stdio::piped() above.
let stdout = child.stdout.take().expect("stdout piped");
let stderr = child.stderr.take().expect("stderr piped");
let tx2 = sender.clone();
let stderr_thread = std::thread::spawn(move || {
for line in BufReader::new(stderr).lines().flatten() {
let _ = tx2.send_blocking(line);
}
});
for line in BufReader::new(stdout).lines().flatten() {
let _ = sender.send_blocking(line);
}
let _ = child.wait();
let _ = stderr_thread.join();
});
glib::spawn_future_local(async move {
while let Ok(line) = receiver.recv().await {
let mut end = log_buf.end_iter();
log_buf.insert(&mut end, &format!("{line}\n"));
}
on_done();
});
}
fn populate_packages(list: &ListBox, log_buf: &gtk4::TextBuffer) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
@ -50,11 +106,13 @@ fn populate_packages(list: &ListBox, log_buf: &gtk4::TextBuffer, log_view: &Text
if packages.is_empty() {
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"package-x-generic-symbolic",
"No bakery packages found",
"~/.local/state/bakery/installed.json is missing or empty.",
)));
let lbl = Label::new(Some(
"No bakery packages found (~/.local/state/bakery/installed.json)",
));
lbl.set_margin_top(8);
lbl.set_margin_bottom(8);
lbl.set_margin_start(8);
row.set_child(Some(&lbl));
list.append(&row);
return;
}
@ -66,9 +124,10 @@ fn populate_packages(list: &ListBox, log_buf: &gtk4::TextBuffer, log_view: &Text
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);
hbox.set_margin_top(6);
hbox.set_margin_bottom(6);
hbox.set_margin_start(8);
hbox.set_margin_end(8);
let name_lbl = Label::new(Some(name));
name_lbl.set_hexpand(true);
@ -81,14 +140,11 @@ fn populate_packages(list: &ListBox, log_buf: &gtk4::TextBuffer, log_view: &Text
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
@ -96,7 +152,7 @@ fn populate_packages(list: &ListBox, log_buf: &gtk4::TextBuffer, log_view: &Text
// 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, &log_view2);
populate_packages(&list2, &log_buf2);
});
});
}
@ -110,32 +166,35 @@ fn populate_packages(list: &ListBox, log_buf: &gtk4::TextBuffer, log_view: &Text
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Packages");
content.append(&w::hint(
"Bread ecosystem packages installed via bakery, and system packages via pacman below.",
));
let vbox = GBox::new(Orientation::Vertical, 0);
vbox.add_css_class("view-content");
let title = Label::new(Some("Packages"));
title.add_css_class("title");
title.set_xalign(0.0);
vbox.append(&title);
let subtitle = Label::new(Some("Bread ecosystem packages installed via bakery, and system packages via pacman below."));
subtitle.set_xalign(0.0);
subtitle.set_margin_bottom(16);
vbox.append(&subtitle);
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
let log_buf = gtk4::TextBuffer::new(None);
populate_packages(&list, &log_buf);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_child(Some(&list));
vbox.append(&scroll);
// 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);
@ -147,27 +206,23 @@ 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);
content.append(&btn_row);
vbox.append(&btn_row);
// ---------------------------------------------------------------------
// System packages (pacman) — the other update channel. bakery only
@ -177,8 +232,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.
// ---------------------------------------------------------------------
content.append(&w::section("System packages (pacman)"));
content.append(&w::hint(
vbox.append(&w::section("System packages (pacman)"));
vbox.append(&w::hint(
"Base system, kernel, bos-settings, and republished AUR packages — \
the other half of what `bos-update` covers. Needs your password \
(polkit) since pacman requires root.",
@ -189,17 +244,15 @@ 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);
content.append(&pacman_btn_row);
vbox.append(&pacman_btn_row);
content.append(&log_view);
vbox.append(&log_view);
outer
vbox
}

View file

@ -1,193 +0,0 @@
//! 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,8 +4,6 @@ use gtk4::{
};
use std::process::Command;
use crate::ui::widgets as w;
#[derive(Clone)]
struct SnapshotRow {
number: String,
@ -52,9 +50,7 @@ fn list_snapshots() -> Vec<SnapshotRow> {
.collect()
}
/// 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 {
fn populate_list(list: &ListBox) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
@ -62,14 +58,13 @@ fn populate_list(list: &ListBox) -> bool {
if snapshots.is_empty() {
let row = ListBoxRow::new();
row.set_selectable(false);
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).",
)));
let lbl = Label::new(Some("No snapshots found (snapper may not be configured yet)"));
lbl.set_margin_top(8);
lbl.set_margin_bottom(8);
lbl.set_margin_start(8);
row.set_child(Some(&lbl));
list.append(&row);
return true;
return;
}
for snap in &snapshots {
let row = ListBoxRow::new();
@ -99,24 +94,33 @@ fn populate_list(list: &ListBox) -> bool {
row.set_child(Some(&hbox));
list.append(&row);
}
false
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Snapshots");
content.append(&w::hint(
let vbox = GBox::new(Orientation::Vertical, 0);
vbox.add_css_class("view-content");
let title = Label::new(Some("Snapshots"));
title.add_css_class("title");
title.set_xalign(0.0);
vbox.append(&title);
let subtitle = Label::new(Some(
"System snapshots created by snap-pac on each pacman transaction. \
Boot into one from the GRUB menu to recover; delete old ones here.",
));
subtitle.set_xalign(0.0);
subtitle.set_margin_bottom(16);
vbox.append(&subtitle);
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::Single);
let empty = populate_list(&list);
populate_list(&list);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_child(Some(&list));
content.append(&scroll);
vbox.append(&scroll);
let btn_row = GBox::new(Orientation::Horizontal, 8);
btn_row.set_margin_top(12);
@ -125,17 +129,11 @@ 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 |_| {
let empty = populate_list(&list);
rollback_btn.set_sensitive(!empty);
delete_btn.set_sensitive(!empty);
populate_list(&list);
});
}
@ -180,14 +178,11 @@ 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());
@ -202,8 +197,6 @@ 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 }
@ -225,14 +218,10 @@ 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 {
let empty = populate_list(&list);
rollback_btn.set_sensitive(!empty);
delete_btn.set_sensitive(!empty);
populate_list(&list);
} else {
let err = AlertDialog::builder()
.message("Delete failed")
@ -250,7 +239,7 @@ pub fn build() -> GBox {
btn_row.append(&refresh_btn);
btn_row.append(&rollback_btn);
btn_row.append(&delete_btn);
outer.append(&btn_row);
vbox.append(&btn_row);
outer
vbox
}

View file

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

@ -1,293 +0,0 @@
//! 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, AlertDialog, Box as GBox, Button, DropDown, Entry, Expression, Label, Orientation,
Adjustment, Box as GBox, Button, DropDown, Entry, Expression, Label, Orientation,
SpinButton, StringList, Switch,
};
use toml_edit::DocumentMut;
@ -31,20 +31,8 @@ fn field_label(text: &str) -> Label {
lbl
}
/// 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 {
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);
@ -52,15 +40,6 @@ pub 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));
@ -77,53 +56,10 @@ 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)`.
@ -134,22 +70,9 @@ 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, 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 content = GBox::new(Orientation::Vertical, 8);
let scroll = gtk4::ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_hscrollbar_policy(gtk4::PolicyType::Never);
@ -282,200 +205,6 @@ 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,12 +4,6 @@ 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)
@ -21,38 +15,30 @@ pub fn build_ui(app: &Application) {
crate::theme::load(&WidgetExt::display(&window));
let hpaned = Paned::new(Orientation::Horizontal);
hpaned.set_position(210);
hpaned.set_position(190);
hpaned.set_shrink_start_child(false);
hpaned.set_resize_start_child(false);
let (sidebar_box, list) = sidebar::build(DEFAULT_PAGE);
let (sidebar_box, list) = sidebar::build();
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"));
stack.set_visible_child_name(DEFAULT_PAGE);
// Default to the bread panel — Snapshots was previously first, an odd
// first impression for a settings app named after the bread ecosystem.
stack.set_visible_child_name("bread");
{
let stack = stack.clone();

View file

@ -19,15 +19,3 @@ 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,19 +10,6 @@ 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.
# ---------------------------------------------------------------------------
@ -65,44 +52,11 @@ 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=.*\b${BASE_HOOK}\b\)/\1 plymouth/" /etc/mkinitcpio.conf \
sed -i 's/^\(HOOKS=.*\budev\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
# ---------------------------------------------------------------------------
@ -120,17 +74,6 @@ 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"
@ -152,20 +95,18 @@ 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 "${CRYPT_MODULES[@]}" \
--bootloader-id=BOS --recheck \
|| echo "WARN: grub-install (nvram) failed"
grub-install --target=x86_64-efi --efi-directory=/boot/efi \
--removable --recheck "${CRYPT_MODULES[@]}" \
--removable --recheck \
|| echo "WARN: grub-install (removable) failed"
else
ROOT_DEV="$(findmnt -no SOURCE / | sed 's/\[.*\]//')"
ROOT_DISK="$(lsblk -no pkname "$ROOT_DEV" 2>/dev/null)"
if [[ -n "$ROOT_DISK" ]]; then
grub-install --target=i386-pc --recheck "${CRYPT_MODULES[@]}" "/dev/$ROOT_DISK" \
grub-install --target=i386-pc --recheck "/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"
@ -176,33 +117,6 @@ 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,15 +30,6 @@ 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
@ -170,12 +161,6 @@ 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,
@ -287,15 +272,6 @@ 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

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