diff --git a/.forgejo/workflows/release-iso.yml b/.forgejo/workflows/release-iso.yml index aff0bef..27c2a34 100644 --- a/.forgejo/workflows/release-iso.yml +++ b/.forgejo/workflows/release-iso.yml @@ -7,8 +7,13 @@ name: Build and release ISO # (GitHub releases cannot host files larger than 2 GB). # # Required secrets: -# RELEASE_TOKEN — Forgejo API token with write:repository scope -# MIRROR_TOKEN — GitHub personal access token with repo scope (already used by mirror.yml) +# RELEASE_TOKEN — Forgejo API token with write:repository scope +# MIRROR_TOKEN — GitHub personal access token with repo scope (already used by mirror.yml) +# GPG_PRIVATE_KEY — armoured secret key for the dedicated "BOS Release Signing" +# identity (releases@breadway.dev); public half is committed +# at KEYS.asc for verification. No passphrase (CI-only key, +# access controlled via the Forgejo secret store, not a +# passphrase nobody could type non-interactively anyway). on: push: @@ -129,14 +134,35 @@ jobs: bash build-local.sh ls -lh /bos-out/*.iso - - name: Create Forgejo release and upload ISO + - name: Checksum and sign + env: + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + run: | + set -euo pipefail + VERSION="${{ steps.vars.outputs.version }}" + ISO=$(ls /bos-out/*.iso | head -1) + ISO_NAME="bos-${VERSION}-x86_64.iso" + cd /bos-out + mv "$(basename "$ISO")" "$ISO_NAME" + + sha256sum "$ISO_NAME" > SHA256SUMS + cat SHA256SUMS + + pacman -S --noconfirm --needed gnupg + export GNUPGHOME=/tmp/gnupg-release + mkdir -m 700 -p "$GNUPGHOME" + echo "$GPG_PRIVATE_KEY" | gpg --batch --import + gpg --batch --yes --local-user releases@breadway.dev \ + --detach-sign --armor -o SHA256SUMS.asc SHA256SUMS + echo "Signed SHA256SUMS -> SHA256SUMS.asc" + + - name: Create Forgejo release and upload assets env: FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }} run: | set -euo pipefail TAG="${{ steps.vars.outputs.tag }}" VERSION="${{ steps.vars.outputs.version }}" - ISO=$(ls /bos-out/*.iso | head -1) ISO_NAME="bos-${VERSION}-x86_64.iso" # Use an existing release for this tag if one exists (e.g. created @@ -157,35 +183,41 @@ jobs: \"tag_name\": \"${TAG}\", \"name\": \"BOS ${TAG}\", \"prerelease\": false, - \"body\": \"ISO image attached below.\\n\\nSee the [README](https://github.com/Breadway/bos#testing-in-a-vm) for VM testing instructions.\" + \"body\": \"ISO image attached below. Verify with SHA256SUMS + SHA256SUMS.asc (signed by the BOS Release Signing key — see KEYS.asc in the repo).\\n\\nSee the [README](https://github.com/Breadway/bos#testing-in-a-vm) for VM testing instructions.\" }") RELEASE_ID=$(echo "${RELEASE}" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") fi echo "Using release ID: ${RELEASE_ID}" - # Remove any existing asset with the same name before uploading - ASSET_ID=$(curl -sf \ - -H "Authorization: token ${FORGEJO_TOKEN}" \ - "http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets" \ - | python3 -c " + upload_asset() { + local file="$1" name + name="$(basename "$file")" + local asset_id + asset_id=$(curl -sf \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + "http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets" \ + | python3 -c " import json,sys assets=json.load(sys.stdin) - match=[a['id'] for a in assets if a['name']=='${ISO_NAME}'] + match=[a['id'] for a in assets if a['name']=='${name}'] print(match[0] if match else '') " 2>/dev/null || true) - - if [ -n "${ASSET_ID}" ]; then - curl -fsS -X DELETE \ + if [ -n "${asset_id}" ]; then + curl -fsS -X DELETE \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + "http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets/${asset_id}" + echo "Removed existing ${name} asset" + fi + curl -fsS -X POST \ -H "Authorization: token ${FORGEJO_TOKEN}" \ - "http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets/${ASSET_ID}" - echo "Removed existing ${ISO_NAME} asset" - fi + -F "attachment=@${file};filename=${name}" \ + "http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets" + echo "Uploaded: ${name}" + } - curl -fsS -X POST \ - -H "Authorization: token ${FORGEJO_TOKEN}" \ - -F "attachment=@${ISO};filename=${ISO_NAME}" \ - "http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets" - echo "Uploaded: ${ISO_NAME}" + upload_asset "/bos-out/${ISO_NAME}" + upload_asset "/bos-out/SHA256SUMS" + upload_asset "/bos-out/SHA256SUMS.asc" - name: Create GitHub release env: @@ -196,7 +228,7 @@ jobs: VERSION="${{ steps.vars.outputs.version }}" FORGEJO_URL="https://git.breadway.dev/${GITHUB_REPOSITORY}/releases/tag/${TAG}" - printf '**Download ISO:** %s\n\nGitHub releases cannot host files >2 GB; the `bos-%s-x86_64.iso` (~2.5 GB) is on Forgejo.\n\nSee the [README](https://github.com/Breadway/bos#testing-in-a-vm) for VM testing instructions.' \ + printf '**Download ISO:** %s\n\nGitHub releases cannot host files >2 GB; the `bos-%s-x86_64.iso` (~2.5 GB), SHA256SUMS, and SHA256SUMS.asc (signed by the BOS Release Signing key — public half at [KEYS.asc](https://github.com/Breadway/bos/blob/main/KEYS.asc)) are all on Forgejo.\n\nSee the [README](https://github.com/Breadway/bos#testing-in-a-vm) for VM testing instructions.' \ "${FORGEJO_URL}" "${VERSION}" > /tmp/gh-release-notes.md gh release create "${TAG}" \ diff --git a/.forgejo/workflows/yay-bin.yml b/.forgejo/workflows/yay-bin.yml new file mode 100644 index 0000000..c31ad58 --- /dev/null +++ b/.forgejo/workflows/yay-bin.yml @@ -0,0 +1,36 @@ +name: Build and publish yay-bin + +# yay (and every AUR helper) is AUR-only — not in Arch's official repos — so +# BOS maintains an in-house PKGBUILD and publishes the built package to the +# [breadway] repo, same as bibata-cursor-theme and calamares. Prebuilt +# release tarball, no build step. +on: + push: + paths: + - 'packaging/yay-bin/**' + workflow_dispatch: + +jobs: + yay-bin: + runs-on: [self-hosted, hestia] + container: + image: archlinux:latest + steps: + - name: Build and publish + env: + PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + pacman -Syu --noconfirm base-devel git + useradd -m builder + git config --global --add safe.directory '*' + git clone --depth 1 --branch "${GITHUB_REF_NAME}" \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src + chown -R builder:builder /home/builder/src + su builder -c "cd /home/builder/src/packaging/yay-bin && makepkg -f --noconfirm --nocheck" + PKG=$(find /home/builder/src/packaging/yay-bin -name '*.pkg.tar.zst' | head -1) + curl -fsS -X PUT \ + -H "Authorization: token ${PUBLISH_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${PKG}" \ + "https://git.breadway.dev/api/packages/Breadway/arch/os" diff --git a/Cargo.lock b/Cargo.lock index f29bbea..bc9c049 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,7 +28,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bos-settings" -version = "0.4.0" +version = "0.6.0" dependencies = [ "async-channel", "bread-theme", @@ -43,7 +43,7 @@ dependencies = [ [[package]] name = "bread-theme" version = "0.2.3" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.8#77417d552130281ff787e07d52541eb25e9d533b" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.10#17d1bb85801b9a8c195b64c02d288cd662c9c780" dependencies = [ "dirs", "gtk4", diff --git a/KEYS.asc b/KEYS.asc new file mode 100644 index 0000000..fe380fd --- /dev/null +++ b/KEYS.asc @@ -0,0 +1,15 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEakhwGhYJKwYBBAHaRw8BAQdA/sZ/GYec5M2MD+w20mVF5tMUhGji210Dg7zL +TAhNsg60WUJPUyBSZWxlYXNlIFNpZ25pbmcgKGdpdC5icmVhZHdheS5kZXYvQnJl +YWR3YXkvYm9zIHJlbGVhc2VzIG9ubHkpIDxyZWxlYXNlc0BicmVhZHdheS5kZXY+ +iJYEExYKAD4WIQRWIDuGoRBpWufzEJNK8zI9Z4614gUCakhwGgIbIwUJA8JnAAUL +CQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRBK8zI9Z4614ggYAQDP8FTZ14i9YPKD +ARvZuP5QaYOUFhQ8uyG0CowXKy9O0AEAqYfjnvyJI3N651pVFSNUXyP16w1kMPSs +K0g3CLsztQ+4OARqSHAaEgorBgEEAZdVAQUBAQdAuJFuy2GHz5m9wXTm/PdSpLE9 +gERwHOLyM1OFuttrJW4DAQgHiH4EGBYKACYWIQRWIDuGoRBpWufzEJNK8zI9Z461 +4gUCakhwGgIbDAUJA8JnAAAKCRBK8zI9Z4614nzLAP9grcIFsAAeCyVKhziHmpXq +E0Hm6FfIr4sdEf63HZkyfwD/XeKeWfb3EWvVsloJrZZ9tDmR67iK52Hwl82wfFAU +cAo= +=Mrh1 +-----END PGP PUBLIC KEY BLOCK----- diff --git a/README.md b/README.md index 930804c..b736e5d 100644 --- a/README.md +++ b/README.md @@ -20,17 +20,23 @@ wiring up dotfiles, no per-tool bakery installs. - **bos-settings**: a GTK4 control panel that configures every bread\* app's config from a GUI (non-destructively), plus snapshot rollback and bakery updates. See below. -- **Login**: greetd + tuigreet → Hyprland session. +- **Login**: greetd + breadgreet (bread-ecosystem's own greeter, under `cage`) + → Hyprland session. - **Boot splash**: Plymouth `bos` theme (logo + spinner, black background). - **Theming**: global dark across GTK3 (Adwaita-dark), GTK4/libadwaita (`color-scheme: prefer-dark`), and Qt (qt5ct/qt6ct Fusion dark); Papirus-Dark icons; Bibata cursor. - **Apps**: kitty, nautilus (+ gvfs), Zen browser, VLC, loupe, gnome-text-editor, gnome-calculator, file-roller, with file associations wired in `mimeapps.list`. + `yay` ships for AUR access beyond bakery's bread ecosystem + `[breadway]`. - **Hardware**: pipewire audio, NetworkManager, BlueZ + blueman, CUPS printing with avahi mDNS discovery, TLP power management, fwupd firmware updates. - **Resilience**: btrfs + snapper + snap-pac + grub-btrfs snapshots on every pacman transaction; zram swap; ufw firewall (deny-incoming, mDNS allowed). +- **Security**: full-disk encryption (LUKS, via Calamares' built-in support — + cryptsetup + the matching mkinitcpio/GRUB wiring ship so an encrypted + install actually boots) and self-signed Secure Boot (via `sbctl`, enrolled + automatically at install time when the firmware is in Setup Mode). ## Repo layout @@ -73,11 +79,30 @@ to the Tailscale-reachable Forgejo registry for the build. ### Why some packages are in-house -`calamares`, `zen-browser-bin`, and `bibata-cursor-theme` are AUR-only. BOS -keeps a PKGBUILD for each under `packaging/` and republishes the built package -to the `[breadway]` repo via a Forgejo Actions workflow (built on the hestia -self-hosted runner, published with a scoped registry token). `bos-settings` -itself publishes the same way on a `v*` tag. +`calamares`, `zen-browser-bin`, `bibata-cursor-theme`, and `yay-bin` are +AUR-only. BOS keeps a PKGBUILD for each under `packaging/` and republishes the +built package to the `[breadway]` repo via a Forgejo Actions workflow (built +on the hestia self-hosted runner, published with a scoped registry token). +`bos-settings` itself publishes the same way on a `v*` tag. + +### Verifying a release + +Every tagged release ISO on the [Forgejo releases +page](https://git.breadway.dev/Breadway/bos/releases) ships alongside a +`SHA256SUMS` file and a detached signature `SHA256SUMS.asc`, signed by a +dedicated release-signing key (not reused from anything else): + +``` +5620 3B86 A110 695A E7F3 1093 4AF3 323D 678E B5E2 +``` + +The public half is committed at [`KEYS.asc`](KEYS.asc). To verify a download: + +```sh +gpg --import KEYS.asc +gpg --verify SHA256SUMS.asc SHA256SUMS +sha256sum -c SHA256SUMS +``` ## Testing in a VM @@ -95,21 +120,40 @@ avoid memory pressure. ## bos-settings -`bos-settings` edits each bread\* app's TOML **non-destructively**: it parses -the file with `toml_edit`, changes only the keys a view exposes, and writes it -back — preserving comments and any keys the UI doesn't model (calendar -passwords, saved-network passwords, model paths). Views: +A GTK4 settings app aiming for GNOME-Settings-style parity: not just editing +config files, but live system state and control, so day-to-day machine +administration doesn't require a terminal. -| View | Config | -|------|--------| -| bread | `bread/breadd.toml` — daemon, lua, modules, all adapters, events, notifications | -| breadbar | `breadbar/style.css` override | -| breadbox | `breadbox/config.toml` — launcher contexts | -| breadcrumbs | `breadcrumbs/breadcrumbs.toml` — settings, saved networks, profiles | -| breadpad | `breadpad/breadpad.toml` — settings, model + ollama, reminders, calendar | -| Snapshots | `snapper` list / rollback / delete | -| Packages | `bakery` installed list + updates | -| Hyprland | open config in editor + monitor list | +Bread-ecosystem TOML configs are edited **non-destructively**: `toml_edit` +parses the file, changes only the keys a view exposes, and writes it back — +preserving comments and any keys the UI doesn't model (calendar passwords, +saved-network passwords, model paths). Panels with a daemon behind them +(bread, breadbox, breadcrumbs, breadsearch, breadclip) also get live +systemd status + Start/Stop/Restart/Logs via a shared `service_control` +widget, not just the config file. + +| Panel | What it does | +|-------|--------------| +| About | System info (OS/kernel/CPU/GPU/memory/disk/uptime) + hostname | +| Network | Wi-Fi scan/connect, Ethernet status, radio toggle | +| Wi-Fi Profiles (breadcrumbs) | `breadcrumbs.toml` — settings, saved networks, profiles | +| Firewall | ufw rules: enable/disable, add/remove, view active rules | +| Sound | PipeWire output/input device + volume via `pactl` | +| Power | Battery status/health, brightness, charge limits (hardware-dependent), TLP profile (read-only) | +| Date & Time | Timezone, NTP sync toggle | +| Display (Hyprland) | Connected monitors + open `hyprland.lua` in editor | +| Users | Add/remove accounts, change passwords | +| Wallpaper (breadpaper) | Set wallpaper, drives the pywal-derived accent palette | +| Bar (breadbar) | `breadbar/style.css` override, live-reloads on save | +| Launcher (breadbox) | `breadbox/config.toml` — launcher contexts | +| Clipboard (breadclip) | breadclipd service control + "open history" | +| Notes (breadpad) | `breadpad/breadpad.toml` — settings, model + ollama, reminders, calendar | +| File Search (breadsearch) | `breadsearch/config.toml` — index/search/model + breadmill service | +| Daemon (bread) | `breadd.toml` — daemon, lua, modules, adapters, events, notifications | +| Packages | `bakery` installed list + updates, pacman system update | +| AUR | Search via `yay`; installing opens a terminal (AUR build scripts need review) | +| Firmware | `fwupd` device list + updates | +| Snapshots | `snapper` list / boot-into (grub-btrfs) / delete | Build standalone: @@ -166,9 +210,19 @@ cheatsheet in-session; first boot shows a short welcome (once). `virtio-vga-gl` + `-display gtk,gl=on` (virgl); plain software rendering is noticeably laggy. - **Wayland-first**: X11-only apps run through XWayland; a few may misbehave. -- **Secure Boot**: not configured. Boot with Secure Boot disabled, or enroll - your own keys. The installer writes both an NVRAM entry and the removable - `EFI/BOOT/BOOTX64.EFI` fallback. +- **Secure Boot**: self-signed only, via `sbctl` — BOS can't ship a + Microsoft-signed shim (that needs going through Microsoft's own paid UEFI + CA process). Post-install enrolls BOS's own keys automatically, but only + when the firmware is already in Setup Mode (no vendor keys installed yet); + otherwise it's skipped and you can run + `sudo sbctl enroll-keys --microsoft && sudo sbctl sign-all -g` yourself + later (after clearing your firmware's existing keys, if any). The installer + writes both an NVRAM entry and the removable `EFI/BOOT/BOOTX64.EFI` fallback + either way. +- **Disk encryption**: full-disk LUKS is available on the installer's "Erase + disk" page (Calamares' own checkbox) and on manually-created partitions — + BOS ships the matching `cryptsetup`/mkinitcpio/GRUB wiring so an encrypted + install actually boots (LUKS1, since GRUB doesn't support LUKS2 + Argon2id). - **Snapshots assume btrfs**: the snapper/grub-btrfs tooling expects the default btrfs subvolume layout the installer creates. diff --git a/bos-settings/Cargo.toml b/bos-settings/Cargo.toml index c6e37ac..61bb665 100644 --- a/bos-settings/Cargo.toml +++ b/bos-settings/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bos-settings" -version = "0.4.0" +version = "0.6.0" edition = "2021" [dependencies] @@ -8,7 +8,7 @@ gtk4 = { version = "0.11", features = ["v4_12"] } glib = "0.22" # Shared ecosystem theming — bos-settings loads the same generated stylesheet as # breadbar/breadbox/breadpad so the whole desktop looks consistent. -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] } serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" diff --git a/bos-settings/src/config/mod.rs b/bos-settings/src/config/mod.rs index 4b56f1c..4cc3266 100644 --- a/bos-settings/src/config/mod.rs +++ b/bos-settings/src/config/mod.rs @@ -12,13 +12,31 @@ use std::path::{Path, PathBuf}; use toml_edit::{value, Array, DocumentMut, Item, Table, Value}; -/// Load a TOML file into an editable document. A missing or unparseable file -/// yields an empty document so the UI still renders (with defaults). +/// Load a TOML file into an editable document. A missing file yields an +/// empty document so the UI still renders with defaults — normal for a fresh +/// install. A file that *exists* but fails to parse is far more dangerous: +/// falling back to an empty document there means the next Save (see +/// `save_doc`) overwrites it with only the UI-modelled keys, silently +/// destroying anything else in the file (breadpad's calendar credentials, +/// breadcrumbs' saved network passwords, ...). Back up the unparseable file +/// once before falling back, so a bad edit is always recoverable. pub fn load_doc(path: &Path) -> DocumentMut { - std::fs::read_to_string(path) - .ok() - .and_then(|s| s.parse::().ok()) - .unwrap_or_default() + let Ok(text) = std::fs::read_to_string(path) else { + return DocumentMut::default(); + }; + match text.parse::() { + Ok(doc) => doc, + Err(e) => { + let backup = PathBuf::from(format!("{}.bak", path.display())); + eprintln!( + "bos-settings: {} failed to parse ({e}); backed up to {} before falling back to defaults", + path.display(), + backup.display() + ); + let _ = std::fs::write(&backup, &text); + DocumentMut::default() + } + } } /// Write the document back to disk, creating parent dirs as needed. diff --git a/bos-settings/src/theme.rs b/bos-settings/src/theme.rs index 4bd254e..5b57ca8 100644 --- a/bos-settings/src/theme.rs +++ b/bos-settings/src/theme.rs @@ -14,6 +14,32 @@ use std::cell::RefCell; const APP_CSS: &str = "\ .view-content { padding: 24px; }\n\ .view-content > label.title { margin-bottom: 16px; }\n\ +/* Sidebar row sub-labels (the underlying binary/config name under a row's \ + human label) — smaller than the shared sheet's default dim-label size. */\n\ +.caption { font-size: 11px; }\n\ +/* bread-theme's shared sheet only overrides background-color on \ + suggested/destructive buttons, not background-image — so Adwaita's \ + built-in gradient (bright blue/red) paints over our flat colour \ + underneath it. Belongs upstream in bread-theme; patched locally here \ + until that's worth its own release. */\n\ +button.suggested-action, button.destructive-action { background-image: none; }\n\ +/* Same upstream gap for scale (Sound's volume sliders) — the shared sheet \ + has no `scale` rules at all, so they render in Adwaita's default blue. */\n\ +scale trough { background-color: alpha(@on-surface, 0.15); border-radius: 999px; min-height: 6px; background-image: none; }\n\ +scale highlight { background-color: @accent; background-image: none; border-radius: 999px; }\n\ +scale slider { background-color: @on-surface; border-radius: 999px; }\n\ +/* Destructive actions must not follow the wallpaper palette: @red is \ + pywal's color1, which can land on gold/yellow/anything depending on the \ + wallpaper (it did, this session) — making Delete/Remove look like a \ + primary action instead of a dangerous one. Fixed regardless of palette. */\n\ +button.destructive-action { background-color: #c0392b; color: #ffffff; }\n\ +button.destructive-action:hover { background-color: #d64535; }\n\ +/* Adwaita's default switch slider (the knob) carries a box-shadow used for \ + its 3D bevel look — bread-theme's override only sets background-color, \ + so that shadow still renders as a pale ring around the knob on top of \ + our flat colour. */\n\ +switch slider { box-shadow: none; outline: none; border: none; background-image: none; }\n\ +switch { box-shadow: none; outline: none; border: none; background-image: none; }\n\ "; thread_local! { diff --git a/bos-settings/src/ui/sidebar.rs b/bos-settings/src/ui/sidebar.rs index 4395591..5b2ef8e 100644 --- a/bos-settings/src/ui/sidebar.rs +++ b/bos-settings/src/ui/sidebar.rs @@ -1,43 +1,89 @@ use gtk4::prelude::*; -use gtk4::{Box as GBox, Label, ListBox, ListBoxRow, Orientation}; +use gtk4::{Box as GBox, Image, Label, ListBox, ListBoxRow, Orientation}; pub struct SidebarItem { + /// Must match the `Stack` page name registered in `window.rs`. pub id: &'static str, pub label: &'static str, + /// Dim second line — the underlying binary/config name, for items whose + /// human label doesn't already make that obvious. + pub sublabel: Option<&'static str>, + /// A `-symbolic` icon name from the system icon theme (Papirus-Dark ships + /// the full Adwaita-compatible symbolic set this app relies on). + pub icon: &'static str, } -pub const APPS_ITEMS: &[SidebarItem] = &[ - SidebarItem { id: "bread", label: "bread" }, - SidebarItem { id: "breadbar", label: "breadbar" }, - SidebarItem { id: "breadbox", label: "breadbox" }, - SidebarItem { id: "breadcrumbs", label: "breadcrumbs" }, - SidebarItem { id: "breadpad", label: "breadpad" }, -]; +const fn item(id: &'static str, label: &'static str, icon: &'static str) -> SidebarItem { + SidebarItem { id, label, sublabel: None, icon } +} +const fn item_sub( + id: &'static str, + label: &'static str, + sublabel: &'static str, + icon: &'static str, +) -> SidebarItem { + SidebarItem { id, label, sublabel: Some(sublabel), icon } +} + +// Grouped by task, not by "app vs system internals" — a user thinks "I want +// to change my Wi-Fi" or "I want to change my wallpaper", not "which of +// these is a bread-ecosystem app". breadcrumbs (Wi-Fi profiles) moves out of +// the old "Apps" bucket into System for the same reason. pub const SYSTEM_ITEMS: &[SidebarItem] = &[ - SidebarItem { id: "snapshots", label: "Snapshots" }, - SidebarItem { id: "packages", label: "Packages" }, - SidebarItem { id: "hyprland", label: "Hyprland" }, + item("network", "Network", "network-wireless-symbolic"), + item_sub("breadcrumbs", "Wi-Fi Profiles", "breadcrumbs", "network-workgroup-symbolic"), + item("firewall", "Firewall", "security-high-symbolic"), + item("sound", "Sound", "audio-volume-high-symbolic"), + item("power", "Power", "battery-good-symbolic"), + item("datetime", "Date & Time", "preferences-system-time-symbolic"), + item_sub("hyprland", "Display", "hyprland.lua", "video-display-symbolic"), + item("users", "Users", "system-users-symbolic"), ]; -pub fn build() -> (GBox, ListBox) { +pub const PERSONALIZATION_ITEMS: &[SidebarItem] = &[ + item_sub("breadpaper", "Wallpaper", "breadpaper", "preferences-desktop-wallpaper-symbolic"), + item_sub("breadbar", "Bar", "breadbar", "view-grid-symbolic"), + item_sub("breadbox", "Launcher", "breadbox", "view-app-grid-symbolic"), + item_sub("breadclip", "Clipboard", "breadclipd", "edit-paste-symbolic"), + item_sub("breadpad", "Notes", "breadpad", "text-editor-symbolic"), + item_sub("breadsearch", "File Search", "breadsearch", "system-search-symbolic"), + item_sub("bread", "Daemon", "breadd", "applications-system-symbolic"), +]; + +pub const MAINTENANCE_ITEMS: &[SidebarItem] = &[ + item("packages", "Packages", "package-x-generic-symbolic"), + item("aur", "AUR", "system-search-symbolic"), + item("firmware", "Firmware", "software-update-available-symbolic"), + item("snapshots", "Snapshots", "document-open-recent-symbolic"), +]; + +pub const ABOUT_ITEMS: &[SidebarItem] = &[item("about", "About", "help-about-symbolic")]; + +/// `default_id` must match whatever page `window.rs` sets as the `Stack`'s +/// initial visible child — previously these were two independent hardcoded +/// "about" literals in different files with no link between them, so +/// changing one without the other silently desynced the sidebar highlight +/// from the actually-displayed page. +pub fn build(default_id: &str) -> (GBox, ListBox) { let vbox = GBox::new(Orientation::Vertical, 0); vbox.add_css_class("sidebar"); - vbox.set_width_request(190); + vbox.set_width_request(210); let list = ListBox::new(); list.set_selection_mode(gtk4::SelectionMode::Single); list.add_css_class("sidebar"); - append_section(&list, "Apps", APPS_ITEMS); append_section(&list, "System", SYSTEM_ITEMS); + append_section(&list, "Personalization", PERSONALIZATION_ITEMS); + append_section(&list, "Maintenance", MAINTENANCE_ITEMS); + append_section(&list, None, ABOUT_ITEMS); - // Select the snapshots 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() == "snapshots" => { + Some(row) if row.widget_name() == default_id => { list.select_row(Some(&row)); break; } @@ -49,24 +95,47 @@ pub fn build() -> (GBox, ListBox) { (vbox, list) } -fn append_section(list: &ListBox, title: &str, items: &[SidebarItem]) { - let header_row = ListBoxRow::new(); - header_row.set_selectable(false); - header_row.set_activatable(false); - let header_lbl = Label::new(Some(title)); - header_lbl.add_css_class("section-header"); - header_lbl.set_xalign(0.0); - header_row.set_child(Some(&header_lbl)); - list.append(&header_row); +fn append_section(list: &ListBox, title: impl Into>, items: &[SidebarItem]) { + if let Some(title) = title.into() { + let header_row = ListBoxRow::new(); + header_row.set_selectable(false); + header_row.set_activatable(false); + let header_lbl = Label::new(Some(title)); + header_lbl.add_css_class("section-header"); + header_lbl.set_xalign(0.0); + header_row.set_child(Some(&header_lbl)); + list.append(&header_row); + } - for item in items { + for entry in items { let row = ListBoxRow::new(); - row.set_widget_name(item.id); - let lbl = Label::new(Some(item.label)); + row.set_widget_name(entry.id); + + let hbox = GBox::new(Orientation::Horizontal, 10); + hbox.set_margin_top(4); + hbox.set_margin_bottom(4); + + let icon = Image::from_icon_name(entry.icon); + icon.set_pixel_size(16); + hbox.append(&icon); + + let labels = GBox::new(Orientation::Vertical, 0); + let lbl = Label::new(Some(entry.label)); lbl.set_xalign(0.0); - lbl.set_margin_top(2); - lbl.set_margin_bottom(2); - row.set_child(Some(&lbl)); + labels.append(&lbl); + if let Some(sub) = entry.sublabel { + let sub_lbl = Label::new(Some(sub)); + sub_lbl.add_css_class("dim-label"); + sub_lbl.set_xalign(0.0); + // Match the sidebar's smaller "section-header" scale rather than + // the shared sheet's default dim-label size, so it reads as a + // caption under the row label, not a second full-size label. + sub_lbl.add_css_class("caption"); + labels.append(&sub_lbl); + } + hbox.append(&labels); + + row.set_child(Some(&hbox)); list.append(&row); } } diff --git a/bos-settings/src/ui/views/about.rs b/bos-settings/src/ui/views/about.rs new file mode 100644 index 0000000..8f4cdcb --- /dev/null +++ b/bos-settings/src/ui/views/about.rs @@ -0,0 +1,172 @@ +//! Read-only system info, plus the one thing worth making writable: hostname. +//! BOS is a rolling release (no fixed version number to show — `os-release` +//! ships `BUILD_ID=rolling` on purpose), so there's no "BOS 1.2.3" readout +//! here the way a point-release distro's About panel would have one. + +use gtk4::prelude::*; +use gtk4::{Box as GBox, Button, Entry, Label, Orientation}; +use std::fs; +use std::process::Command; + +use crate::ui::widgets as w; + +fn os_pretty_name() -> String { + fs::read_to_string("/etc/os-release") + .ok() + .and_then(|s| { + s.lines() + .find_map(|l| l.strip_prefix("PRETTY_NAME=").map(|v| v.trim_matches('"').to_string())) + }) + .unwrap_or_else(|| "BOS".to_string()) +} + +fn hostname() -> String { + fs::read_to_string("/etc/hostname") + .map(|s| s.trim().to_string()) + .unwrap_or_else(|_| "unknown".to_string()) +} + +fn kernel() -> String { + Command::new("uname") + .arg("-r") + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn cpu() -> String { + let model = fs::read_to_string("/proc/cpuinfo") + .ok() + .and_then(|s| { + s.lines() + .find_map(|l| l.strip_prefix("model name").map(|v| v.trim_start_matches([':', ' ', '\t']).to_string())) + }) + .unwrap_or_else(|| "unknown".to_string()); + let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0); + if cores > 0 { + format!("{model} ({cores} threads)") + } else { + model + } +} + +fn memory() -> String { + let kb = fs::read_to_string("/proc/meminfo") + .ok() + .and_then(|s| { + s.lines() + .find(|l| l.starts_with("MemTotal:")) + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|v| v.parse::().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 +} diff --git a/bos-settings/src/ui/views/aur.rs b/bos-settings/src/ui/views/aur.rs new file mode 100644 index 0000000..b526224 --- /dev/null +++ b/bos-settings/src/ui/views/aur.rs @@ -0,0 +1,169 @@ +//! AUR search via yay — graphical discovery for the wider AUR beyond +//! bakery's bread ecosystem and [breadway]'s own republished packages. +//! +//! Search and browsing are fully graphical; actually installing a package +//! opens a terminal running `yay -S ` 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 { + 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