dev #1
77 changed files with 3885 additions and 542 deletions
|
|
@ -9,6 +9,11 @@ name: Build and release ISO
|
|||
# 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).
|
||||
|
||||
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 \
|
||||
upload_asset() {
|
||||
local file="$1" name
|
||||
name="$(basename "$file")"
|
||||
local asset_id
|
||||
asset_id=$(curl -sf \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
"http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets" \
|
||||
| python3 -c "
|
||||
import json,sys
|
||||
assets=json.load(sys.stdin)
|
||||
match=[a['id'] for a in assets if a['name']=='${ISO_NAME}']
|
||||
match=[a['id'] for a in assets if a['name']=='${name}']
|
||||
print(match[0] if match else '')
|
||||
" 2>/dev/null || true)
|
||||
|
||||
if [ -n "${ASSET_ID}" ]; then
|
||||
if [ -n "${asset_id}" ]; then
|
||||
curl -fsS -X DELETE \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
"http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
||||
echo "Removed existing ${ISO_NAME} asset"
|
||||
"http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets/${asset_id}"
|
||||
echo "Removed existing ${name} asset"
|
||||
fi
|
||||
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-F "attachment=@${ISO};filename=${ISO_NAME}" \
|
||||
-F "attachment=@${file};filename=${name}" \
|
||||
"http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets"
|
||||
echo "Uploaded: ${ISO_NAME}"
|
||||
echo "Uploaded: ${name}"
|
||||
}
|
||||
|
||||
upload_asset "/bos-out/${ISO_NAME}"
|
||||
upload_asset "/bos-out/SHA256SUMS"
|
||||
upload_asset "/bos-out/SHA256SUMS.asc"
|
||||
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
|
|
@ -196,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}" \
|
||||
|
|
|
|||
36
.forgejo/workflows/yay-bin.yml
Normal file
36
.forgejo/workflows/yay-bin.yml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
name: Build and publish yay-bin
|
||||
|
||||
# yay (and every AUR helper) is AUR-only — not in Arch's official repos — so
|
||||
# BOS maintains an in-house PKGBUILD and publishes the built package to the
|
||||
# [breadway] repo, same as bibata-cursor-theme and calamares. Prebuilt
|
||||
# release tarball, no build step.
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'packaging/yay-bin/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
yay-bin:
|
||||
runs-on: [self-hosted, hestia]
|
||||
container:
|
||||
image: archlinux:latest
|
||||
steps:
|
||||
- name: Build and publish
|
||||
env:
|
||||
PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pacman -Syu --noconfirm base-devel git
|
||||
useradd -m builder
|
||||
git config --global --add safe.directory '*'
|
||||
git clone --depth 1 --branch "${GITHUB_REF_NAME}" \
|
||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src
|
||||
chown -R builder:builder /home/builder/src
|
||||
su builder -c "cd /home/builder/src/packaging/yay-bin && makepkg -f --noconfirm --nocheck"
|
||||
PKG=$(find /home/builder/src/packaging/yay-bin -name '*.pkg.tar.zst' | head -1)
|
||||
curl -fsS -X PUT \
|
||||
-H "Authorization: token ${PUBLISH_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${PKG}" \
|
||||
"https://git.breadway.dev/api/packages/Breadway/arch/os"
|
||||
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
15
KEYS.asc
Normal file
15
KEYS.asc
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mDMEakhwGhYJKwYBBAHaRw8BAQdA/sZ/GYec5M2MD+w20mVF5tMUhGji210Dg7zL
|
||||
TAhNsg60WUJPUyBSZWxlYXNlIFNpZ25pbmcgKGdpdC5icmVhZHdheS5kZXYvQnJl
|
||||
YWR3YXkvYm9zIHJlbGVhc2VzIG9ubHkpIDxyZWxlYXNlc0BicmVhZHdheS5kZXY+
|
||||
iJYEExYKAD4WIQRWIDuGoRBpWufzEJNK8zI9Z4614gUCakhwGgIbIwUJA8JnAAUL
|
||||
CQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRBK8zI9Z4614ggYAQDP8FTZ14i9YPKD
|
||||
ARvZuP5QaYOUFhQ8uyG0CowXKy9O0AEAqYfjnvyJI3N651pVFSNUXyP16w1kMPSs
|
||||
K0g3CLsztQ+4OARqSHAaEgorBgEEAZdVAQUBAQdAuJFuy2GHz5m9wXTm/PdSpLE9
|
||||
gERwHOLyM1OFuttrJW4DAQgHiH4EGBYKACYWIQRWIDuGoRBpWufzEJNK8zI9Z461
|
||||
4gUCakhwGgIbDAUJA8JnAAAKCRBK8zI9Z4614nzLAP9grcIFsAAeCyVKhziHmpXq
|
||||
E0Hm6FfIr4sdEf63HZkyfwD/XeKeWfb3EWvVsloJrZZ9tDmR67iK52Hwl82wfFAU
|
||||
cAo=
|
||||
=Mrh1
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
100
README.md
100
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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::<DocumentMut>().ok())
|
||||
.unwrap_or_default()
|
||||
let Ok(text) = std::fs::read_to_string(path) else {
|
||||
return DocumentMut::default();
|
||||
};
|
||||
match text.parse::<DocumentMut>() {
|
||||
Ok(doc) => doc,
|
||||
Err(e) => {
|
||||
let backup = PathBuf::from(format!("{}.bak", path.display()));
|
||||
eprintln!(
|
||||
"bos-settings: {} failed to parse ({e}); backed up to {} before falling back to defaults",
|
||||
path.display(),
|
||||
backup.display()
|
||||
);
|
||||
let _ = std::fs::write(&backup, &text);
|
||||
DocumentMut::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the document back to disk, creating parent dirs as needed.
|
||||
|
|
|
|||
|
|
@ -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! {
|
||||
|
|
|
|||
|
|
@ -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,7 +95,8 @@ pub fn build() -> (GBox, ListBox) {
|
|||
(vbox, list)
|
||||
}
|
||||
|
||||
fn append_section(list: &ListBox, title: &str, items: &[SidebarItem]) {
|
||||
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);
|
||||
|
|
@ -58,15 +105,37 @@ fn append_section(list: &ListBox, title: &str, items: &[SidebarItem]) {
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
172
bos-settings/src/ui/views/about.rs
Normal file
172
bos-settings/src/ui/views/about.rs
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
//! Read-only system info, plus the one thing worth making writable: hostname.
|
||||
//! BOS is a rolling release (no fixed version number to show — `os-release`
|
||||
//! ships `BUILD_ID=rolling` on purpose), so there's no "BOS 1.2.3" readout
|
||||
//! here the way a point-release distro's About panel would have one.
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{Box as GBox, Button, Entry, Label, Orientation};
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
fn os_pretty_name() -> String {
|
||||
fs::read_to_string("/etc/os-release")
|
||||
.ok()
|
||||
.and_then(|s| {
|
||||
s.lines()
|
||||
.find_map(|l| l.strip_prefix("PRETTY_NAME=").map(|v| v.trim_matches('"').to_string()))
|
||||
})
|
||||
.unwrap_or_else(|| "BOS".to_string())
|
||||
}
|
||||
|
||||
fn hostname() -> String {
|
||||
fs::read_to_string("/etc/hostname")
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|_| "unknown".to_string())
|
||||
}
|
||||
|
||||
fn kernel() -> String {
|
||||
Command::new("uname")
|
||||
.arg("-r")
|
||||
.output()
|
||||
.ok()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
fn cpu() -> String {
|
||||
let model = fs::read_to_string("/proc/cpuinfo")
|
||||
.ok()
|
||||
.and_then(|s| {
|
||||
s.lines()
|
||||
.find_map(|l| l.strip_prefix("model name").map(|v| v.trim_start_matches([':', ' ', '\t']).to_string()))
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
|
||||
if cores > 0 {
|
||||
format!("{model} ({cores} threads)")
|
||||
} else {
|
||||
model
|
||||
}
|
||||
}
|
||||
|
||||
fn memory() -> String {
|
||||
let kb = fs::read_to_string("/proc/meminfo")
|
||||
.ok()
|
||||
.and_then(|s| {
|
||||
s.lines()
|
||||
.find(|l| l.starts_with("MemTotal:"))
|
||||
.and_then(|l| l.split_whitespace().nth(1))
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
});
|
||||
match kb {
|
||||
Some(kb) => format!("{:.1} GiB", kb as f64 / 1024.0 / 1024.0),
|
||||
None => "unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn gpu() -> String {
|
||||
let Ok(output) = Command::new("lspci").output() else {
|
||||
return "unknown".to_string();
|
||||
};
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
text.lines()
|
||||
// "Display controller" covers integrated GPUs some laptop chipsets
|
||||
// (this dev laptop's AMD Radeon 860M included) report under instead
|
||||
// of "VGA compatible controller" — without it those show "unknown".
|
||||
.find(|l| {
|
||||
l.contains("VGA compatible controller")
|
||||
|| l.contains("3D controller")
|
||||
|| l.contains("Display controller")
|
||||
})
|
||||
.and_then(|l| l.split(": ").nth(1))
|
||||
.unwrap_or("unknown")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn disk_usage() -> String {
|
||||
let Ok(output) = Command::new("df").args(["-h", "--output=used,size,pcent", "/"]).output() else {
|
||||
return "unknown".to_string();
|
||||
};
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
text.lines()
|
||||
.nth(1)
|
||||
.map(|l| {
|
||||
let cols: Vec<&str> = l.split_whitespace().collect();
|
||||
match cols.as_slice() {
|
||||
[used, size, pcent] => format!("{used} of {size} used ({pcent})"),
|
||||
_ => l.trim().to_string(),
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
fn uptime() -> String {
|
||||
Command::new("uptime")
|
||||
.arg("-p")
|
||||
.output()
|
||||
.ok()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let (outer, content) = w::view_scaffold("About");
|
||||
|
||||
content.append(&w::info_row("Operating system", &os_pretty_name()));
|
||||
content.append(&w::info_row("Kernel", &kernel()));
|
||||
content.append(&w::info_row("CPU", &cpu()));
|
||||
content.append(&w::info_row("GPU", &gpu()));
|
||||
content.append(&w::info_row("Memory", &memory()));
|
||||
content.append(&w::info_row("Disk (/)", &disk_usage()));
|
||||
content.append(&w::info_row("Uptime", &uptime()));
|
||||
|
||||
content.append(&w::section("Hostname"));
|
||||
content.append(&w::hint(
|
||||
"Changes the machine's network name. Takes effect immediately; \
|
||||
needs your password (polkit).",
|
||||
));
|
||||
|
||||
let hn_row = GBox::new(Orientation::Horizontal, 12);
|
||||
let entry = Entry::new();
|
||||
entry.set_text(&hostname());
|
||||
entry.set_hexpand(true);
|
||||
let apply_btn = Button::with_label("Apply");
|
||||
let status = Label::new(None);
|
||||
status.add_css_class("dim-label");
|
||||
|
||||
{
|
||||
let entry = entry.clone();
|
||||
let status = status.clone();
|
||||
apply_btn.connect_clicked(move |_| {
|
||||
let name = entry.text().to_string();
|
||||
if name.trim().is_empty() {
|
||||
status.set_text("Hostname can't be empty");
|
||||
return;
|
||||
}
|
||||
let log_buf = gtk4::TextBuffer::new(None);
|
||||
let status2 = status.clone();
|
||||
status.set_text("Applying…");
|
||||
w::stream_command_then(
|
||||
&["pkexec", "hostnamectl", "set-hostname", name.trim()],
|
||||
log_buf.clone(),
|
||||
move || {
|
||||
let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false);
|
||||
if text.trim().is_empty() {
|
||||
status2.set_text("Applied");
|
||||
} else {
|
||||
status2.set_text(&format!("Error: {}", text.trim()));
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
hn_row.append(&entry);
|
||||
hn_row.append(&apply_btn);
|
||||
content.append(&hn_row);
|
||||
content.append(&status);
|
||||
|
||||
outer
|
||||
}
|
||||
169
bos-settings/src/ui/views/aur.rs
Normal file
169
bos-settings/src/ui/views/aur.rs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
//! AUR search via yay — graphical discovery for the wider AUR beyond
|
||||
//! bakery's bread ecosystem and [breadway]'s own republished packages.
|
||||
//!
|
||||
//! Search and browsing are fully graphical; actually installing a package
|
||||
//! opens a terminal running `yay -S <pkg>` instead of a silent `--noconfirm`
|
||||
//! install. That's deliberate, not a shortcut we didn't get around to:
|
||||
//! AUR packages run arbitrary maintainer-supplied build scripts, and yay's
|
||||
//! interactive PKGBUILD diff review (plus the sudo password prompt) is the
|
||||
//! actual safety mechanism against a malicious/compromised AUR package —
|
||||
//! automating it away in the name of "no terminal" would remove the one
|
||||
//! step that exists to catch that.
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AurResult {
|
||||
name: String,
|
||||
version: String,
|
||||
description: String,
|
||||
}
|
||||
|
||||
fn search(query: &str) -> Vec<AurResult> {
|
||||
let Ok(output) = Command::new("yay").args(["-Ss", "--aur", query]).output() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
let mut results = Vec::new();
|
||||
let mut lines = text.lines().peekable();
|
||||
while let Some(header) = lines.next() {
|
||||
// "aur/name version (+votes score) [Orphaned]" — name/version are
|
||||
// always the first two whitespace-separated fields after the repo/.
|
||||
let Some(rest) = header.strip_prefix("aur/") else { continue };
|
||||
let mut parts = rest.split_whitespace();
|
||||
let Some(name) = parts.next() else { continue };
|
||||
let version = parts.next().unwrap_or("").to_string();
|
||||
let description = lines.next().unwrap_or("").trim().to_string();
|
||||
results.push(AurResult { name: name.to_string(), version, description });
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
fn install_in_terminal(pkg: &str) {
|
||||
let _ = Command::new("kitty").args(["-e", "yay", "-S", pkg]).spawn();
|
||||
}
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let (outer, content) = w::view_scaffold("AUR");
|
||||
content.append(&w::hint(
|
||||
"Search the Arch User Repository via yay. Installing opens a \
|
||||
terminal — AUR packages run arbitrary build scripts, and reviewing \
|
||||
what yay is about to do (and entering your password) is a real \
|
||||
safety step, not just a formality.",
|
||||
));
|
||||
|
||||
let search_row = GBox::new(Orientation::Horizontal, 8);
|
||||
let search_entry = Entry::new();
|
||||
search_entry.set_hexpand(true);
|
||||
search_entry.set_placeholder_text(Some("Search the AUR…"));
|
||||
let search_btn = Button::with_label("Search");
|
||||
search_btn.add_css_class("suggested-action");
|
||||
search_row.append(&search_entry);
|
||||
search_row.append(&search_btn);
|
||||
content.append(&search_row);
|
||||
|
||||
let status = w::hint("Search for a package to see results here.");
|
||||
content.append(&status);
|
||||
|
||||
let list = ListBox::new();
|
||||
list.set_selection_mode(gtk4::SelectionMode::None);
|
||||
let scroll = ScrolledWindow::new();
|
||||
scroll.set_vexpand(true);
|
||||
scroll.set_min_content_height(320);
|
||||
scroll.set_child(Some(&list));
|
||||
content.append(&scroll);
|
||||
|
||||
let run_search = {
|
||||
let list = list.clone();
|
||||
let status = status.clone();
|
||||
let search_entry = search_entry.clone();
|
||||
move |btn: Option<Button>| {
|
||||
let query = search_entry.text().to_string();
|
||||
if query.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(b) = &btn {
|
||||
b.set_sensitive(false);
|
||||
}
|
||||
status.set_text("Searching…");
|
||||
|
||||
let (tx, rx) = async_channel::bounded::<Vec<AurResult>>(1);
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send_blocking(search(&query));
|
||||
});
|
||||
|
||||
let list = list.clone();
|
||||
let status = status.clone();
|
||||
let btn = btn.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
if let Ok(results) = rx.recv().await {
|
||||
while let Some(child) = list.first_child() {
|
||||
list.remove(&child);
|
||||
}
|
||||
if results.is_empty() {
|
||||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
row.set_child(Some(&w::empty_state(
|
||||
"system-search-symbolic",
|
||||
"No results",
|
||||
"Try a different search term.",
|
||||
)));
|
||||
list.append(&row);
|
||||
status.set_text("No results.");
|
||||
} else {
|
||||
status.set_text(&format!("{} result(s)", results.len()));
|
||||
for r in results.into_iter().take(50) {
|
||||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
let vbox = GBox::new(Orientation::Vertical, 2);
|
||||
vbox.add_css_class("card");
|
||||
vbox.set_margin_top(3);
|
||||
vbox.set_margin_bottom(3);
|
||||
|
||||
let top = GBox::new(Orientation::Horizontal, 12);
|
||||
let name_lbl = Label::new(Some(&format!("{} {}", r.name, r.version)));
|
||||
name_lbl.set_hexpand(true);
|
||||
name_lbl.set_xalign(0.0);
|
||||
let install_btn = Button::with_label("Install");
|
||||
{
|
||||
let pkg = r.name.clone();
|
||||
install_btn.connect_clicked(move |_| install_in_terminal(&pkg));
|
||||
}
|
||||
top.append(&name_lbl);
|
||||
top.append(&install_btn);
|
||||
vbox.append(&top);
|
||||
|
||||
let desc_lbl = Label::new(Some(&r.description));
|
||||
desc_lbl.add_css_class("dim-label");
|
||||
desc_lbl.set_xalign(0.0);
|
||||
desc_lbl.set_wrap(true);
|
||||
desc_lbl.set_max_width_chars(64);
|
||||
vbox.append(&desc_lbl);
|
||||
|
||||
row.set_child(Some(&vbox));
|
||||
list.append(&row);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(b) = &btn {
|
||||
b.set_sensitive(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let run_search = run_search.clone();
|
||||
search_btn.connect_clicked(move |btn| run_search(Some(btn.clone())));
|
||||
}
|
||||
{
|
||||
let run_search = run_search.clone();
|
||||
search_entry.connect_activate(move |_| run_search(None));
|
||||
}
|
||||
|
||||
outer
|
||||
}
|
||||
|
|
@ -19,7 +19,9 @@ pub fn build() -> GBox {
|
|||
let path = config_path();
|
||||
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
|
||||
|
||||
let (outer, c) = w::view_scaffold("bread");
|
||||
let (outer, c) = w::view_scaffold("Daemon");
|
||||
|
||||
c.append(&w::service_control("breadd.service", true, true));
|
||||
|
||||
c.append(&w::section("Daemon"));
|
||||
c.append(&w::dropdown_row(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ use gtk4::prelude::*;
|
|||
use gtk4::{Box as GBox, Button, Label, Orientation, ScrolledWindow, TextView};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
fn css_path() -> PathBuf {
|
||||
crate::config::config_dir().join("breadbar/style.css")
|
||||
}
|
||||
|
|
@ -10,37 +12,42 @@ pub fn build() -> GBox {
|
|||
let path = css_path();
|
||||
let existing_css = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
|
||||
let vbox = GBox::new(Orientation::Vertical, 12);
|
||||
vbox.add_css_class("view-content");
|
||||
|
||||
let title = Label::new(Some("breadbar"));
|
||||
title.add_css_class("title");
|
||||
title.set_xalign(0.0);
|
||||
vbox.append(&title);
|
||||
|
||||
let subtitle = Label::new(Some(
|
||||
"CSS overrides for breadbar. Leave empty to use the default bread theme.",
|
||||
let (outer, content) = w::view_scaffold("Bar");
|
||||
content.append(&w::hint(
|
||||
"CSS overrides for breadbar. Leave empty to use the default bread theme. \
|
||||
Reloads live on save — no need to restart the bar.",
|
||||
));
|
||||
subtitle.set_xalign(0.0);
|
||||
subtitle.set_margin_bottom(8);
|
||||
subtitle.set_wrap(true);
|
||||
vbox.append(&subtitle);
|
||||
|
||||
let buf = gtk4::TextBuffer::new(None);
|
||||
buf.set_text(&existing_css);
|
||||
|
||||
let text_view = TextView::with_buffer(&buf);
|
||||
text_view.set_monospace(true);
|
||||
text_view.set_top_margin(12);
|
||||
text_view.set_bottom_margin(12);
|
||||
text_view.set_left_margin(12);
|
||||
text_view.set_right_margin(12);
|
||||
|
||||
// A borderless full-bleed textview reads as a rendering bug, not an
|
||||
// editor — give it the same card framing every other panel's content
|
||||
// gets, and cap its height so it doesn't compete with the button row
|
||||
// for the one screen's worth of space.
|
||||
let editor_card = GBox::new(Orientation::Vertical, 0);
|
||||
editor_card.add_css_class("card");
|
||||
editor_card.set_vexpand(true);
|
||||
|
||||
let scroll = ScrolledWindow::new();
|
||||
scroll.set_vexpand(true);
|
||||
scroll.set_min_content_height(360);
|
||||
scroll.set_child(Some(&text_view));
|
||||
vbox.append(&scroll);
|
||||
editor_card.append(&scroll);
|
||||
content.append(&editor_card);
|
||||
|
||||
let btn_row = GBox::new(Orientation::Horizontal, 12);
|
||||
btn_row.set_margin_top(12);
|
||||
|
||||
let save_btn = Button::with_label("Save");
|
||||
save_btn.add_css_class("suggested-action");
|
||||
let status_lbl = Label::new(None);
|
||||
status_lbl.add_css_class("dim-label");
|
||||
|
||||
|
|
@ -55,7 +62,18 @@ pub fn build() -> GBox {
|
|||
}
|
||||
match std::fs::write(&path, text.as_str()) {
|
||||
Ok(()) => {
|
||||
status_lbl.set_text("Saved");
|
||||
// breadbar has no systemd unit (it's launched directly by
|
||||
// hyprland.lua's exec-once) — SIGHUP is its own documented
|
||||
// live-reload mechanism (see this file's own header
|
||||
// comment: "reload live: kill -HUP $(pidof breadbar)").
|
||||
// -x pkill exits non-zero if breadbar isn't running,
|
||||
// which is fine — the file's still saved either way.
|
||||
let reloaded = std::process::Command::new("pkill")
|
||||
.args(["-HUP", "-x", "breadbar"])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
status_lbl.set_text(if reloaded { "Saved & reloaded" } else { "Saved" });
|
||||
let lbl = status_lbl.clone();
|
||||
glib::timeout_add_seconds_local(3, move || {
|
||||
lbl.set_text("");
|
||||
|
|
@ -69,7 +87,7 @@ pub fn build() -> GBox {
|
|||
|
||||
btn_row.append(&save_btn);
|
||||
btn_row.append(&status_lbl);
|
||||
vbox.append(&btn_row);
|
||||
outer.append(&btn_row);
|
||||
|
||||
vbox
|
||||
outer
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
//! breadbox config.toml — launcher contexts.
|
||||
//! Schema mirrors breadbox-shared (`[[contexts]]` with `name` + `priority`, an
|
||||
//! ordered list of app/category hints). The contexts array is rewritten on
|
||||
//! save; any other top-level keys/comments in the file are preserved.
|
||||
//! Schema mirrors breadbox-shared (`#[serde(rename = "context")]` — the TOML
|
||||
//! key is `[[context]]`, singular, despite the Rust field being `contexts`),
|
||||
//! with `name` + `priority`, an ordered list of app/category hints. The
|
||||
//! context array is rewritten on save; any other top-level keys/comments in
|
||||
//! the file are preserved.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
|
@ -13,6 +15,7 @@ use gtk4::{
|
|||
use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
|
||||
|
||||
use crate::config;
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct Context {
|
||||
|
|
@ -25,7 +28,7 @@ fn config_path() -> std::path::PathBuf {
|
|||
}
|
||||
|
||||
fn read_contexts(doc: &DocumentMut) -> Vec<Context> {
|
||||
let Some(aot) = doc.get("contexts").and_then(Item::as_array_of_tables) else {
|
||||
let Some(aot) = doc.get("context").and_then(Item::as_array_of_tables) else {
|
||||
return Vec::new();
|
||||
};
|
||||
aot.iter()
|
||||
|
|
@ -53,13 +56,24 @@ fn write_contexts(doc: &mut DocumentMut, ctxs: &[Context]) {
|
|||
t.insert("priority", value(arr));
|
||||
aot.push(t);
|
||||
}
|
||||
doc.as_table_mut().insert("contexts", Item::ArrayOfTables(aot));
|
||||
doc.as_table_mut().insert("context", Item::ArrayOfTables(aot));
|
||||
}
|
||||
|
||||
fn rebuild_list(list: &ListBox, model: &Rc<RefCell<Vec<Context>>>) {
|
||||
while let Some(child) = list.first_child() {
|
||||
list.remove(&child);
|
||||
}
|
||||
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);
|
||||
|
|
@ -126,21 +140,13 @@ pub fn build() -> GBox {
|
|||
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
|
||||
let model = Rc::new(RefCell::new(read_contexts(&doc.borrow())));
|
||||
|
||||
let vbox = GBox::new(Orientation::Vertical, 12);
|
||||
vbox.add_css_class("view-content");
|
||||
let (outer, content) = w::view_scaffold("Launcher");
|
||||
content.append(&w::service_control("breadbox-sync.service", false, true));
|
||||
|
||||
let title = Label::new(Some("breadbox"));
|
||||
title.add_css_class("title");
|
||||
title.set_xalign(0.0);
|
||||
vbox.append(&title);
|
||||
|
||||
let subtitle = Label::new(Some(
|
||||
content.append(&w::section("Contexts"));
|
||||
content.append(&w::hint(
|
||||
"Launcher contexts — each lists, in priority order, the apps/categories surfaced first.",
|
||||
));
|
||||
subtitle.set_xalign(0.0);
|
||||
subtitle.set_wrap(true);
|
||||
subtitle.set_margin_bottom(8);
|
||||
vbox.append(&subtitle);
|
||||
|
||||
let list = ListBox::new();
|
||||
list.set_selection_mode(gtk4::SelectionMode::None);
|
||||
|
|
@ -149,12 +155,14 @@ pub fn build() -> GBox {
|
|||
let scroll = ScrolledWindow::new();
|
||||
scroll.set_vexpand(true);
|
||||
scroll.set_child(Some(&list));
|
||||
vbox.append(&scroll);
|
||||
|
||||
let btn_row = GBox::new(Orientation::Horizontal, 8);
|
||||
btn_row.set_margin_top(8);
|
||||
content.append(&scroll);
|
||||
|
||||
// Add-row button lives with the list it adds to, halign Start like
|
||||
// breadcrumbs' "Add network"/"Add profile" — Save stays alone in its
|
||||
// own row at the bottom, consistent with every other panel.
|
||||
let add_btn = Button::with_label("Add context");
|
||||
add_btn.set_halign(gtk4::Align::Start);
|
||||
add_btn.set_margin_top(8);
|
||||
{
|
||||
let model = model.clone();
|
||||
let list = list.clone();
|
||||
|
|
@ -166,12 +174,14 @@ pub fn build() -> GBox {
|
|||
rebuild_list(&list, &model);
|
||||
});
|
||||
}
|
||||
content.append(&add_btn);
|
||||
|
||||
let btn_row = GBox::new(Orientation::Horizontal, 12);
|
||||
btn_row.set_margin_top(16);
|
||||
let save_btn = Button::with_label("Save");
|
||||
save_btn.add_css_class("suggested-action");
|
||||
let status_lbl = Label::new(None);
|
||||
status_lbl.add_css_class("dim-label");
|
||||
|
||||
{
|
||||
let doc = doc.clone();
|
||||
let model = model.clone();
|
||||
|
|
@ -192,11 +202,9 @@ pub fn build() -> GBox {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
btn_row.append(&add_btn);
|
||||
btn_row.append(&save_btn);
|
||||
btn_row.append(&status_lbl);
|
||||
vbox.append(&btn_row);
|
||||
outer.append(&btn_row);
|
||||
|
||||
vbox
|
||||
outer
|
||||
}
|
||||
|
|
|
|||
33
bos-settings/src/ui/views/breadclip.rs
Normal file
33
bos-settings/src/ui/views/breadclip.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
//! breadclip — clipboard history popup + its background daemon (breadclipd).
|
||||
//! No config file to edit: breadclip takes no persistent settings. This
|
||||
//! panel exists purely so the daemon backing it is visible/controllable
|
||||
//! from bos-settings instead of only from a terminal — previously breadclip
|
||||
//! had no presence in Settings at all despite running its own service.
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{Box as GBox, Button};
|
||||
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let (outer, c) = w::view_scaffold("Clipboard");
|
||||
|
||||
c.append(&w::hint(
|
||||
"Keeps a history of copied text/images and shows it as a popup. \
|
||||
breadclipd (below) is the background daemon that watches the \
|
||||
clipboard — breadclip itself is just the popup UI, launched on \
|
||||
demand by the keybind or the button below.",
|
||||
));
|
||||
|
||||
let open_btn = Button::with_label("Open history (SUPER+V)");
|
||||
open_btn.add_css_class("suggested-action");
|
||||
open_btn.set_halign(gtk4::Align::Start);
|
||||
open_btn.connect_clicked(|_| {
|
||||
let _ = std::process::Command::new("breadclip").spawn();
|
||||
});
|
||||
c.append(&open_btn);
|
||||
|
||||
c.append(&w::service_control("breadclipd.service", false, false));
|
||||
|
||||
outer
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ use std::rc::Rc;
|
|||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{
|
||||
Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch,
|
||||
Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, Switch,
|
||||
};
|
||||
use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
|
||||
|
||||
|
|
@ -334,20 +334,8 @@ pub fn build() -> GBox {
|
|||
let nets = Rc::new(RefCell::new(read_networks(&doc.borrow())));
|
||||
let profiles = Rc::new(RefCell::new(read_profiles(&doc.borrow())));
|
||||
|
||||
let outer = GBox::new(Orientation::Vertical, 8);
|
||||
outer.add_css_class("view-content");
|
||||
|
||||
let title = Label::new(Some("breadcrumbs"));
|
||||
title.add_css_class("title");
|
||||
title.set_xalign(0.0);
|
||||
outer.append(&title);
|
||||
|
||||
let content = GBox::new(Orientation::Vertical, 8);
|
||||
let scroll = ScrolledWindow::new();
|
||||
scroll.set_vexpand(true);
|
||||
scroll.set_hscrollbar_policy(gtk4::PolicyType::Never);
|
||||
scroll.set_child(Some(&content));
|
||||
outer.append(&scroll);
|
||||
let (outer, content) = w::view_scaffold("Wi-Fi Profiles");
|
||||
content.append(&w::service_control("breadcrumbs.service", false, true));
|
||||
|
||||
// [settings] — edited in place on the shared doc
|
||||
content.append(&w::section("Settings"));
|
||||
|
|
@ -356,7 +344,9 @@ pub fn build() -> GBox {
|
|||
&doc,
|
||||
&["settings", "default_profile"],
|
||||
&["home", "away"],
|
||||
"home",
|
||||
// breadcrumbs' own default_profile_name() is "away", not "home" —
|
||||
// this was showing the wrong value for an unset key.
|
||||
"away",
|
||||
));
|
||||
content.append(&w::entry_row("DNS", &doc, &["settings", "dns"], "1.1.1.1", ""));
|
||||
content.append(&w::entry_row(
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ pub fn build() -> GBox {
|
|||
let path = config_path();
|
||||
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
|
||||
|
||||
let (outer, c) = w::view_scaffold("breadpad");
|
||||
let (outer, c) = w::view_scaffold("Notes");
|
||||
|
||||
c.append(&w::section("Capture"));
|
||||
c.append(&w::dropdown_row(
|
||||
|
|
|
|||
163
bos-settings/src/ui/views/breadpaper.rs
Normal file
163
bos-settings/src/ui/views/breadpaper.rs
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
//! breadpaper — wallpaper manager. No config file to edit here; breadpaper
|
||||
//! takes no persistent settings, just an image path via its CLI (`breadpaper
|
||||
//! set <path>` / `breadpaper get`). This panel is a thin GUI front-end for
|
||||
//! that CLI so wallpaper (and the pywal-driven theme it generates) has a
|
||||
//! discoverable home in BOS Settings instead of only being reachable from a
|
||||
//! terminal.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{
|
||||
Box as GBox, Button, FileChooserAction, FileChooserDialog, Image, Label, Orientation,
|
||||
ResponseType,
|
||||
};
|
||||
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
fn current_wallpaper() -> Option<PathBuf> {
|
||||
let out = Command::new("breadpaper").arg("get").output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(PathBuf::from(s))
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_preview(preview: &Image, path_lbl: &Label) {
|
||||
match current_wallpaper() {
|
||||
Some(path) => {
|
||||
let filename = path.file_name().map(|f| f.to_string_lossy().to_string());
|
||||
path_lbl.set_text(filename.as_deref().unwrap_or("(unknown filename)"));
|
||||
path_lbl.set_tooltip_text(Some(&path.display().to_string()));
|
||||
preview.set_from_file(Some(&path));
|
||||
}
|
||||
None => {
|
||||
path_lbl.set_text("No wallpaper set");
|
||||
path_lbl.set_tooltip_text(None);
|
||||
preview.set_icon_name(Some("image-missing"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let (outer, c) = w::view_scaffold("Wallpaper");
|
||||
|
||||
c.append(&w::hint(
|
||||
"Sets the desktop wallpaper, generates a matching pywal palette, and \
|
||||
reloads the shared bread-theme stylesheet — the wallpaper drives \
|
||||
the whole desktop's accent colors.",
|
||||
));
|
||||
|
||||
let preview_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);
|
||||
|
||||
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);
|
||||
|
||||
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");
|
||||
|
||||
{
|
||||
let preview = preview.clone();
|
||||
let path_lbl = path_lbl.clone();
|
||||
let status = status.clone();
|
||||
choose_btn.connect_clicked(move |btn| {
|
||||
let window = btn.root().and_then(|r| r.downcast::<gtk4::Window>().ok());
|
||||
let dialog = FileChooserDialog::new(
|
||||
Some("Choose a wallpaper"),
|
||||
window.as_ref(),
|
||||
FileChooserAction::Open,
|
||||
&[("Cancel", ResponseType::Cancel), ("Set", ResponseType::Accept)],
|
||||
);
|
||||
// Restricted to what breadpaper's own validate() actually
|
||||
// accepts (png/jpg/jpeg/webp/gif/bmp) — add_pixbuf_formats()
|
||||
// also offers svg/tiff/etc. that breadpaper rejects outright.
|
||||
let filter = gtk4::FileFilter::new();
|
||||
for ext in ["png", "jpg", "jpeg", "webp", "gif", "bmp"] {
|
||||
filter.add_suffix(ext);
|
||||
}
|
||||
filter.set_name(Some("Images"));
|
||||
dialog.add_filter(&filter);
|
||||
|
||||
let preview = preview.clone();
|
||||
let path_lbl = path_lbl.clone();
|
||||
let status = status.clone();
|
||||
dialog.connect_response(move |dialog, response| {
|
||||
if response == ResponseType::Accept {
|
||||
if let Some(file) = dialog.file() {
|
||||
if let Some(path) = file.path() {
|
||||
// breadpaper set runs `awww img` + pywal palette
|
||||
// generation, which is routinely 1-3s (pywal
|
||||
// spawns Python + an ImageMagick backend) — not
|
||||
// the "sub-second" call this used to assume.
|
||||
// GTK widgets aren't Send, so run it in a thread
|
||||
// and hand the result back over a channel
|
||||
// (same pattern as snapshots.rs).
|
||||
status.set_text("Setting...");
|
||||
let (tx, rx) = async_channel::bounded::<bool>(1);
|
||||
std::thread::spawn(move || {
|
||||
let ok = Command::new("breadpaper")
|
||||
.arg("set")
|
||||
.arg(&path)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
let _ = tx.send_blocking(ok);
|
||||
});
|
||||
|
||||
let preview = preview.clone();
|
||||
let path_lbl = path_lbl.clone();
|
||||
let status = status.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let ok = rx.recv().await.unwrap_or(false);
|
||||
if ok {
|
||||
refresh_preview(&preview, &path_lbl);
|
||||
status.set_text("Wallpaper set");
|
||||
} else {
|
||||
status.set_text("breadpaper failed — see terminal/journal");
|
||||
}
|
||||
let lbl = status.clone();
|
||||
glib::timeout_add_seconds_local(3, move || {
|
||||
lbl.set_text("");
|
||||
glib::ControlFlow::Break
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
dialog.close();
|
||||
});
|
||||
dialog.show();
|
||||
});
|
||||
}
|
||||
|
||||
btn_row.append(&choose_btn);
|
||||
btn_row.append(&status);
|
||||
c.append(&btn_row);
|
||||
|
||||
outer
|
||||
}
|
||||
115
bos-settings/src/ui/views/breadsearch.rs
Normal file
115
bos-settings/src/ui/views/breadsearch.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
//! breadsearch/config.toml — semantic search indexer (breadmill) + GUI.
|
||||
//! Schema mirrors breadsearch-shared::Config ([index], [search], [model], [power]).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::Box as GBox;
|
||||
|
||||
use crate::config;
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
fn config_path() -> std::path::PathBuf {
|
||||
config::config_dir().join("breadsearch/config.toml")
|
||||
}
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let path = config_path();
|
||||
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
|
||||
|
||||
let (outer, c) = w::view_scaffold("File Search");
|
||||
c.append(&w::service_control("breadmill.service", false, true));
|
||||
|
||||
c.append(&w::section("Power"));
|
||||
c.append(&w::hint(
|
||||
"breadmill's embedding step is CPU/NPU/GPU-heavy. Turn it off entirely, \
|
||||
or just pause it on battery — it resumes automatically on AC power.",
|
||||
));
|
||||
c.append(&w::switch_row("Enabled", &doc, &["power", "enabled"], true));
|
||||
c.append(&w::switch_row(
|
||||
"Index while on battery",
|
||||
&doc,
|
||||
&["power", "run_on_battery"],
|
||||
false,
|
||||
));
|
||||
|
||||
c.append(&w::section("Model"));
|
||||
// breadsearch v0.2.3+ publishes breadmill built with --features full
|
||||
// (npu + rocm + cuda + openvino all in one binary, ort load-dynamic/
|
||||
// dlopen — no separate build needed per backend). Selecting a GPU/NPU
|
||||
// backend here only takes effect if the matching ONNX Runtime is
|
||||
// actually present on this machine; breadmill logs which EP really
|
||||
// registered at startup.
|
||||
c.append(&w::dropdown_row(
|
||||
"Compute backend",
|
||||
&doc,
|
||||
&["model", "backend"],
|
||||
&["cpu", "npu", "rocm", "cuda", "openvino"],
|
||||
"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 \
|
||||
`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 \
|
||||
breadmill.service sets this by default) or the first query after \
|
||||
each backend/model change costs a one-time ~1-2 minute GPU compile.",
|
||||
));
|
||||
|
||||
c.append(&w::section("Index"));
|
||||
c.append(&w::csv_row(
|
||||
"Roots",
|
||||
&doc,
|
||||
&["index", "roots"],
|
||||
"~/Documents, ~/Projects",
|
||||
));
|
||||
c.append(&w::csv_row(
|
||||
"Excludes",
|
||||
&doc,
|
||||
&["index", "excludes"],
|
||||
"~/Projects/some-noisy-repo",
|
||||
));
|
||||
c.append(&w::csv_row(
|
||||
"Extensions",
|
||||
&doc,
|
||||
&["index", "extensions"],
|
||||
"md, txt, org, pdf, odt, docx",
|
||||
));
|
||||
c.append(&w::spin_f64_row(
|
||||
"Max file size (MB)",
|
||||
&doc,
|
||||
&["index", "max_file_mb"],
|
||||
0.1,
|
||||
500.0,
|
||||
0.5,
|
||||
1,
|
||||
10.0,
|
||||
));
|
||||
|
||||
c.append(&w::section("Search"));
|
||||
c.append(&w::spin_row(
|
||||
"Result limit",
|
||||
&doc,
|
||||
&["search", "limit"],
|
||||
1.0,
|
||||
100.0,
|
||||
1.0,
|
||||
10,
|
||||
));
|
||||
c.append(&w::spin_row(
|
||||
"Snippet length",
|
||||
&doc,
|
||||
&["search", "snippet_len"],
|
||||
20.0,
|
||||
2000.0,
|
||||
20.0,
|
||||
200,
|
||||
));
|
||||
|
||||
outer.append(&w::save_button(&doc, path));
|
||||
outer
|
||||
}
|
||||
107
bos-settings/src/ui/views/datetime.rs
Normal file
107
bos-settings/src/ui/views/datetime.rs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
//! Timezone + NTP, over `timedatectl`. `systemd-timesyncd` is enabled by
|
||||
//! default (see `post-install.sh`), so NTP sync is on out of the box — this
|
||||
//! panel is mostly for picking a timezone and confirming sync is healthy.
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{Box as GBox, DropDown, Expression, Label, StringList, Switch};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
fn show_property(prop: &str) -> String {
|
||||
Command::new("timedatectl")
|
||||
.args(["show", &format!("--property={prop}")])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
String::from_utf8_lossy(&o.stdout)
|
||||
.trim()
|
||||
.strip_prefix(&format!("{prop}="))
|
||||
.map(str::to_string)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn list_timezones() -> Vec<String> {
|
||||
Command::new("timedatectl")
|
||||
.arg("list-timezones")
|
||||
.output()
|
||||
.ok()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).lines().map(str::to_string).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn current_time_label() -> String {
|
||||
Command::new("date")
|
||||
.arg("+%A, %d %B %Y %H:%M")
|
||||
.output()
|
||||
.ok()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let (outer, content) = w::view_scaffold("Date & Time");
|
||||
|
||||
content.append(&w::info_row("Current time", ¤t_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
|
||||
}
|
||||
246
bos-settings/src/ui/views/firewall.rs
Normal file
246
bos-settings/src/ui/views/firewall.rs
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
//! ufw firewall rules — BOS enables ufw by default (deny incoming, allow
|
||||
//! outgoing, mDNS allowed for printer discovery; see post-install.sh) but
|
||||
//! previously offered no graphical way to add/remove a rule for something
|
||||
//! like a local dev server or a LAN game, only a terminal.
|
||||
//!
|
||||
//! `ufw status` itself requires root — confirmed against the installed
|
||||
//! ufw script, which exits with "ERROR: You need to be root to run this
|
||||
//! script" for a plain status check, not just for changes. Every other
|
||||
//! panel's read-only state is free to query eagerly in `build()`, but doing
|
||||
//! that here would mean a polkit password prompt on every single
|
||||
//! bos-settings launch (every view is constructed immediately at startup —
|
||||
//! see window.rs). So this panel starts blank and loads state only when the
|
||||
//! user clicks Refresh, deferring the one unavoidable prompt to an explicit
|
||||
//! action instead of forcing it on app open.
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch};
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Rule {
|
||||
number: String,
|
||||
text: String,
|
||||
}
|
||||
|
||||
struct Status {
|
||||
active: bool,
|
||||
rules: Vec<Rule>,
|
||||
}
|
||||
|
||||
/// One `pkexec ufw status numbered` call, parsed for both the active/inactive
|
||||
/// line and the numbered rules — a single privileged read instead of two.
|
||||
fn fetch_status() -> Option<Status> {
|
||||
let output = std::process::Command::new("pkexec")
|
||||
.args(["ufw", "status", "numbered"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
let active = text.lines().next().is_some_and(|l| l.trim() == "Status: active");
|
||||
let rules = text
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let l = l.trim_start();
|
||||
if !l.starts_with('[') {
|
||||
return None;
|
||||
}
|
||||
let (num, rest) = l.split_once(']')?;
|
||||
let number = num.trim_start_matches('[').trim().to_string();
|
||||
Some(Rule { number, text: rest.trim().to_string() })
|
||||
})
|
||||
.collect();
|
||||
Some(Status { active, rules })
|
||||
}
|
||||
|
||||
fn render_rules(list: &ListBox, rules: &[Rule], programmatic: &Rc<Cell<bool>>) {
|
||||
while let Some(child) = list.first_child() {
|
||||
list.remove(&child);
|
||||
}
|
||||
if rules.is_empty() {
|
||||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
row.set_child(Some(&w::empty_state(
|
||||
"security-high-symbolic",
|
||||
"No rules",
|
||||
"Only the default policy (deny incoming, allow outgoing) applies.",
|
||||
)));
|
||||
list.append(&row);
|
||||
return;
|
||||
}
|
||||
for rule in rules {
|
||||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
let hbox = GBox::new(Orientation::Horizontal, 16);
|
||||
hbox.add_css_class("card");
|
||||
hbox.set_margin_top(3);
|
||||
hbox.set_margin_bottom(3);
|
||||
|
||||
let text_lbl = Label::new(Some(&rule.text));
|
||||
text_lbl.set_hexpand(true);
|
||||
text_lbl.set_xalign(0.0);
|
||||
text_lbl.set_wrap(true);
|
||||
|
||||
let remove_btn = Button::with_label("Remove");
|
||||
remove_btn.add_css_class("destructive-action");
|
||||
{
|
||||
let list = list.clone();
|
||||
let number = rule.number.clone();
|
||||
let programmatic = programmatic.clone();
|
||||
remove_btn.connect_clicked(move |_| {
|
||||
let log_buf = gtk4::TextBuffer::new(None);
|
||||
let list2 = list.clone();
|
||||
let programmatic2 = programmatic.clone();
|
||||
w::stream_command_then(
|
||||
&["pkexec", "ufw", "--force", "delete", &number],
|
||||
log_buf,
|
||||
move || refresh(&list2, None, &programmatic2),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
hbox.append(&text_lbl);
|
||||
hbox.append(&remove_btn);
|
||||
row.set_child(Some(&hbox));
|
||||
list.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-fetch status on a background thread and update the list (+ switch, if
|
||||
/// given) on completion. `enabled_sw` is `None` when called from a row
|
||||
/// action (delete/add) where the enabled state can't have changed.
|
||||
///
|
||||
/// `programmatic` guards against `set_active` below re-triggering the
|
||||
/// switch's own `connect_active_notify` handler (which calls `ufw enable`/
|
||||
/// `disable`) — without it, the very first Refresh after ufw turns out to
|
||||
/// already be active would immediately fire an unwanted `ufw enable` the
|
||||
/// moment `set_active(true)` flips a switch that just became sensitive.
|
||||
fn refresh(list: &ListBox, enabled_sw: Option<&Switch>, programmatic: &Rc<Cell<bool>>) {
|
||||
let (tx, rx) = async_channel::bounded::<Option<Status>>(1);
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send_blocking(fetch_status());
|
||||
});
|
||||
let list = list.clone();
|
||||
let enabled_sw = enabled_sw.cloned();
|
||||
let programmatic = programmatic.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
if let Ok(Some(status)) = rx.recv().await {
|
||||
render_rules(&list, &status.rules, &programmatic);
|
||||
if let Some(sw) = &enabled_sw {
|
||||
programmatic.set(true);
|
||||
sw.set_sensitive(true);
|
||||
sw.set_active(status.active);
|
||||
programmatic.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let (outer, content) = w::view_scaffold("Firewall");
|
||||
content.append(&w::hint(
|
||||
"Reading and changing firewall state needs your password (polkit) \
|
||||
— ufw requires root even just to check status. Click Refresh to \
|
||||
load the current state.",
|
||||
));
|
||||
|
||||
let programmatic = Rc::new(Cell::new(false));
|
||||
|
||||
let enabled_sw = Switch::new();
|
||||
enabled_sw.set_sensitive(false);
|
||||
content.append(&w::row("Firewall enabled", &enabled_sw));
|
||||
content.append(&w::hint(
|
||||
"Default policy: deny incoming, allow outgoing. mDNS (5353/udp) is \
|
||||
allowed by default so printer/network discovery keeps working.",
|
||||
));
|
||||
|
||||
let list = ListBox::new();
|
||||
list.set_selection_mode(gtk4::SelectionMode::None);
|
||||
{
|
||||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
row.set_child(Some(&w::empty_state(
|
||||
"security-high-symbolic",
|
||||
"Status not loaded",
|
||||
"Click Refresh below to check the firewall's current state.",
|
||||
)));
|
||||
list.append(&row);
|
||||
}
|
||||
let scroll = ScrolledWindow::new();
|
||||
scroll.set_vexpand(true);
|
||||
scroll.set_min_content_height(260);
|
||||
scroll.set_child(Some(&list));
|
||||
content.append(&scroll);
|
||||
|
||||
let refresh_btn = Button::with_label("Refresh status");
|
||||
{
|
||||
let list = list.clone();
|
||||
let enabled_sw = enabled_sw.clone();
|
||||
let programmatic = programmatic.clone();
|
||||
refresh_btn.connect_clicked(move |_| refresh(&list, Some(&enabled_sw), &programmatic));
|
||||
}
|
||||
content.append(&refresh_btn);
|
||||
|
||||
{
|
||||
let list = list.clone();
|
||||
let programmatic = programmatic.clone();
|
||||
enabled_sw.connect_active_notify(move |s| {
|
||||
// Skip both the pre-refresh insensitive state and any
|
||||
// programmatic set_active() from refresh() itself — only a
|
||||
// real user click should call out to ufw enable/disable.
|
||||
if !s.is_sensitive() || programmatic.get() {
|
||||
return;
|
||||
}
|
||||
let verb = if s.is_active() { "enable" } else { "disable" };
|
||||
let log_buf = gtk4::TextBuffer::new(None);
|
||||
let list2 = list.clone();
|
||||
let programmatic2 = programmatic.clone();
|
||||
w::stream_command_then(&["pkexec", "ufw", "--force", verb], log_buf, move || {
|
||||
refresh(&list2, None, &programmatic2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
content.append(&w::section("Add rule"));
|
||||
content.append(&w::hint(
|
||||
"e.g. \"8080/tcp\", \"22/tcp\", or a service name like \"OpenSSH\".",
|
||||
));
|
||||
let add_row = GBox::new(Orientation::Horizontal, 8);
|
||||
let add_entry = Entry::new();
|
||||
add_entry.set_hexpand(true);
|
||||
add_entry.set_placeholder_text(Some("port/proto or service name"));
|
||||
let add_btn = Button::with_label("Allow");
|
||||
{
|
||||
let list = list.clone();
|
||||
let add_entry = add_entry.clone();
|
||||
let programmatic = programmatic.clone();
|
||||
add_btn.connect_clicked(move |_| {
|
||||
let rule = add_entry.text().to_string();
|
||||
if rule.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
let log_buf = gtk4::TextBuffer::new(None);
|
||||
let list2 = list.clone();
|
||||
let add_entry2 = add_entry.clone();
|
||||
let programmatic2 = programmatic.clone();
|
||||
w::stream_command_then(
|
||||
&["pkexec", "ufw", "allow", rule.trim()],
|
||||
log_buf,
|
||||
move || {
|
||||
add_entry2.set_text("");
|
||||
refresh(&list2, None, &programmatic2);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
add_row.append(&add_entry);
|
||||
add_row.append(&add_btn);
|
||||
content.append(&add_row);
|
||||
|
||||
outer
|
||||
}
|
||||
159
bos-settings/src/ui/views/firmware.rs
Normal file
159
bos-settings/src/ui/views/firmware.rs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
//! fwupd firmware updates — thin GUI over `fwupdmgr`, following the same
|
||||
//! "stream command output, refresh the list on completion" pattern as
|
||||
//! Packages. fwupd itself (the daemon + CLI) is always installed;
|
||||
//! `fwupd-refresh.timer` already keeps the metadata current in the
|
||||
//! background, this panel is just for actually seeing/applying updates.
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, TextView};
|
||||
|
||||
use crate::ui::widgets as w;
|
||||
use crate::ui::widgets::stream_command_then;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FwDevice {
|
||||
name: String,
|
||||
version: String,
|
||||
}
|
||||
|
||||
fn list_updatable() -> Vec<FwDevice> {
|
||||
let Ok(output) = std::process::Command::new("fwupdmgr")
|
||||
.args(["get-devices", "--json"])
|
||||
.output()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(root) = serde_json::from_slice::<serde_json::Value>(&output.stdout) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(devices) = root.get("Devices").and_then(|d| d.as_array()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
devices
|
||||
.iter()
|
||||
.filter(|d| {
|
||||
d.get("Flags")
|
||||
.and_then(|f| f.as_array())
|
||||
.is_some_and(|flags| flags.iter().any(|f| f.as_str() == Some("updatable")))
|
||||
})
|
||||
.filter_map(|d| {
|
||||
Some(FwDevice {
|
||||
name: d.get("Name")?.as_str()?.to_string(),
|
||||
version: d.get("Version").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn populate(list: &ListBox) {
|
||||
while let Some(child) = list.first_child() {
|
||||
list.remove(&child);
|
||||
}
|
||||
let devices = list_updatable();
|
||||
if devices.is_empty() {
|
||||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
row.set_child(Some(&w::empty_state(
|
||||
"software-update-available-symbolic",
|
||||
"No updatable firmware devices found",
|
||||
"Not every device supports firmware updates through fwupd.",
|
||||
)));
|
||||
list.append(&row);
|
||||
return;
|
||||
}
|
||||
for dev in devices {
|
||||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
let hbox = GBox::new(Orientation::Horizontal, 16);
|
||||
hbox.add_css_class("card");
|
||||
hbox.set_margin_top(3);
|
||||
hbox.set_margin_bottom(3);
|
||||
|
||||
let name_lbl = Label::new(Some(&dev.name));
|
||||
name_lbl.set_hexpand(true);
|
||||
name_lbl.set_xalign(0.0);
|
||||
let ver_lbl = Label::new(Some(&dev.version));
|
||||
ver_lbl.add_css_class("dim-label");
|
||||
ver_lbl.set_xalign(1.0);
|
||||
|
||||
hbox.append(&name_lbl);
|
||||
hbox.append(&ver_lbl);
|
||||
row.set_child(Some(&hbox));
|
||||
list.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let (outer, content) = w::view_scaffold("Firmware");
|
||||
content.append(&w::hint(
|
||||
"Firmware updates for hardware that supports them (UEFI, some \
|
||||
peripherals). fwupd-refresh.timer already keeps update metadata \
|
||||
current in the background.",
|
||||
));
|
||||
|
||||
let list = ListBox::new();
|
||||
list.set_selection_mode(gtk4::SelectionMode::None);
|
||||
populate(&list);
|
||||
let scroll = ScrolledWindow::new();
|
||||
scroll.set_vexpand(true);
|
||||
scroll.set_child(Some(&list));
|
||||
content.append(&scroll);
|
||||
|
||||
let log_buf = gtk4::TextBuffer::new(None);
|
||||
let log_view = TextView::with_buffer(&log_buf);
|
||||
log_view.set_editable(false);
|
||||
log_view.set_monospace(true);
|
||||
log_view.set_height_request(140);
|
||||
log_view.set_margin_top(8);
|
||||
log_view.set_visible(false);
|
||||
|
||||
let btn_row = GBox::new(Orientation::Horizontal, 8);
|
||||
btn_row.set_margin_top(12);
|
||||
|
||||
let check_btn = Button::with_label("Check for updates");
|
||||
{
|
||||
let log_buf = log_buf.clone();
|
||||
let log_view = log_view.clone();
|
||||
let list = list.clone();
|
||||
check_btn.connect_clicked(move |_| {
|
||||
log_buf.set_text("");
|
||||
log_view.set_visible(true);
|
||||
let list2 = list.clone();
|
||||
stream_command_then(&["fwupdmgr", "refresh"], log_buf.clone(), move || {
|
||||
populate(&list2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let update_btn = Button::with_label("Update all");
|
||||
update_btn.add_css_class("suggested-action");
|
||||
{
|
||||
let log_buf = log_buf.clone();
|
||||
let log_view = log_view.clone();
|
||||
let list = list.clone();
|
||||
update_btn.connect_clicked(move |_| {
|
||||
log_buf.set_text("");
|
||||
log_view.set_visible(true);
|
||||
let list2 = list.clone();
|
||||
stream_command_then(&["fwupdmgr", "update", "-y"], log_buf.clone(), move || {
|
||||
populate(&list2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let refresh_btn = Button::with_label("Refresh list");
|
||||
{
|
||||
let list = list.clone();
|
||||
refresh_btn.connect_clicked(move |_| {
|
||||
populate(&list);
|
||||
});
|
||||
}
|
||||
|
||||
btn_row.append(&check_btn);
|
||||
btn_row.append(&update_btn);
|
||||
btn_row.append(&refresh_btn);
|
||||
content.append(&btn_row);
|
||||
content.append(&log_view);
|
||||
|
||||
outer
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
use gtk4::prelude::*;
|
||||
use gtk4::{Box as GBox, Button, Label, Orientation};
|
||||
use gtk4::{Box as GBox, Button};
|
||||
use std::process::Command;
|
||||
|
||||
fn get_monitors() -> Vec<String> {
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
fn get_monitors() -> Vec<(String, String)> {
|
||||
let Ok(output) = Command::new("hyprctl").args(["monitors", "-j"]).output() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
|
@ -17,7 +19,7 @@ fn get_monitors() -> Vec<String> {
|
|||
let w = m.get("width")?.as_u64()?;
|
||||
let h = m.get("height")?.as_u64()?;
|
||||
let refresh = m.get("refreshRate")?.as_f64()?;
|
||||
Some(format!("{name} {w}x{h} @ {refresh:.0}Hz"))
|
||||
Some((name.to_string(), format!("{w}x{h} @ {refresh:.0}Hz")))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -26,62 +28,58 @@ fn hypr_path(name: &str) -> std::path::PathBuf {
|
|||
crate::config::config_dir().join("hypr").join(name)
|
||||
}
|
||||
|
||||
/// Open `path` in $EDITOR (nano if unset) inside a terminal window. Spawning
|
||||
/// an editor directly (no terminal) is a silent no-op for any TUI editor —
|
||||
/// there's nothing for it to attach to — so it always needs a terminal
|
||||
/// wrapper. Uses kitty, which is what BOS actually ships (not foot).
|
||||
fn open_in_terminal(path: &std::path::Path) {
|
||||
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string());
|
||||
if let Ok(mut child) = Command::new("kitty").args(["-e", &editor]).arg(path).spawn() {
|
||||
std::thread::spawn(move || { let _ = child.wait(); });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let vbox = GBox::new(Orientation::Vertical, 12);
|
||||
vbox.add_css_class("view-content");
|
||||
|
||||
let title = Label::new(Some("Hyprland"));
|
||||
title.add_css_class("title");
|
||||
title.set_xalign(0.0);
|
||||
vbox.append(&title);
|
||||
|
||||
let monitors_lbl = Label::new(Some("Connected monitors"));
|
||||
monitors_lbl.set_xalign(0.0);
|
||||
monitors_lbl.set_margin_top(8);
|
||||
monitors_lbl.set_margin_bottom(4);
|
||||
vbox.append(&monitors_lbl);
|
||||
let (outer, content) = w::view_scaffold("Display");
|
||||
|
||||
content.append(&w::section("Connected monitors"));
|
||||
let monitors = get_monitors();
|
||||
if monitors.is_empty() {
|
||||
let lbl = Label::new(Some("No monitors detected (is Hyprland running?)"));
|
||||
lbl.set_xalign(0.0);
|
||||
vbox.append(&lbl);
|
||||
content.append(&w::hint("No monitors detected (is Hyprland running?)"));
|
||||
} else {
|
||||
for mon in &monitors {
|
||||
let lbl = Label::new(Some(mon));
|
||||
lbl.set_xalign(0.0);
|
||||
lbl.add_css_class("monospace");
|
||||
vbox.append(&lbl);
|
||||
for (name, mode) in &monitors {
|
||||
content.append(&w::info_row(name, mode));
|
||||
}
|
||||
}
|
||||
|
||||
let open_btn = Button::with_label("Open hyprland.conf in editor");
|
||||
open_btn.set_margin_top(16);
|
||||
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_halign(gtk4::Align::Start);
|
||||
{
|
||||
let conf_path = hypr_path("hyprland.conf");
|
||||
open_btn.connect_clicked(move |_| {
|
||||
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "foot".to_string());
|
||||
if let Ok(mut child) = Command::new(&editor).arg(&conf_path).spawn() {
|
||||
std::thread::spawn(move || { let _ = child.wait(); });
|
||||
let conf_path = hypr_path("hyprland.lua");
|
||||
open_btn.connect_clicked(move |_| open_in_terminal(&conf_path));
|
||||
}
|
||||
});
|
||||
}
|
||||
vbox.append(&open_btn);
|
||||
content.append(&open_btn);
|
||||
|
||||
let keybinds_btn = Button::with_label("Open keybinds.conf in editor");
|
||||
keybinds_btn.set_margin_top(8);
|
||||
// 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_halign(gtk4::Align::Start);
|
||||
{
|
||||
let kb_path = hypr_path("keybinds.conf");
|
||||
keybinds_btn.connect_clicked(move |_| {
|
||||
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "foot".to_string());
|
||||
if let Ok(mut child) = Command::new(&editor).arg(&kb_path).spawn() {
|
||||
std::thread::spawn(move || { let _ = child.wait(); });
|
||||
let kb_path = std::path::PathBuf::from("/usr/share/bos/keybinds.txt");
|
||||
keybinds_btn.connect_clicked(move |_| open_in_terminal(&kb_path));
|
||||
}
|
||||
});
|
||||
}
|
||||
vbox.append(&keybinds_btn);
|
||||
content.append(&keybinds_btn);
|
||||
|
||||
vbox
|
||||
outer
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,20 @@
|
|||
pub mod about;
|
||||
pub mod aur;
|
||||
pub mod bread;
|
||||
pub mod breadbar;
|
||||
pub mod breadbox;
|
||||
pub mod breadclip;
|
||||
pub mod breadcrumbs;
|
||||
pub mod breadpad;
|
||||
pub mod breadpaper;
|
||||
pub mod breadsearch;
|
||||
pub mod datetime;
|
||||
pub mod firewall;
|
||||
pub mod firmware;
|
||||
pub mod hyprland;
|
||||
pub mod network;
|
||||
pub mod packages;
|
||||
pub mod power;
|
||||
pub mod snapshots;
|
||||
pub mod sound;
|
||||
pub mod users;
|
||||
|
|
|
|||
328
bos-settings/src/ui/views/network.rs
Normal file
328
bos-settings/src/ui/views/network.rs
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
//! Wi-Fi + Ethernet over `nmcli`. NetworkManager lets the active session user
|
||||
//! manage connections via polkit already, so the common paths (scan, connect,
|
||||
//! toggle radio) need no `pkexec`. VPN import, 802.1x, and other edge cases
|
||||
//! are punted to `nm-connection-editor` via the Advanced button rather than
|
||||
//! reimplemented here.
|
||||
//!
|
||||
//! The scan is deliberately NOT run in `build()` — every view is constructed
|
||||
//! eagerly at app launch (see `window.rs`), and a Wi-Fi scan takes seconds;
|
||||
//! doing it here would add that latency to every bos-settings launch. It
|
||||
//! only runs when the user clicks Scan.
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashSet;
|
||||
use std::process::Command;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct WifiNetwork {
|
||||
ssid: String,
|
||||
signal: i32,
|
||||
secured: bool,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
fn radio_enabled() -> bool {
|
||||
Command::new("nmcli")
|
||||
.args(["radio", "wifi"])
|
||||
.output()
|
||||
.ok()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "enabled")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn ethernet_status() -> Option<String> {
|
||||
let out = Command::new("nmcli").args(["-t", "-f", "DEVICE,TYPE,STATE"]).arg("dev").output().ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
text.lines().find_map(|l| {
|
||||
let mut cols = l.splitn(3, ':');
|
||||
let (dev, ty, state) = (cols.next()?, cols.next()?, cols.next()?);
|
||||
(ty == "ethernet").then(|| format!("{dev}: {state}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn known_connection_names() -> HashSet<String> {
|
||||
let Ok(out) = Command::new("nmcli").args(["-t", "-f", "NAME"]).arg("con").arg("show").output() else {
|
||||
return HashSet::new();
|
||||
};
|
||||
String::from_utf8_lossy(&out.stdout).lines().map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// Scan + list Wi-Fi networks, deduplicated by SSID (keeping the strongest
|
||||
/// signal — the same AP shows once per band/BSSID otherwise).
|
||||
fn scan_wifi() -> Vec<WifiNetwork> {
|
||||
let _ = Command::new("nmcli").args(["dev", "wifi", "rescan"]).output();
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
let Ok(out) = Command::new("nmcli")
|
||||
.args(["-t", "-f", "SSID,SIGNAL,SECURITY,IN-USE", "dev", "wifi", "list"])
|
||||
.output()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let mut by_ssid: std::collections::HashMap<String, WifiNetwork> = std::collections::HashMap::new();
|
||||
for line in text.lines() {
|
||||
let mut cols = line.splitn(4, ':');
|
||||
let (ssid, signal, security, in_use) = (cols.next(), cols.next(), cols.next(), cols.next());
|
||||
let Some(ssid) = ssid.filter(|s| !s.is_empty()) else { continue };
|
||||
let signal: i32 = signal.and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let net = WifiNetwork {
|
||||
ssid: ssid.to_string(),
|
||||
signal,
|
||||
secured: security.map(|s| !s.is_empty()).unwrap_or(false),
|
||||
active: in_use == Some("*"),
|
||||
};
|
||||
by_ssid
|
||||
.entry(ssid.to_string())
|
||||
.and_modify(|existing| if net.signal > existing.signal { *existing = net.clone() })
|
||||
.or_insert(net);
|
||||
}
|
||||
let mut list: Vec<_> = by_ssid.into_values().collect();
|
||||
list.sort_by(|a, b| b.signal.cmp(&a.signal));
|
||||
list
|
||||
}
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let (outer, content) = w::view_scaffold("Network");
|
||||
|
||||
content.append(&w::section("Wi-Fi"));
|
||||
let radio_sw = Switch::new();
|
||||
radio_sw.set_active(radio_enabled());
|
||||
radio_sw.connect_active_notify(|s| {
|
||||
let val = if s.is_active() { "on" } else { "off" };
|
||||
let _ = Command::new("nmcli").args(["radio", "wifi", val]).spawn();
|
||||
});
|
||||
content.append(&w::row("Wi-Fi radio", &radio_sw));
|
||||
|
||||
let status = Label::new(None);
|
||||
status.add_css_class("dim-label");
|
||||
status.set_xalign(0.0);
|
||||
|
||||
let list = ListBox::new();
|
||||
list.set_selection_mode(gtk4::SelectionMode::None);
|
||||
// Starts with an empty_state row ("Not scanned yet") rather than being
|
||||
// hidden — the "no results yet" and "scanned, found nothing" states are
|
||||
// the same idea and should look the same, not one a hint label and the
|
||||
// other an empty_state card.
|
||||
{
|
||||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
row.set_child(Some(&w::empty_state(
|
||||
"network-wireless-symbolic",
|
||||
"Not scanned yet",
|
||||
"Press Scan to see nearby networks.",
|
||||
)));
|
||||
list.append(&row);
|
||||
}
|
||||
let scroll = ScrolledWindow::new();
|
||||
scroll.set_min_content_height(220);
|
||||
scroll.set_child(Some(&list));
|
||||
content.append(&scroll);
|
||||
|
||||
// Password entry, shown only while connecting to a secured, unknown
|
||||
// network — set visible/hidden rather than a modal dialog, to keep this
|
||||
// panel's async flow in one place instead of a nested dialog callback.
|
||||
let pw_row = GBox::new(Orientation::Horizontal, 8);
|
||||
let pw_entry = Entry::new();
|
||||
pw_entry.set_visibility(false);
|
||||
pw_entry.set_hexpand(true);
|
||||
pw_entry.set_placeholder_text(Some("Password"));
|
||||
let pw_connect_btn = Button::with_label("Connect");
|
||||
pw_row.append(&pw_entry);
|
||||
pw_row.append(&pw_connect_btn);
|
||||
pw_row.set_visible(false);
|
||||
content.append(&pw_row);
|
||||
content.append(&status);
|
||||
|
||||
let pending_ssid: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
|
||||
|
||||
let connect_open_or_known = {
|
||||
let status = status.clone();
|
||||
move |ssid: String| {
|
||||
status.set_text(&format!("Connecting to {ssid}…"));
|
||||
let log_buf = gtk4::TextBuffer::new(None);
|
||||
let status2 = status.clone();
|
||||
let ssid2 = ssid.clone();
|
||||
let known = known_connection_names();
|
||||
let args: Vec<String> = if known.contains(&ssid) {
|
||||
vec!["nmcli".into(), "con".into(), "up".into(), ssid.clone()]
|
||||
} else {
|
||||
vec!["nmcli".into(), "dev".into(), "wifi".into(), "connect".into(), ssid.clone()]
|
||||
};
|
||||
let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
w::stream_command_then(&args_ref, log_buf.clone(), move || {
|
||||
let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false);
|
||||
if text.to_lowercase().contains("error") {
|
||||
status2.set_text(&format!("Failed to connect to {ssid2}: {}", text.trim()));
|
||||
} else {
|
||||
status2.set_text(&format!("Connected to {ssid2}"));
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let pw_row = pw_row.clone();
|
||||
let pw_entry = pw_entry.clone();
|
||||
let pending_ssid = pending_ssid.clone();
|
||||
let status = status.clone();
|
||||
pw_connect_btn.connect_clicked(move |_| {
|
||||
let Some(ssid) = pending_ssid.borrow_mut().take() else { return };
|
||||
let password = pw_entry.text().to_string();
|
||||
pw_row.set_visible(false);
|
||||
pw_entry.set_text("");
|
||||
status.set_text(&format!("Connecting to {ssid}…"));
|
||||
let log_buf = gtk4::TextBuffer::new(None);
|
||||
let status2 = status.clone();
|
||||
let ssid2 = ssid.clone();
|
||||
w::stream_command_then(
|
||||
&["nmcli", "dev", "wifi", "connect", &ssid, "password", &password],
|
||||
log_buf.clone(),
|
||||
move || {
|
||||
let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false);
|
||||
if text.to_lowercase().contains("error") {
|
||||
status2.set_text(&format!("Failed to connect to {ssid2}: wrong password?"));
|
||||
} else {
|
||||
status2.set_text(&format!("Connected to {ssid2}"));
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
let populate_list = {
|
||||
let list = list.clone();
|
||||
let pw_row = pw_row.clone();
|
||||
let pending_ssid = pending_ssid.clone();
|
||||
let connect_open_or_known = connect_open_or_known.clone();
|
||||
move |networks: Vec<WifiNetwork>| {
|
||||
while let Some(child) = list.first_child() {
|
||||
list.remove(&child);
|
||||
}
|
||||
if networks.is_empty() {
|
||||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
row.set_child(Some(&w::empty_state(
|
||||
"network-wireless-offline-symbolic",
|
||||
"No networks found",
|
||||
"Try Scan again, or check Wi-Fi radio is on.",
|
||||
)));
|
||||
list.append(&row);
|
||||
return;
|
||||
}
|
||||
let known = known_connection_names();
|
||||
for net in networks {
|
||||
let row = ListBoxRow::new();
|
||||
let hbox = GBox::new(Orientation::Horizontal, 12);
|
||||
hbox.set_margin_top(4);
|
||||
hbox.set_margin_bottom(4);
|
||||
hbox.set_margin_start(8);
|
||||
hbox.set_margin_end(8);
|
||||
|
||||
let name = if net.active {
|
||||
format!("{} (connected)", net.ssid)
|
||||
} else {
|
||||
net.ssid.clone()
|
||||
};
|
||||
let name_lbl = Label::new(Some(&name));
|
||||
name_lbl.set_hexpand(true);
|
||||
name_lbl.set_xalign(0.0);
|
||||
if net.active {
|
||||
name_lbl.add_css_class("heading");
|
||||
}
|
||||
|
||||
let signal_icon_name = match net.signal.clamp(0, 100) {
|
||||
0..=24 => "network-wireless-signal-weak-symbolic",
|
||||
25..=49 => "network-wireless-signal-ok-symbolic",
|
||||
50..=74 => "network-wireless-signal-good-symbolic",
|
||||
_ => "network-wireless-signal-excellent-symbolic",
|
||||
};
|
||||
let signal_icon = gtk4::Image::from_icon_name(signal_icon_name);
|
||||
signal_icon.add_css_class("dim-label");
|
||||
|
||||
let meta_lbl = Label::new(Some(&format!("{}%", net.signal.clamp(0, 100))));
|
||||
meta_lbl.add_css_class("dim-label");
|
||||
|
||||
hbox.append(&name_lbl);
|
||||
if net.secured {
|
||||
let lock_icon = gtk4::Image::from_icon_name("channel-secure-symbolic");
|
||||
lock_icon.add_css_class("dim-label");
|
||||
hbox.append(&lock_icon);
|
||||
}
|
||||
hbox.append(&signal_icon);
|
||||
hbox.append(&meta_lbl);
|
||||
|
||||
if !net.active {
|
||||
let connect_btn = Button::with_label("Connect");
|
||||
let ssid = net.ssid.clone();
|
||||
let secured = net.secured;
|
||||
let already_known = known.contains(&net.ssid);
|
||||
let pw_row = pw_row.clone();
|
||||
let pending_ssid = pending_ssid.clone();
|
||||
let connect_open_or_known = connect_open_or_known.clone();
|
||||
connect_btn.connect_clicked(move |_| {
|
||||
if secured && !already_known {
|
||||
*pending_ssid.borrow_mut() = Some(ssid.clone());
|
||||
pw_row.set_visible(true);
|
||||
} else {
|
||||
pw_row.set_visible(false);
|
||||
connect_open_or_known(ssid.clone());
|
||||
}
|
||||
});
|
||||
hbox.append(&connect_btn);
|
||||
}
|
||||
|
||||
row.set_child(Some(&hbox));
|
||||
list.append(&row);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let scan_btn = Button::with_label("Scan");
|
||||
scan_btn.set_halign(gtk4::Align::Start);
|
||||
{
|
||||
let status = status.clone();
|
||||
let populate_list = populate_list.clone();
|
||||
scan_btn.connect_clicked(move |btn| {
|
||||
btn.set_sensitive(false);
|
||||
status.set_text("Scanning…");
|
||||
let (tx, rx) = async_channel::bounded::<Vec<WifiNetwork>>(1);
|
||||
std::thread::spawn(move || {
|
||||
let nets = scan_wifi();
|
||||
let _ = tx.send_blocking(nets);
|
||||
});
|
||||
let btn = btn.clone();
|
||||
let status = status.clone();
|
||||
let populate_list = populate_list.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
if let Ok(nets) = rx.recv().await {
|
||||
let count = nets.len();
|
||||
populate_list(nets);
|
||||
status.set_text(&format!("Found {count} network(s)"));
|
||||
}
|
||||
btn.set_sensitive(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
content.append(&scan_btn);
|
||||
|
||||
if let Some(eth) = ethernet_status() {
|
||||
content.append(&w::section("Ethernet"));
|
||||
content.append(&w::info_row("Status", ð));
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
use async_channel;
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{
|
||||
Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, TextView,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use crate::ui::widgets as w;
|
||||
use crate::ui::widgets::{stream_command, stream_command_then};
|
||||
|
||||
fn read_installed() -> HashMap<String, String> {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
|
||||
|
|
@ -15,11 +15,20 @@ fn read_installed() -> HashMap<String, String> {
|
|||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let Ok(parsed) = serde_json::from_str::<HashMap<String, serde_json::Value>>(&text) else {
|
||||
let Ok(mut parsed) = serde_json::from_str::<serde_json::Value>(&text) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
// installed.json is {"packages": {name: {version, binaries, services}}},
|
||||
// not a flat map of package name to metadata — without unwrapping this,
|
||||
// every install shows a single bogus row named "packages".
|
||||
let Some(packages) = parsed.get_mut("packages").map(std::mem::take) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let Ok(packages) = serde_json::from_value::<HashMap<String, serde_json::Value>>(packages) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
|
||||
parsed
|
||||
packages
|
||||
.into_iter()
|
||||
.filter_map(|(name, val)| {
|
||||
let version = val
|
||||
|
|
@ -32,80 +41,24 @@ fn read_installed() -> HashMap<String, String> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) {
|
||||
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;
|
||||
fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer, log_view: &TextView) {
|
||||
while let Some(child) = list.first_child() {
|
||||
list.remove(&child);
|
||||
}
|
||||
};
|
||||
|
||||
// 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();
|
||||
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();
|
||||
});
|
||||
|
||||
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"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let vbox = GBox::new(Orientation::Vertical, 0);
|
||||
vbox.add_css_class("view-content");
|
||||
|
||||
let title = Label::new(Some("Packages"));
|
||||
title.add_css_class("title");
|
||||
title.set_xalign(0.0);
|
||||
vbox.append(&title);
|
||||
|
||||
let subtitle = Label::new(Some("Bread ecosystem packages installed via bakery."));
|
||||
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 packages = read_installed();
|
||||
if packages.is_empty() {
|
||||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
let lbl = Label::new(Some(
|
||||
"No bakery packages found (~/.local/state/bakery/installed.json)",
|
||||
));
|
||||
lbl.set_margin_top(8);
|
||||
lbl.set_margin_bottom(8);
|
||||
lbl.set_margin_start(8);
|
||||
row.set_child(Some(&lbl));
|
||||
row.set_child(Some(&w::empty_state(
|
||||
"package-x-generic-symbolic",
|
||||
"No bakery packages found",
|
||||
"~/.local/state/bakery/installed.json is missing or empty.",
|
||||
)));
|
||||
list.append(&row);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut names: Vec<_> = packages.iter().collect();
|
||||
names.sort_by_key(|(k, _)| k.as_str());
|
||||
|
||||
|
|
@ -113,10 +66,9 @@ pub fn build() -> GBox {
|
|||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
let hbox = GBox::new(Orientation::Horizontal, 16);
|
||||
hbox.set_margin_top(6);
|
||||
hbox.set_margin_bottom(6);
|
||||
hbox.set_margin_start(8);
|
||||
hbox.set_margin_end(8);
|
||||
hbox.add_css_class("card");
|
||||
hbox.set_margin_top(3);
|
||||
hbox.set_margin_bottom(3);
|
||||
|
||||
let name_lbl = Label::new(Some(name));
|
||||
name_lbl.set_hexpand(true);
|
||||
|
|
@ -125,17 +77,29 @@ pub fn build() -> GBox {
|
|||
let ver_lbl = Label::new(Some(version));
|
||||
ver_lbl.set_xalign(1.0);
|
||||
|
||||
// Spawn a thread to reap the child process — no zombies
|
||||
let pkg_name = name.clone();
|
||||
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 |_| {
|
||||
match Command::new("bakery").args(["update", &pkg_name]).spawn() {
|
||||
Ok(mut child) => {
|
||||
std::thread::spawn(move || { let _ = child.wait(); });
|
||||
}
|
||||
Err(_) => {} // bakery not found; button is a no-op
|
||||
}
|
||||
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
|
||||
// this was fire-and-forget with the Err case silently
|
||||
// swallowed, so a missing `bakery` binary made the button
|
||||
// look broken with zero feedback either way.
|
||||
stream_command_then(&["bakery", "update", &pkg_name], log_buf.clone(), move || {
|
||||
populate_packages(&list2, &log_buf2, &log_view2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
hbox.append(&name_lbl);
|
||||
hbox.append(&ver_lbl);
|
||||
|
|
@ -143,46 +107,99 @@ pub fn build() -> GBox {
|
|||
row.set_child(Some(&hbox));
|
||||
list.append(&row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let scroll = ScrolledWindow::new();
|
||||
scroll.set_vexpand(true);
|
||||
scroll.set_child(Some(&list));
|
||||
vbox.append(&scroll);
|
||||
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 list = ListBox::new();
|
||||
list.set_selection_mode(gtk4::SelectionMode::None);
|
||||
|
||||
let log_buf = gtk4::TextBuffer::new(None);
|
||||
|
||||
// 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);
|
||||
|
||||
let check_btn = Button::with_label("Check for updates");
|
||||
// Labeled "List installed", not "Check for updates" — bakery list is a
|
||||
// listing of installed packages, it doesn't check for available updates.
|
||||
let check_btn = Button::with_label("List installed");
|
||||
let update_all_btn = Button::with_label("Update all");
|
||||
|
||||
{
|
||||
let log_buf = log_buf.clone();
|
||||
let log_view = log_view.clone();
|
||||
check_btn.connect_clicked(move |_| {
|
||||
log_buf.set_text("");
|
||||
log_view.set_visible(true);
|
||||
stream_command(&["bakery", "list"], log_buf.clone());
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let log_buf = log_buf.clone();
|
||||
let log_view = log_view.clone();
|
||||
update_all_btn.connect_clicked(move |_| {
|
||||
log_buf.set_text("");
|
||||
log_view.set_visible(true);
|
||||
stream_command(&["bakery", "update", "--all"], log_buf.clone());
|
||||
});
|
||||
}
|
||||
|
||||
btn_row.append(&check_btn);
|
||||
btn_row.append(&update_all_btn);
|
||||
vbox.append(&btn_row);
|
||||
vbox.append(&log_view);
|
||||
content.append(&btn_row);
|
||||
|
||||
vbox
|
||||
// ---------------------------------------------------------------------
|
||||
// System packages (pacman) — the other update channel. bakery only
|
||||
// covers the userspace bread apps; base system/kernel/bos-settings/AUR
|
||||
// republished packages come from pacman + the [breadway] repo, and
|
||||
// bos-update (the CLI) already updates both — this panel previously
|
||||
// only exposed the bakery half, so a user relying on it alone would
|
||||
// never get base-system updates through the GUI.
|
||||
// ---------------------------------------------------------------------
|
||||
content.append(&w::section("System packages (pacman)"));
|
||||
content.append(&w::hint(
|
||||
"Base system, kernel, bos-settings, and republished AUR packages — \
|
||||
the other half of what `bos-update` covers. Needs your password \
|
||||
(polkit) since pacman requires root.",
|
||||
));
|
||||
|
||||
let pacman_btn_row = GBox::new(Orientation::Horizontal, 8);
|
||||
pacman_btn_row.set_margin_top(8);
|
||||
let pacman_update_btn = Button::with_label("Update system (pacman -Syu)");
|
||||
{
|
||||
let log_buf = log_buf.clone();
|
||||
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);
|
||||
|
||||
content.append(&log_view);
|
||||
|
||||
outer
|
||||
}
|
||||
|
|
|
|||
193
bos-settings/src/ui/views/power.rs
Normal file
193
bos-settings/src/ui/views/power.rs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
//! Battery/power status (upower), brightness (brightnessctl), and TLP's
|
||||
//! current profile. Deliberately no AC/Battery/Performance *switcher* —
|
||||
//! TLP's whole model on BOS is automatic profile selection by power source
|
||||
//! (see packages.x86_64: power-profiles-daemon is intentionally not
|
||||
//! installed, it conflicts with tlp), so this panel is read-only for TLP
|
||||
//! and only exposes controls for things that are actually user choices:
|
||||
//! brightness, and charge thresholds where the hardware supports them.
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{Box as GBox, Scale};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
fn upower_device(kind: &str) -> Option<String> {
|
||||
let out = Command::new("upower").arg("-e").output().ok()?;
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.find(|l| l.to_lowercase().contains(kind))
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn upower_field(device: &str, field: &str) -> Option<String> {
|
||||
let out = Command::new("upower").args(["-i", device]).output().ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
text.lines()
|
||||
.find(|l| l.trim_start().starts_with(field))
|
||||
.and_then(|l| l.split(':').nth(1))
|
||||
.map(|v| v.trim().to_string())
|
||||
}
|
||||
|
||||
fn battery_summary() -> Vec<(String, String)> {
|
||||
let Some(bat) = upower_device("bat") else {
|
||||
return vec![("Battery".to_string(), "No battery detected".to_string())];
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
if let Some(state) = upower_field(&bat, "state") {
|
||||
rows.push(("Status".to_string(), state));
|
||||
}
|
||||
if let Some(pct) = upower_field(&bat, "percentage") {
|
||||
rows.push(("Charge".to_string(), pct));
|
||||
}
|
||||
if let Some(t) = upower_field(&bat, "time to empty").or_else(|| upower_field(&bat, "time to full")) {
|
||||
rows.push(("Time remaining".to_string(), t));
|
||||
}
|
||||
let full: Option<f64> = upower_field(&bat, "energy-full").and_then(|v| {
|
||||
v.split_whitespace().next()?.parse().ok()
|
||||
});
|
||||
let design: Option<f64> = upower_field(&bat, "energy-full-design").and_then(|v| {
|
||||
v.split_whitespace().next()?.parse().ok()
|
||||
});
|
||||
if let (Some(full), Some(design)) = (full, design) {
|
||||
if design > 0.0 {
|
||||
rows.push(("Battery health".to_string(), format!("{:.0}% of design capacity", full / design * 100.0)));
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn power_source() -> String {
|
||||
match upower_device("ac").and_then(|ac| upower_field(&ac, "online")) {
|
||||
Some(v) if v == "yes" => "AC power".to_string(),
|
||||
Some(_) => "Battery".to_string(),
|
||||
None => "Unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tlp_profile() -> Option<String> {
|
||||
let out = Command::new("tlp-stat").arg("-s").output().ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
text.lines()
|
||||
.find(|l| l.trim_start().starts_with("TLP profile"))
|
||||
.and_then(|l| l.split('=').nth(1))
|
||||
.map(|v| v.trim().to_string())
|
||||
}
|
||||
|
||||
fn brightness_device() -> Option<String> {
|
||||
let out = Command::new("brightnessctl").output().ok()?;
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.find(|l| l.starts_with("Device"))
|
||||
.and_then(|l| l.split('\'').nth(1))
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn brightness_pct() -> Option<u32> {
|
||||
let out = Command::new("brightnessctl").output().ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
text.lines()
|
||||
.find(|l| l.contains("Current brightness"))
|
||||
.and_then(|l| l.split('(').nth(1))
|
||||
.and_then(|v| v.trim_end_matches("%)").parse().ok())
|
||||
}
|
||||
|
||||
/// Charge-threshold sysfs paths, only Some when the running kernel driver
|
||||
/// actually exposes them (ideapad_laptop/thinkpad_acpi on some models;
|
||||
/// nothing on this dev laptop's Yoga Slim 7 — this is genuinely
|
||||
/// hardware-dependent, not something every install will have).
|
||||
fn charge_threshold_paths() -> Option<(std::path::PathBuf, std::path::PathBuf)> {
|
||||
let base = std::path::Path::new("/sys/class/power_supply");
|
||||
let entries = std::fs::read_dir(base).ok()?;
|
||||
for entry in entries.flatten() {
|
||||
let start = entry.path().join("charge_control_start_threshold");
|
||||
let end = entry.path().join("charge_control_end_threshold");
|
||||
if start.exists() && end.exists() {
|
||||
return Some((start, end));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn read_threshold(path: &std::path::Path) -> i64 {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
.unwrap_or(100)
|
||||
}
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let (outer, c) = w::view_scaffold("Power");
|
||||
|
||||
c.append(&w::section("Battery"));
|
||||
for (label, value) in battery_summary() {
|
||||
c.append(&w::info_row(&label, &value));
|
||||
}
|
||||
c.append(&w::info_row("Power source", &power_source()));
|
||||
|
||||
c.append(&w::section("Brightness"));
|
||||
if let Some(pct) = brightness_pct() {
|
||||
let scale = Scale::with_range(gtk4::Orientation::Horizontal, 1.0, 100.0, 1.0);
|
||||
scale.set_value(pct as f64);
|
||||
scale.set_hexpand(true);
|
||||
scale.set_draw_value(true);
|
||||
if let Some(device) = brightness_device() {
|
||||
scale.connect_value_changed(move |s| {
|
||||
let pct = format!("{}%", s.value() as i64);
|
||||
let _ = Command::new("brightnessctl")
|
||||
.args(["--device", &device, "set", &pct])
|
||||
.spawn();
|
||||
});
|
||||
}
|
||||
c.append(&w::row("Screen brightness", &scale));
|
||||
} else {
|
||||
c.append(&w::hint("No controllable backlight found."));
|
||||
}
|
||||
|
||||
if let Some((start_path, end_path)) = charge_threshold_paths() {
|
||||
c.append(&w::section("Charge limits"));
|
||||
c.append(&w::hint(
|
||||
"Some laptops let you cap charging below 100% to slow battery \
|
||||
wear on a machine that's mostly plugged in. Not every model \
|
||||
supports this — shown here because yours reported it does.",
|
||||
));
|
||||
let start_adj = gtk4::Adjustment::new(read_threshold(&start_path) as f64, 0.0, 100.0, 1.0, 5.0, 0.0);
|
||||
let start_spin = gtk4::SpinButton::new(Some(&start_adj), 1.0, 0);
|
||||
{
|
||||
let path = start_path.clone();
|
||||
start_spin.connect_value_changed(move |s| {
|
||||
let val = (s.value() as i64).to_string();
|
||||
let _ = Command::new("pkexec")
|
||||
.args(["tee", &path.display().to_string()])
|
||||
.arg(&val)
|
||||
.output();
|
||||
});
|
||||
}
|
||||
c.append(&w::row("Start charging below (%)", &start_spin));
|
||||
|
||||
let end_adj = gtk4::Adjustment::new(read_threshold(&end_path) as f64, 1.0, 100.0, 1.0, 5.0, 0.0);
|
||||
let end_spin = gtk4::SpinButton::new(Some(&end_adj), 1.0, 0);
|
||||
{
|
||||
let path = end_path.clone();
|
||||
end_spin.connect_value_changed(move |s| {
|
||||
let val = (s.value() as i64).to_string();
|
||||
let _ = Command::new("pkexec")
|
||||
.args(["tee", &path.display().to_string()])
|
||||
.arg(&val)
|
||||
.output();
|
||||
});
|
||||
}
|
||||
c.append(&w::row("Stop charging at (%)", &end_spin));
|
||||
}
|
||||
|
||||
c.append(&w::section("TLP"));
|
||||
c.append(&w::hint(
|
||||
"TLP automatically applies a power-saving profile on battery and a \
|
||||
performance profile on AC — there's no manual profile switch here \
|
||||
by design (power-profiles-daemon isn't installed; it conflicts \
|
||||
with tlp).",
|
||||
));
|
||||
c.append(&w::info_row("Current profile", tlp_profile().as_deref().unwrap_or("unknown")));
|
||||
|
||||
outer
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@ use gtk4::{
|
|||
};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SnapshotRow {
|
||||
number: String,
|
||||
|
|
@ -12,20 +14,37 @@ struct SnapshotRow {
|
|||
}
|
||||
|
||||
fn list_snapshots() -> Vec<SnapshotRow> {
|
||||
// NOTE: the real flag is --columns, not --output-cols (which snapper
|
||||
// rejects outright with "Unknown option") — confirmed against snapper
|
||||
// 0.13's own --help. With the wrong flag this always failed and the
|
||||
// panel silently showed "No snapshots found" on every install.
|
||||
let Ok(output) = Command::new("snapper")
|
||||
.args(["list", "--output-cols", "number,date,description"])
|
||||
.args(["list", "--columns", "number,date,description"])
|
||||
.output()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !output.status.success() {
|
||||
eprintln!(
|
||||
"bos-settings: snapper list failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
text.lines()
|
||||
.skip(2) // header + separator
|
||||
.filter_map(|line| {
|
||||
let mut cols = line.splitn(3, '|');
|
||||
let number = cols.next()?.trim().to_string();
|
||||
// Snapshot 0 ("current") always exists, can't be rolled back to
|
||||
// or deleted, and isn't a real snapshot — filter it out.
|
||||
if number == "0" {
|
||||
return None;
|
||||
}
|
||||
Some(SnapshotRow {
|
||||
number: cols.next()?.trim().to_string(),
|
||||
number,
|
||||
date: cols.next()?.trim().to_string(),
|
||||
description: cols.next()?.trim().to_string(),
|
||||
})
|
||||
|
|
@ -33,7 +52,9 @@ fn list_snapshots() -> Vec<SnapshotRow> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn populate_list(list: &ListBox) {
|
||||
/// Returns whether the list ended up empty, so callers can disable the
|
||||
/// selection-dependent buttons instead of leaving them clickable no-ops.
|
||||
fn populate_list(list: &ListBox) -> bool {
|
||||
while let Some(child) = list.first_child() {
|
||||
list.remove(&child);
|
||||
}
|
||||
|
|
@ -41,13 +62,14 @@ fn populate_list(list: &ListBox) {
|
|||
if snapshots.is_empty() {
|
||||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
let lbl = Label::new(Some("No snapshots found (snapper may not be configured yet)"));
|
||||
lbl.set_margin_top(8);
|
||||
lbl.set_margin_bottom(8);
|
||||
lbl.set_margin_start(8);
|
||||
row.set_child(Some(&lbl));
|
||||
row.set_child(Some(&w::empty_state(
|
||||
"document-open-recent-symbolic",
|
||||
"No snapshots yet",
|
||||
"Snapshots are created automatically on every pacman transaction \
|
||||
(snapper may not be configured yet).",
|
||||
)));
|
||||
list.append(&row);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
for snap in &snapshots {
|
||||
let row = ListBoxRow::new();
|
||||
|
|
@ -77,45 +99,43 @@ fn populate_list(list: &ListBox) {
|
|||
row.set_child(Some(&hbox));
|
||||
list.append(&row);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let vbox = GBox::new(Orientation::Vertical, 0);
|
||||
vbox.add_css_class("view-content");
|
||||
|
||||
let title = Label::new(Some("Snapshots"));
|
||||
title.add_css_class("title");
|
||||
title.set_xalign(0.0);
|
||||
vbox.append(&title);
|
||||
|
||||
let subtitle = Label::new(Some(
|
||||
"System snapshots created by snap-pac on each pacman transaction.",
|
||||
let (outer, content) = w::view_scaffold("Snapshots");
|
||||
content.append(&w::hint(
|
||||
"System snapshots created by snap-pac on each pacman transaction. \
|
||||
Boot into one from the GRUB menu to recover; delete old ones here.",
|
||||
));
|
||||
subtitle.set_xalign(0.0);
|
||||
subtitle.set_margin_bottom(16);
|
||||
vbox.append(&subtitle);
|
||||
|
||||
let list = ListBox::new();
|
||||
list.set_selection_mode(gtk4::SelectionMode::Single);
|
||||
populate_list(&list);
|
||||
let empty = populate_list(&list);
|
||||
|
||||
let scroll = ScrolledWindow::new();
|
||||
scroll.set_vexpand(true);
|
||||
scroll.set_child(Some(&list));
|
||||
vbox.append(&scroll);
|
||||
content.append(&scroll);
|
||||
|
||||
let btn_row = GBox::new(Orientation::Horizontal, 8);
|
||||
btn_row.set_margin_top(12);
|
||||
|
||||
let refresh_btn = Button::with_label("Refresh");
|
||||
let rollback_btn = Button::with_label("Rollback to selected");
|
||||
let rollback_btn = Button::with_label("Boot into selected...");
|
||||
let delete_btn = Button::with_label("Delete selected");
|
||||
delete_btn.add_css_class("destructive-action");
|
||||
rollback_btn.set_sensitive(!empty);
|
||||
delete_btn.set_sensitive(!empty);
|
||||
|
||||
{
|
||||
let list = list.clone();
|
||||
let rollback_btn = rollback_btn.clone();
|
||||
let delete_btn = delete_btn.clone();
|
||||
refresh_btn.connect_clicked(move |_| {
|
||||
populate_list(&list);
|
||||
let empty = populate_list(&list);
|
||||
rollback_btn.set_sensitive(!empty);
|
||||
delete_btn.set_sensitive(!empty);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -130,23 +150,29 @@ pub fn build() -> GBox {
|
|||
.root()
|
||||
.and_then(|r| r.downcast::<gtk4::Window>().ok());
|
||||
|
||||
// BOS boots with root pinned to a named subvolume (grub emits
|
||||
// rootflags=subvol=@), so `snapper rollback`'s usual mechanism —
|
||||
// switching the btrfs *default* subvolume — has no effect here;
|
||||
// grub never consults it. The real, working way to get back to a
|
||||
// snapshot on this layout is grub-btrfs (already installed +
|
||||
// running via grub-btrfsd.service): it generates a GRUB submenu
|
||||
// entry per snapshot, bootable directly. So this button doesn't
|
||||
// touch the filesystem at all — it just points you at that menu.
|
||||
let dialog = AlertDialog::builder()
|
||||
.message(&format!("Roll back to snapshot #{number}?"))
|
||||
.detail("The current system state will be replaced on next boot. \
|
||||
A polkit prompt will ask for your password.")
|
||||
.buttons(["Cancel", "Roll back"])
|
||||
.message(&format!("Boot into snapshot #{number}?"))
|
||||
.detail("Snapshots on BOS are booted directly from the GRUB \
|
||||
menu (under \"BOS snapshots\"), not rolled back in \
|
||||
place. Reboot now and pick this snapshot there, or \
|
||||
later if you'd rather keep working — the menu entry \
|
||||
will still be there.")
|
||||
.buttons(["Later", "Reboot now"])
|
||||
.cancel_button(0)
|
||||
.default_button(0)
|
||||
.build();
|
||||
|
||||
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
|
||||
if result == Ok(1) {
|
||||
// pkexec so polkit handles the privilege escalation
|
||||
std::thread::spawn(move || {
|
||||
let _ = Command::new("pkexec")
|
||||
.args(["snapper", "rollback", &number])
|
||||
.status();
|
||||
});
|
||||
let _ = Command::new("systemctl").args(["reboot"]).spawn();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -154,16 +180,18 @@ 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());
|
||||
|
||||
let list = list.clone();
|
||||
let dialog = AlertDialog::builder()
|
||||
.message(&format!("Delete snapshot #{number}?"))
|
||||
.detail("This cannot be undone.")
|
||||
|
|
@ -172,19 +200,57 @@ pub fn build() -> GBox {
|
|||
.default_button(0)
|
||||
.build();
|
||||
|
||||
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) {
|
||||
let _ = Command::new("snapper").args(["delete", &number]).status();
|
||||
populate_list(&list);
|
||||
if result != Ok(1) { return }
|
||||
|
||||
// snapper's DBus path authorizes via ALLOW_USERS, not pkexec —
|
||||
// this only works because post-install.sh seeds that config
|
||||
// key, but if it's ever missing this fails silently unless we
|
||||
// check the exit status. GTK widgets aren't Send, so hand the
|
||||
// outcome back over a channel rather than touching them from
|
||||
// the thread (same pattern as the rollback flow used to).
|
||||
let (tx, rx) = async_channel::bounded::<bool>(1);
|
||||
std::thread::spawn(move || {
|
||||
let ok = Command::new("snapper")
|
||||
.args(["delete", &number])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
let _ = tx.send_blocking(ok);
|
||||
});
|
||||
|
||||
let list = list2.clone();
|
||||
let window = window2.clone();
|
||||
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);
|
||||
} else {
|
||||
let err = AlertDialog::builder()
|
||||
.message("Delete failed")
|
||||
.detail("snapper delete exited with an error — the \
|
||||
snapshot wasn't removed.")
|
||||
.buttons(["OK"])
|
||||
.build();
|
||||
err.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, |_| {});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
btn_row.append(&refresh_btn);
|
||||
btn_row.append(&rollback_btn);
|
||||
btn_row.append(&delete_btn);
|
||||
vbox.append(&btn_row);
|
||||
outer.append(&btn_row);
|
||||
|
||||
vbox
|
||||
outer
|
||||
}
|
||||
|
|
|
|||
167
bos-settings/src/ui/views/sound.rs
Normal file
167
bos-settings/src/ui/views/sound.rs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
//! Output/input volume and device selection over PipeWire's pulse
|
||||
//! compatibility layer (`pactl`) — the same surface `hyprland.lua`'s media
|
||||
//! keys already use via `wpctl`. `pactl` is used here instead of `wpctl`
|
||||
//! because it can enumerate devices with human-readable descriptions and
|
||||
//! switch the default in one command; `wpctl` cannot easily do either.
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{Box as GBox, Button, DropDown, Expression, Orientation, Scale, StringList, Switch};
|
||||
use serde::Deserialize;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
struct Device {
|
||||
name: String,
|
||||
description: String,
|
||||
mute: bool,
|
||||
volume: std::collections::HashMap<String, VolumeChannel>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
struct VolumeChannel {
|
||||
value_percent: String,
|
||||
}
|
||||
|
||||
impl Device {
|
||||
fn percent(&self) -> f64 {
|
||||
self.volume
|
||||
.values()
|
||||
.next()
|
||||
.and_then(|v| v.value_percent.trim_end_matches('%').trim().parse::<f64>().ok())
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn list_devices(kind: &str) -> Vec<Device> {
|
||||
let Ok(output) = Command::new("pactl").args(["-f", "json", "list", kind]).output() else {
|
||||
return Vec::new();
|
||||
};
|
||||
serde_json::from_slice(&output.stdout).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn default_device_name(kind: &str) -> Option<String> {
|
||||
let flag = if kind == "sinks" { "get-default-sink" } else { "get-default-source" };
|
||||
Command::new("pactl")
|
||||
.arg(flag)
|
||||
.output()
|
||||
.ok()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Build one section (Output or Input): device dropdown + volume slider + mute
|
||||
/// switch. `kind` is "sinks" or "sources"; `set_default_flag` is the pactl
|
||||
/// verb ("set-default-sink"/"set-default-source"); `vol_flag`/`mute_flag`
|
||||
/// likewise.
|
||||
fn build_section(
|
||||
title: &str,
|
||||
kind: &'static str,
|
||||
set_default_flag: &'static str,
|
||||
vol_flag: &'static str,
|
||||
mute_flag: &'static str,
|
||||
) -> GBox {
|
||||
let section = GBox::new(Orientation::Vertical, 4);
|
||||
section.append(&w::section(title));
|
||||
|
||||
let devices = list_devices(kind);
|
||||
if devices.is_empty() {
|
||||
section.append(&w::hint("No devices found."));
|
||||
return section;
|
||||
}
|
||||
|
||||
let current_name = default_device_name(kind);
|
||||
let selected_idx = current_name
|
||||
.as_ref()
|
||||
.and_then(|n| devices.iter().position(|d| &d.name == n))
|
||||
.unwrap_or(0);
|
||||
let current = devices[selected_idx].clone();
|
||||
|
||||
let labels: Vec<&str> = devices.iter().map(|d| d.description.as_str()).collect();
|
||||
let model = StringList::new(&labels);
|
||||
let dd = DropDown::new(Some(model), Expression::NONE);
|
||||
dd.set_selected(selected_idx as u32);
|
||||
section.append(&w::row("Device", &dd));
|
||||
|
||||
let scale = Scale::with_range(Orientation::Horizontal, 0.0, 150.0, 1.0);
|
||||
scale.set_value(current.percent());
|
||||
scale.set_size_request(180, -1);
|
||||
scale.set_draw_value(true);
|
||||
scale.set_value_pos(gtk4::PositionType::Right);
|
||||
section.append(&w::row("Volume", &scale));
|
||||
|
||||
let mute_sw = Switch::new();
|
||||
mute_sw.set_active(current.mute);
|
||||
section.append(&w::row("Mute", &mute_sw));
|
||||
|
||||
// Device switch: fire-and-forget pactl call, then resync the slider/mute
|
||||
// switch to whatever the newly-default device's actual state is.
|
||||
{
|
||||
let devices = devices.clone();
|
||||
let scale = scale.clone();
|
||||
let mute_sw = mute_sw.clone();
|
||||
dd.connect_selected_notify(move |dd| {
|
||||
let Some(d) = devices.get(dd.selected() as usize) else { return };
|
||||
let _ = Command::new("pactl").args([set_default_flag, &d.name]).spawn();
|
||||
scale.set_value(d.percent());
|
||||
mute_sw.set_active(d.mute);
|
||||
});
|
||||
}
|
||||
|
||||
// Volume: target whichever device is currently selected in the dropdown,
|
||||
// not necessarily the system default at the time this closure was built.
|
||||
{
|
||||
let devices = devices.clone();
|
||||
let dd = dd.clone();
|
||||
scale.connect_value_changed(move |s| {
|
||||
let Some(d) = devices.get(dd.selected() as usize) else { return };
|
||||
let pct = format!("{}%", s.value() as i64);
|
||||
let _ = Command::new("pactl").args([vol_flag, &d.name, &pct]).spawn();
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let devices = devices.clone();
|
||||
let dd = dd.clone();
|
||||
mute_sw.connect_active_notify(move |s| {
|
||||
let Some(d) = devices.get(dd.selected() as usize) else { return };
|
||||
let val = if s.is_active() { "1" } else { "0" };
|
||||
let _ = Command::new("pactl").args([mute_flag, &d.name, val]).spawn();
|
||||
});
|
||||
}
|
||||
|
||||
section
|
||||
}
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let (outer, content) = w::view_scaffold("Sound");
|
||||
|
||||
content.append(&build_section(
|
||||
"Output",
|
||||
"sinks",
|
||||
"set-default-sink",
|
||||
"set-sink-volume",
|
||||
"set-sink-mute",
|
||||
));
|
||||
content.append(&build_section(
|
||||
"Input",
|
||||
"sources",
|
||||
"set-default-source",
|
||||
"set-source-volume",
|
||||
"set-source-mute",
|
||||
));
|
||||
|
||||
content.append(&w::section("Advanced"));
|
||||
content.append(&w::hint(
|
||||
"Per-app volume, port selection, and profile switching aren't covered here.",
|
||||
));
|
||||
let mixer_btn = Button::with_label("Open advanced mixer (pavucontrol)");
|
||||
mixer_btn.set_halign(gtk4::Align::Start);
|
||||
mixer_btn.connect_clicked(|_| {
|
||||
let _ = Command::new("pavucontrol").spawn();
|
||||
});
|
||||
content.append(&mixer_btn);
|
||||
|
||||
outer
|
||||
}
|
||||
293
bos-settings/src/ui/views/users.rs
Normal file
293
bos-settings/src/ui/views/users.rs
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
//! User account management — add/remove users, change passwords. Everything
|
||||
//! here needs root (useradd/userdel/chpasswd), so every action goes through
|
||||
//! `pkexec` on a background thread (GTK widgets aren't Send), matching the
|
||||
//! async_channel + glib::spawn_future_local handoff used elsewhere for
|
||||
//! destructive/root actions (see snapshots.rs).
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{AlertDialog, Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow};
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Account {
|
||||
username: String,
|
||||
full_name: String,
|
||||
}
|
||||
|
||||
fn list_accounts() -> Vec<Account> {
|
||||
let Ok(text) = std::fs::read_to_string("/etc/passwd") else {
|
||||
return Vec::new();
|
||||
};
|
||||
text.lines()
|
||||
.filter_map(|line| {
|
||||
let f: Vec<&str> = line.split(':').collect();
|
||||
if f.len() < 7 {
|
||||
return None;
|
||||
}
|
||||
let uid: u32 = f[2].parse().ok()?;
|
||||
let shell = f[6];
|
||||
// Real human accounts: normal UID range, a real login shell (not
|
||||
// nologin/false — excludes system/service accounts like
|
||||
// greeter, avahi, etc).
|
||||
if !(1000..60000).contains(&uid) || shell.ends_with("nologin") || shell.ends_with("/false") {
|
||||
return None;
|
||||
}
|
||||
Some(Account {
|
||||
username: f[0].to_string(),
|
||||
full_name: f[4].split(',').next().unwrap_or("").to_string(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn current_user() -> String {
|
||||
std::env::var("USER").unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Run a root command that needs a line of input on stdin (chpasswd's own
|
||||
/// "user:password" format) on a background thread, reporting success back
|
||||
/// via a channel. `pkexec` inherits the spawning process's stdin only when
|
||||
/// explicitly piped, so this pipes it through rather than relying on that.
|
||||
fn run_with_stdin(args: &[&str], input: String, on_done: impl FnOnce(bool) + 'static) {
|
||||
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
|
||||
let (tx, rx) = async_channel::bounded::<bool>(1);
|
||||
std::thread::spawn(move || {
|
||||
let ok = (|| -> std::io::Result<bool> {
|
||||
let mut child = Command::new(&args[0])
|
||||
.args(&args[1..])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(input.as_bytes())?;
|
||||
}
|
||||
Ok(child.wait()?.success())
|
||||
})()
|
||||
.unwrap_or(false);
|
||||
let _ = tx.send_blocking(ok);
|
||||
});
|
||||
glib::spawn_future_local(async move {
|
||||
let ok = rx.recv().await.unwrap_or(false);
|
||||
on_done(ok);
|
||||
});
|
||||
}
|
||||
|
||||
fn populate(list: &ListBox) {
|
||||
while let Some(child) = list.first_child() {
|
||||
list.remove(&child);
|
||||
}
|
||||
let me = current_user();
|
||||
for acc in list_accounts() {
|
||||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
let card = GBox::new(Orientation::Vertical, 6);
|
||||
card.add_css_class("card");
|
||||
card.set_margin_top(3);
|
||||
card.set_margin_bottom(3);
|
||||
|
||||
let top = GBox::new(Orientation::Horizontal, 12);
|
||||
let label_text = if acc.full_name.is_empty() {
|
||||
acc.username.clone()
|
||||
} else {
|
||||
format!("{} ({})", acc.username, acc.full_name)
|
||||
};
|
||||
let name_lbl = Label::new(Some(&label_text));
|
||||
name_lbl.set_hexpand(true);
|
||||
name_lbl.set_xalign(0.0);
|
||||
|
||||
let change_pw_btn = Button::with_label("Change password");
|
||||
let remove_btn = Button::with_label("Remove");
|
||||
remove_btn.add_css_class("destructive-action");
|
||||
// Don't let the panel remove the account it's currently running as
|
||||
// — that's a self-lockout, not a normal account-management action.
|
||||
remove_btn.set_sensitive(acc.username != me);
|
||||
|
||||
top.append(&name_lbl);
|
||||
top.append(&change_pw_btn);
|
||||
top.append(&remove_btn);
|
||||
card.append(&top);
|
||||
|
||||
// Inline password row, hidden until "Change password" is clicked —
|
||||
// same pattern as Network's secured-connection password prompt.
|
||||
let pw_row = GBox::new(Orientation::Horizontal, 8);
|
||||
let pw_entry = Entry::new();
|
||||
pw_entry.set_visibility(false);
|
||||
pw_entry.set_hexpand(true);
|
||||
pw_entry.set_placeholder_text(Some("New password"));
|
||||
let pw_apply_btn = Button::with_label("Apply");
|
||||
pw_row.append(&pw_entry);
|
||||
pw_row.append(&pw_apply_btn);
|
||||
pw_row.set_visible(false);
|
||||
card.append(&pw_row);
|
||||
|
||||
let status = Label::new(None);
|
||||
status.add_css_class("dim-label");
|
||||
status.set_xalign(0.0);
|
||||
card.append(&status);
|
||||
|
||||
{
|
||||
let pw_row = pw_row.clone();
|
||||
change_pw_btn.connect_clicked(move |_| {
|
||||
pw_row.set_visible(!pw_row.is_visible());
|
||||
});
|
||||
}
|
||||
{
|
||||
let username = acc.username.clone();
|
||||
let pw_entry = pw_entry.clone();
|
||||
let pw_row = pw_row.clone();
|
||||
let status = status.clone();
|
||||
pw_apply_btn.connect_clicked(move |_| {
|
||||
let password = pw_entry.text().to_string();
|
||||
if password.is_empty() {
|
||||
return;
|
||||
}
|
||||
let input = format!("{username}:{password}\n");
|
||||
let status2 = status.clone();
|
||||
let pw_entry2 = pw_entry.clone();
|
||||
let pw_row2 = pw_row.clone();
|
||||
status.set_text("Applying…");
|
||||
run_with_stdin(&["pkexec", "chpasswd"], input, move |ok| {
|
||||
if ok {
|
||||
status2.set_text("Password changed");
|
||||
pw_entry2.set_text("");
|
||||
pw_row2.set_visible(false);
|
||||
} else {
|
||||
status2.set_text("Failed to change password");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
{
|
||||
let list = list.clone();
|
||||
let username = acc.username.clone();
|
||||
remove_btn.connect_clicked(move |btn| {
|
||||
let window = btn.root().and_then(|r| r.downcast::<gtk4::Window>().ok());
|
||||
let dialog = AlertDialog::builder()
|
||||
.message(format!("Remove user {username}?"))
|
||||
.detail("Deletes the account and its home directory. This cannot be undone.")
|
||||
.buttons(["Cancel", "Remove"])
|
||||
.cancel_button(0)
|
||||
.default_button(0)
|
||||
.build();
|
||||
let list2 = list.clone();
|
||||
let username2 = username.clone();
|
||||
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
|
||||
if result != Ok(1) {
|
||||
return;
|
||||
}
|
||||
let log_buf = gtk4::TextBuffer::new(None);
|
||||
let list3 = list2.clone();
|
||||
w::stream_command_then(
|
||||
&["pkexec", "userdel", "-r", &username2],
|
||||
log_buf,
|
||||
move || populate(&list3),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
row.set_child(Some(&card));
|
||||
list.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let (outer, content) = w::view_scaffold("Users");
|
||||
content.append(&w::hint(
|
||||
"Real login accounts on this machine (system/service accounts \
|
||||
aren't shown). Your own account can't be removed from here.",
|
||||
));
|
||||
|
||||
let list = ListBox::new();
|
||||
list.set_selection_mode(gtk4::SelectionMode::None);
|
||||
populate(&list);
|
||||
let scroll = ScrolledWindow::new();
|
||||
scroll.set_vexpand(true);
|
||||
scroll.set_min_content_height(260);
|
||||
scroll.set_child(Some(&list));
|
||||
content.append(&scroll);
|
||||
|
||||
content.append(&w::section("Add user"));
|
||||
let username_entry = Entry::new();
|
||||
username_entry.set_placeholder_text(Some("username"));
|
||||
content.append(&w::row("Username", &username_entry));
|
||||
|
||||
let fullname_entry = Entry::new();
|
||||
fullname_entry.set_placeholder_text(Some("Full name (optional)"));
|
||||
content.append(&w::row("Full name", &fullname_entry));
|
||||
|
||||
let password_entry = Entry::new();
|
||||
password_entry.set_visibility(false);
|
||||
password_entry.set_placeholder_text(Some("password"));
|
||||
content.append(&w::row("Password", &password_entry));
|
||||
|
||||
let add_status = Label::new(None);
|
||||
add_status.add_css_class("dim-label");
|
||||
add_status.set_xalign(0.0);
|
||||
|
||||
let add_btn = Button::with_label("Add user");
|
||||
add_btn.add_css_class("suggested-action");
|
||||
add_btn.set_halign(gtk4::Align::Start);
|
||||
add_btn.set_margin_top(8);
|
||||
{
|
||||
let list = list.clone();
|
||||
let username_entry = username_entry.clone();
|
||||
let fullname_entry = fullname_entry.clone();
|
||||
let password_entry = password_entry.clone();
|
||||
let add_status = add_status.clone();
|
||||
add_btn.connect_clicked(move |_| {
|
||||
let username = username_entry.text().to_string();
|
||||
let full_name = fullname_entry.text().to_string();
|
||||
let password = password_entry.text().to_string();
|
||||
if username.trim().is_empty() || password.is_empty() {
|
||||
add_status.set_text("Username and password are required.");
|
||||
return;
|
||||
}
|
||||
add_status.set_text("Adding…");
|
||||
|
||||
let mut useradd_args = vec!["pkexec", "useradd", "-m", "-s", "/bin/bash"];
|
||||
if !full_name.trim().is_empty() {
|
||||
useradd_args.push("-c");
|
||||
useradd_args.push(full_name.trim());
|
||||
}
|
||||
useradd_args.push(username.trim());
|
||||
|
||||
let list2 = list.clone();
|
||||
let username2 = username.trim().to_string();
|
||||
let password2 = password.clone();
|
||||
let add_status2 = add_status.clone();
|
||||
let username_entry2 = username_entry.clone();
|
||||
let fullname_entry2 = fullname_entry.clone();
|
||||
let password_entry2 = password_entry.clone();
|
||||
let log_buf = gtk4::TextBuffer::new(None);
|
||||
w::stream_command_then(&useradd_args, log_buf, move || {
|
||||
let input = format!("{username2}:{password2}\n");
|
||||
let list3 = list2.clone();
|
||||
let add_status3 = add_status2.clone();
|
||||
let username_entry3 = username_entry2.clone();
|
||||
let fullname_entry3 = fullname_entry2.clone();
|
||||
let password_entry3 = password_entry2.clone();
|
||||
run_with_stdin(&["pkexec", "chpasswd"], input, move |ok| {
|
||||
if ok {
|
||||
add_status3.set_text("User added.");
|
||||
username_entry3.set_text("");
|
||||
fullname_entry3.set_text("");
|
||||
password_entry3.set_text("");
|
||||
populate(&list3);
|
||||
} else {
|
||||
add_status3.set_text("User created, but setting the password failed.");
|
||||
populate(&list3);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
content.append(&add_btn);
|
||||
content.append(&add_status);
|
||||
|
||||
outer
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ use std::rc::Rc;
|
|||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{
|
||||
Adjustment, Box as GBox, Button, DropDown, Entry, Expression, Label, Orientation,
|
||||
Adjustment, AlertDialog, Box as GBox, Button, DropDown, Entry, Expression, Label, Orientation,
|
||||
SpinButton, StringList, Switch,
|
||||
};
|
||||
use toml_edit::DocumentMut;
|
||||
|
|
@ -31,8 +31,20 @@ fn field_label(text: &str) -> Label {
|
|||
lbl
|
||||
}
|
||||
|
||||
fn row(label: &str, control: &impl IsA<gtk4::Widget>) -> GBox {
|
||||
/// A label + control row, the same layout every `*_row` helper below uses.
|
||||
/// Styled as its own small card (background + padding + rounded corners) so
|
||||
/// a page of settings reads as a list of distinct rows, not a flat column of
|
||||
/// labels with the control floating far away at the window's edge — which is
|
||||
/// what plain edge-aligned rows look like once [`view_scaffold`] stops
|
||||
/// letting the content column stretch to the full window width.
|
||||
/// Exposed so panels that front live system state (not a `Doc`) — About,
|
||||
/// Sound, Date & Time, Network — can still lay out rows consistently with
|
||||
/// the TOML-editor panels.
|
||||
pub fn row(label: &str, control: &impl IsA<gtk4::Widget>) -> GBox {
|
||||
let row = GBox::new(Orientation::Horizontal, 16);
|
||||
row.add_css_class("card");
|
||||
row.set_margin_top(3);
|
||||
row.set_margin_bottom(3);
|
||||
row.append(&field_label(label));
|
||||
control.set_halign(gtk4::Align::End);
|
||||
control.set_valign(gtk4::Align::Center);
|
||||
|
|
@ -40,6 +52,15 @@ fn row(label: &str, control: &impl IsA<gtk4::Widget>) -> GBox {
|
|||
row
|
||||
}
|
||||
|
||||
/// A label + read-only value label, for panels that only display state
|
||||
/// (About's CPU/memory/disk readouts, etc).
|
||||
pub fn info_row(label: &str, value: &str) -> GBox {
|
||||
let value_lbl = Label::new(Some(value));
|
||||
value_lbl.add_css_class("dim-label");
|
||||
value_lbl.set_selectable(true);
|
||||
row(label, &value_lbl)
|
||||
}
|
||||
|
||||
/// A bold section heading with spacing above it.
|
||||
pub fn section(text: &str) -> Label {
|
||||
let lbl = Label::new(Some(text));
|
||||
|
|
@ -56,10 +77,53 @@ pub fn hint(text: &str) -> Label {
|
|||
lbl.add_css_class("dim-label");
|
||||
lbl.set_xalign(0.0);
|
||||
lbl.set_wrap(true);
|
||||
// set_wrap alone doesn't cap a label's *natural* width — a long enough
|
||||
// hint reports a natural width of its whole unwrapped string, which
|
||||
// `view_scaffold`'s content column (sized to its widest child, floored
|
||||
// not capped at CONTENT_MAX_WIDTH) then inherits, blowing the entire
|
||||
// panel full-width. This is what makes breadsearch/breadclip render
|
||||
// edge-to-edge while every other panel sits in a stable ~760px column.
|
||||
lbl.set_max_width_chars(64);
|
||||
lbl.set_margin_bottom(4);
|
||||
lbl
|
||||
}
|
||||
|
||||
/// Widest a settings column is allowed to grow. Without a cap, `content`
|
||||
/// inherits hexpand from its rows' hexpand-ing labels and the ScrolledWindow
|
||||
/// stretches it to the full window width — on a maximized/ultrawide window
|
||||
/// that leaves the control on every row stranded ~1500px from its label.
|
||||
const CONTENT_MAX_WIDTH: i32 = 760;
|
||||
|
||||
/// A centered placeholder for a panel's empty state (no snapshots yet, no
|
||||
/// scan results yet, etc) — a dim icon + title + hint, instead of a single
|
||||
/// small sentence lost in an otherwise-empty scroll area.
|
||||
pub fn empty_state(icon_name: &str, title: &str, hint_text: &str) -> GBox {
|
||||
let wrapper = GBox::new(Orientation::Vertical, 6);
|
||||
wrapper.set_valign(gtk4::Align::Center);
|
||||
wrapper.set_halign(gtk4::Align::Center);
|
||||
wrapper.set_vexpand(true);
|
||||
wrapper.set_margin_top(32);
|
||||
wrapper.set_margin_bottom(32);
|
||||
|
||||
let icon = gtk4::Image::from_icon_name(icon_name);
|
||||
icon.set_pixel_size(48);
|
||||
icon.add_css_class("dim-label");
|
||||
wrapper.append(&icon);
|
||||
|
||||
let title_lbl = Label::new(Some(title));
|
||||
title_lbl.add_css_class("heading");
|
||||
wrapper.append(&title_lbl);
|
||||
|
||||
let hint_lbl = Label::new(Some(hint_text));
|
||||
hint_lbl.add_css_class("dim-label");
|
||||
hint_lbl.set_justify(gtk4::Justification::Center);
|
||||
hint_lbl.set_wrap(true);
|
||||
hint_lbl.set_max_width_chars(48);
|
||||
wrapper.append(&hint_lbl);
|
||||
|
||||
wrapper
|
||||
}
|
||||
|
||||
/// Standard view scaffold: an outer vertical box with a title and a scrollable
|
||||
/// content area. Append setting rows to the returned `content`, then append a
|
||||
/// [`save_button`] to `outer`. Returns `(outer, content)`.
|
||||
|
|
@ -70,9 +134,22 @@ pub fn view_scaffold(title: &str) -> (GBox, GBox) {
|
|||
let title_lbl = Label::new(Some(title));
|
||||
title_lbl.add_css_class("title");
|
||||
title_lbl.set_xalign(0.0);
|
||||
// Same width+center treatment as `content` below, so the title's left
|
||||
// edge lines up with the column it's heading instead of sitting flush
|
||||
// against the panel edge while the column centers ~440px to its right.
|
||||
title_lbl.set_halign(gtk4::Align::Center);
|
||||
title_lbl.set_size_request(CONTENT_MAX_WIDTH, -1);
|
||||
outer.append(&title_lbl);
|
||||
|
||||
let content = GBox::new(Orientation::Vertical, 8);
|
||||
let content = GBox::new(Orientation::Vertical, 4);
|
||||
// Explicit hexpand(false) overrides the auto-computed value GTK would
|
||||
// otherwise derive from the hexpand-ing labels inside every row, and
|
||||
// Center distributes the dead space evenly either side on a wide window
|
||||
// instead of stranding it all on the right of a left-hugging column.
|
||||
content.set_hexpand(false);
|
||||
content.set_halign(gtk4::Align::Center);
|
||||
content.set_size_request(CONTENT_MAX_WIDTH, -1);
|
||||
|
||||
let scroll = gtk4::ScrolledWindow::new();
|
||||
scroll.set_vexpand(true);
|
||||
scroll.set_hscrollbar_policy(gtk4::PolicyType::Never);
|
||||
|
|
@ -205,6 +282,200 @@ pub fn csv_row(label: &str, doc: &Doc, path: Path, placeholder: &str) -> GBox {
|
|||
row(label, &entry)
|
||||
}
|
||||
|
||||
/// Run `args` as a subprocess, streaming stdout+stderr into `log_buf` line by
|
||||
/// line, then call `on_done` once the process exits. GTK widgets aren't
|
||||
/// `Send`, so the child runs on its own thread and results come back over an
|
||||
/// `async_channel` to a `glib::spawn_future_local` task that touches the
|
||||
/// widget. Shared by every panel that shells out to a CLI tool and wants live
|
||||
/// output (Packages, Network, Firewall, Firmware, ...).
|
||||
pub fn stream_command_then(
|
||||
args: &[&str],
|
||||
log_buf: gtk4::TextBuffer,
|
||||
on_done: impl FnOnce() + 'static,
|
||||
) {
|
||||
let (sender, receiver) = async_channel::bounded::<String>(256);
|
||||
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut child = match std::process::Command::new(&args[0])
|
||||
.args(&args[1..])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = sender.send_blocking(format!("Error: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Both are Some because we spawned with Stdio::piped() above.
|
||||
let stdout = child.stdout.take().expect("stdout piped");
|
||||
let stderr = child.stderr.take().expect("stderr piped");
|
||||
|
||||
let tx2 = sender.clone();
|
||||
let stderr_thread = std::thread::spawn(move || {
|
||||
for line in std::io::BufRead::lines(std::io::BufReader::new(stderr)).flatten() {
|
||||
let _ = tx2.send_blocking(line);
|
||||
}
|
||||
});
|
||||
|
||||
for line in std::io::BufRead::lines(std::io::BufReader::new(stdout)).flatten() {
|
||||
let _ = sender.send_blocking(line);
|
||||
}
|
||||
let _ = child.wait();
|
||||
let _ = stderr_thread.join();
|
||||
});
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
while let Ok(line) = receiver.recv().await {
|
||||
let mut end = log_buf.end_iter();
|
||||
log_buf.insert(&mut end, &format!("{line}\n"));
|
||||
}
|
||||
on_done();
|
||||
});
|
||||
}
|
||||
|
||||
/// [`stream_command_then`] with no completion callback.
|
||||
pub fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) {
|
||||
stream_command_then(args, log_buf, || {});
|
||||
}
|
||||
|
||||
fn systemctl_active(unit: &str) -> bool {
|
||||
std::process::Command::new("systemctl")
|
||||
.args(["--user", "is-active", "--quiet", unit])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn systemctl_enabled(unit: &str) -> bool {
|
||||
std::process::Command::new("systemctl")
|
||||
.args(["--user", "is-enabled", "--quiet", unit])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Live systemd `--user` unit status plus Start/Stop/Restart/Logs controls —
|
||||
/// every bread-ecosystem panel whose app is actually a daemon (not just a
|
||||
/// config file) gets this, instead of only ever being able to edit the TOML
|
||||
/// and hope the running process picks it up.
|
||||
///
|
||||
/// `critical` guards Stop behind a confirm dialog — for a unit like `breadd`
|
||||
/// (the whole desktop's event backbone), a bare click currently stops it as
|
||||
/// casually as an optional sync helper; everything else can stay one-click.
|
||||
///
|
||||
/// `has_config` adds a one-line note that Restart (not Save) is what applies
|
||||
/// config changes below — previously only breadsearch's panel said this,
|
||||
/// inconsistently, in its own hint text; bread/breadbox/breadcrumbs edit a
|
||||
/// running daemon's TOML and never mentioned the running process won't
|
||||
/// notice until restarted.
|
||||
pub fn service_control(unit: &'static str, critical: bool, has_config: bool) -> GBox {
|
||||
let wrapper = GBox::new(Orientation::Vertical, 4);
|
||||
wrapper.append(§ion("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);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@ use gtk4::{Application, ApplicationWindow, Orientation, Paned, Stack};
|
|||
use super::sidebar;
|
||||
use super::views;
|
||||
|
||||
// About is a settings app's most GNOME-Settings-like landing page —
|
||||
// identifies the machine, no config editor thrown at you first. Defined
|
||||
// once here and threaded through to both the `Stack` and the sidebar's
|
||||
// initial selection, so the two can't independently drift out of sync.
|
||||
const DEFAULT_PAGE: &str = "about";
|
||||
|
||||
pub fn build_ui(app: &Application) {
|
||||
let window = ApplicationWindow::builder()
|
||||
.application(app)
|
||||
|
|
@ -15,27 +21,38 @@ pub fn build_ui(app: &Application) {
|
|||
crate::theme::load(&WidgetExt::display(&window));
|
||||
|
||||
let hpaned = Paned::new(Orientation::Horizontal);
|
||||
hpaned.set_position(190);
|
||||
hpaned.set_position(210);
|
||||
hpaned.set_shrink_start_child(false);
|
||||
hpaned.set_resize_start_child(false);
|
||||
|
||||
let (sidebar_box, list) = sidebar::build();
|
||||
let (sidebar_box, list) = sidebar::build(DEFAULT_PAGE);
|
||||
|
||||
let stack = Stack::new();
|
||||
stack.set_hexpand(true);
|
||||
stack.set_vexpand(true);
|
||||
|
||||
stack.add_named(&views::about::build(), Some("about"));
|
||||
stack.add_named(&views::network::build(), Some("network"));
|
||||
stack.add_named(&views::sound::build(), Some("sound"));
|
||||
stack.add_named(&views::datetime::build(), Some("datetime"));
|
||||
stack.add_named(&views::power::build(), Some("power"));
|
||||
stack.add_named(&views::firewall::build(), Some("firewall"));
|
||||
stack.add_named(&views::users::build(), Some("users"));
|
||||
stack.add_named(&views::snapshots::build(), Some("snapshots"));
|
||||
stack.add_named(&views::packages::build(), Some("packages"));
|
||||
stack.add_named(&views::firmware::build(), Some("firmware"));
|
||||
stack.add_named(&views::aur::build(), Some("aur"));
|
||||
stack.add_named(&views::bread::build(), Some("bread"));
|
||||
stack.add_named(&views::breadbar::build(), Some("breadbar"));
|
||||
stack.add_named(&views::breadbox::build(), Some("breadbox"));
|
||||
stack.add_named(&views::breadclip::build(), Some("breadclip"));
|
||||
stack.add_named(&views::breadcrumbs::build(), Some("breadcrumbs"));
|
||||
stack.add_named(&views::breadpad::build(), Some("breadpad"));
|
||||
stack.add_named(&views::breadpaper::build(), Some("breadpaper"));
|
||||
stack.add_named(&views::breadsearch::build(), Some("breadsearch"));
|
||||
stack.add_named(&views::hyprland::build(), Some("hyprland"));
|
||||
|
||||
// Default to snapshots view
|
||||
stack.set_visible_child_name("snapshots");
|
||||
stack.set_visible_child_name(DEFAULT_PAGE);
|
||||
|
||||
{
|
||||
let stack = stack.clone();
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ grep airootfs_image_tool_options "$STAGE/profiledef.sh"
|
|||
# created from skel (the live user and the installed user) then gets the same
|
||||
# versions `bakery list` reports here, fully offline. Copied at build time so the
|
||||
# binaries never bloat the git repo and always track the current bakery state.
|
||||
BREAD_BINS=(bakery bread breadd breadman breadbar breadbox breadbox-sync breadcrumbs breadpad breadpaper bread-theme)
|
||||
BREAD_BINS=(bakery bread breadd breadman breadbar breadbox breadbox-sync breadcrumbs breadpad breadpaper bread-theme breadmon breadsearch breadmill breadclip breadclipd breadshot)
|
||||
LAPTOP_HOME="${LAPTOP_HOME:-$(getent passwd "${SUDO_USER:-$USER}" | cut -d: -f6)}"
|
||||
BAKERY_BIN="$LAPTOP_HOME/.local/bin"
|
||||
BAKERY_STATE="$LAPTOP_HOME/.local/state/bakery"
|
||||
|
|
@ -69,6 +69,50 @@ install -m 0644 "$BAKERY_STATE/installed.json" "$SKEL/.local/state/bakery/instal
|
|||
install -m 0644 "$BAKERY_CACHE/index.json" "$SKEL/.cache/bakery/index.json"
|
||||
echo "baked: $(ls "$SKEL/.local/bin")"
|
||||
|
||||
# --- Bake systemd user services for bakery-managed bread packages -----------
|
||||
# Historically only breadd.service was hand-committed to skel; every other
|
||||
# bakery package's service (breadbox-sync, breadmill, breadclipd, ...) was
|
||||
# silently left out, so those daemons never start on a fresh install/live
|
||||
# boot until the user re-runs `bakery install` (which needs network).
|
||||
# Generalize from the same source of truth as the binary bake above: read
|
||||
# the services this laptop's bakery actually installed, copy each unit file
|
||||
# into skel with ExecStart rewritten from this laptop's literal home path to
|
||||
# the portable `%h` specifier, and recreate whichever *.target.wants enable
|
||||
# symlink bakery created locally. Units already committed by hand (breadd.service
|
||||
# carries a RuntimeDirectoryPreserve=yes fix not yet upstreamed — see bread-release-build
|
||||
# notes) are left alone rather than overwritten.
|
||||
echo "=== baking bakery service units into skel ==="
|
||||
SYSTEMD_USER_DIR="$LAPTOP_HOME/.config/systemd/user"
|
||||
SKEL_SYSTEMD="$SKEL/.config/systemd/user"
|
||||
mapfile -t SERVICE_UNITS < <(python3 -c "
|
||||
import json
|
||||
with open('$BAKERY_STATE/installed.json') as f:
|
||||
d = json.load(f)
|
||||
for pkg in d.get('packages', d).values():
|
||||
for s in pkg.get('services', []):
|
||||
print(s)
|
||||
")
|
||||
for unit in "${SERVICE_UNITS[@]}"; do
|
||||
if [[ -f "$SKEL_SYSTEMD/$unit" ]]; then
|
||||
echo " $unit already committed in skel, leaving as-is"
|
||||
continue
|
||||
fi
|
||||
src="$SYSTEMD_USER_DIR/$unit"
|
||||
if [[ ! -f "$src" ]]; then
|
||||
echo " warning: $unit not found at $src, skipping"
|
||||
continue
|
||||
fi
|
||||
install -d -m 0755 "$SKEL_SYSTEMD"
|
||||
sed "s#ExecStart=$LAPTOP_HOME/.local/bin/#ExecStart=%h/.local/bin/#" "$src" > "$SKEL_SYSTEMD/$unit"
|
||||
for wants_dir in "$SYSTEMD_USER_DIR"/*.target.wants; do
|
||||
[[ -L "$wants_dir/$unit" ]] || continue
|
||||
target_name="$(basename "$wants_dir")"
|
||||
install -d -m 0755 "$SKEL_SYSTEMD/$target_name"
|
||||
ln -sf "../$unit" "$SKEL_SYSTEMD/$target_name/$unit"
|
||||
done
|
||||
echo " baked $unit"
|
||||
done
|
||||
|
||||
# mkarchiso resets every airootfs file to 0644, so executables must be declared
|
||||
# in profiledef.sh's file_permissions array or they ship non-executable and the
|
||||
# exec-once launches fail with "permission denied". Inject a 0755 entry for each
|
||||
|
|
|
|||
|
|
@ -1 +1,5 @@
|
|||
bread.activate_profile("default")
|
||||
bread.once("bread.system.startup", function()
|
||||
bread.profile.activate("default")
|
||||
end)
|
||||
|
||||
return bread
|
||||
|
|
|
|||
|
|
@ -5,25 +5,29 @@ efiSystemPartitionName: "EFI"
|
|||
|
||||
defaultFileSystemType: "btrfs"
|
||||
|
||||
btrfsSubvolumes:
|
||||
- mountPoint: /
|
||||
subvolume: "@"
|
||||
mountOptions: "noatime,compress=zstd,space_cache=v2"
|
||||
- mountPoint: /home
|
||||
subvolume: "@home"
|
||||
mountOptions: "noatime,compress=zstd,space_cache=v2"
|
||||
- mountPoint: /.snapshots
|
||||
subvolume: "@snapshots"
|
||||
mountOptions: "noatime,compress=zstd,space_cache=v2"
|
||||
- mountPoint: /var/log
|
||||
subvolume: "@log"
|
||||
mountOptions: "noatime,compress=zstd,space_cache=v2"
|
||||
- mountPoint: /var/cache
|
||||
subvolume: "@cache"
|
||||
mountOptions: "noatime,compress=zstd,space_cache=v2"
|
||||
# NOTE: there is no `btrfsSubvolumes:` key in this Calamares version's
|
||||
# partition module schema (confirmed against /usr/share/calamares/modules/
|
||||
# partition.conf and on real hardware — zero mentions of "subvolume"
|
||||
# anywhere in the stock reference config). A previous version of this file
|
||||
# had one; Calamares silently ignored it. Calamares' partition module only
|
||||
# natively creates @ (root) and @home (home) when btrfs + separate /home is
|
||||
# selected — nothing else. @snapshots/@log/@cache are created by hand in
|
||||
# post-install.sh instead, after unpackfs has populated the filesystem.
|
||||
|
||||
userSwapChoices:
|
||||
- none
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
# Runs before unpackfs so it can hand it a source path that survives
|
||||
# copytoram unmounting /run/archiso/bootmnt. See bos-resolve-airootfs.
|
||||
dontChroot: true
|
||||
timeout: 30
|
||||
|
||||
script:
|
||||
- "/usr/bin/bash /usr/local/bin/bos-resolve-airootfs"
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
---
|
||||
# Unpack the live squashfs onto the target partition.
|
||||
# "arch" matches profiledef.sh install_dir; adjust if that changes.
|
||||
# The source is a symlink written by shellprocess@resolve-source (see
|
||||
# bos-resolve-airootfs) rather than a path under /run/archiso/bootmnt directly
|
||||
# — that mount gets torn down by archiso's own copytoram handling, which
|
||||
# self-enables on most real hardware, so a hardcoded bootmnt path would point
|
||||
# at nothing on exactly the machines this install is meant to run on.
|
||||
unpack:
|
||||
- source: "/run/archiso/bootmnt/arch/x86_64/airootfs.sfs"
|
||||
- source: "/run/archiso/resolved-airootfs.sfs"
|
||||
sourcefs: "squashfs"
|
||||
destination: ""
|
||||
|
|
|
|||
|
|
@ -38,4 +38,11 @@ passwordRequirements:
|
|||
- minlen=6
|
||||
|
||||
allowWeakPasswords: false
|
||||
userShell: /bin/zsh
|
||||
|
||||
# `userShell` (top-level) is not a real key in this Calamares version's users
|
||||
# module schema (3.4.2) — it's silently ignored, and an installed user gets
|
||||
# whatever the module's own default is (bash), confirmed on real hardware.
|
||||
# The actual key is nested: user.shell. See /usr/share/calamares/modules/users.conf
|
||||
# for the documented schema.
|
||||
user:
|
||||
shell: /usr/bin/zsh
|
||||
|
|
|
|||
|
|
@ -10,6 +10,19 @@ set -uo pipefail
|
|||
|
||||
MAIN_USER="$(getent passwd 1000 | cut -d: -f1 || true)"
|
||||
|
||||
# Whether Calamares encrypted the root partition (LUKS) — checked once here,
|
||||
# used below to conditionally wire mkinitcpio's encrypt hook and GRUB's
|
||||
# cryptodisk support. `lsblk TYPE` reports "crypt" for a cryptsetup-opened
|
||||
# mapper device regardless of what Calamares named it, so this works whether
|
||||
# the user picked automated "Erase disk" encryption or hand-encrypted a
|
||||
# partition in manual mode.
|
||||
ROOT_SRC="$(findmnt -no SOURCE / | sed 's/\[.*\]//')"
|
||||
if [[ "$(lsblk -no TYPE "$ROOT_SRC" 2>/dev/null)" == "crypt" ]]; then
|
||||
ROOT_ENCRYPTED=1
|
||||
else
|
||||
ROOT_ENCRYPTED=0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strip live-only bits that unpackfs copied verbatim from the live medium.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -52,11 +65,44 @@ if [[ -f /etc/mkinitcpio.conf ]]; then
|
|||
sed -i 's/^\(HOOKS=.*\bautodetect\b\)/\1 microcode/' /etc/mkinitcpio.conf \
|
||||
|| echo "WARN: adding microcode hook failed"
|
||||
fi
|
||||
|
||||
# Current mkinitcpio's own shipped default template (verified against the
|
||||
# actual package, not assumed) uses the systemd-based hook set
|
||||
# (`HOOKS=(base systemd autodetect ... sd-vconsole block filesystems
|
||||
# fsck)`), NOT the classic udev-based one — there is no literal "udev"
|
||||
# token to match against on a stock install. The two hook sets are
|
||||
# mutually exclusive alternatives (systemd substitutes for udev as the
|
||||
# base hook providing the init program), and each has its own
|
||||
# counterpart for anything that hooks into device/root setup:
|
||||
# plymouth is the same either way, but LUKS unlocking needs `encrypt`
|
||||
# under udev and `sd-encrypt` under systemd. Detect which is in play
|
||||
# once and use the matching hook, instead of assuming udev (which
|
||||
# silently no-ops the sed on every current install — this was already
|
||||
# true for the plymouth insertion below before this fix, just never
|
||||
# surfaced because it fails quietly).
|
||||
if grep -qE '^HOOKS=.*\bsystemd\b' /etc/mkinitcpio.conf; then
|
||||
BASE_HOOK="systemd"
|
||||
ENCRYPT_HOOK="sd-encrypt"
|
||||
else
|
||||
BASE_HOOK="udev"
|
||||
ENCRYPT_HOOK="encrypt"
|
||||
fi
|
||||
|
||||
if command -v plymouth-set-default-theme &>/dev/null \
|
||||
&& ! grep -qE '^HOOKS=.*\bplymouth\b' /etc/mkinitcpio.conf; then
|
||||
sed -i 's/^\(HOOKS=.*\budev\b\)/\1 plymouth/' /etc/mkinitcpio.conf \
|
||||
sed -i "s/^\(HOOKS=.*\b${BASE_HOOK}\b\)/\1 plymouth/" /etc/mkinitcpio.conf \
|
||||
|| echo "WARN: adding plymouth hook failed"
|
||||
fi
|
||||
# encrypt/sd-encrypt — only when root is actually LUKS-encrypted
|
||||
# (ROOT_ENCRYPTED, detected above). Must sit after `block` (provides the
|
||||
# device nodes it opens) and before `filesystems` (mounts the now-
|
||||
# unlocked root) — both already present in stock mkinitcpio.conf's
|
||||
# default HOOKS regardless of which base hook is in use.
|
||||
if [[ "$ROOT_ENCRYPTED" == "1" ]] \
|
||||
&& ! grep -qE '^HOOKS=.*\bencrypt\b' /etc/mkinitcpio.conf; then
|
||||
sed -i "s/^\(HOOKS=.*\bblock\b\)/\1 ${ENCRYPT_HOOK}/" /etc/mkinitcpio.conf \
|
||||
|| echo "WARN: adding ${ENCRYPT_HOOK} hook failed"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -74,36 +120,182 @@ 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"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Install GRUB (UEFI). /boot now has the kernel + initramfs, and the mount
|
||||
# module has bind-mounted /proc /sys /dev /run + efivars into this chroot, so
|
||||
# Install GRUB. /boot now has the kernel + initramfs, and the mount module has
|
||||
# bind-mounted /proc /sys /dev /run (+ efivars on UEFI) into this chroot, so
|
||||
# both grub-install passes and grub-mkconfig succeed.
|
||||
# 1. NVRAM entry (EFI/BOS/grubx64.efi + a firmware boot entry)
|
||||
# 2. --removable copy to EFI/BOOT/BOOTX64.EFI, so firmware that ignores/loses
|
||||
# the NVRAM entry (the "no boot device / PXE fallback" failure) still finds
|
||||
# a bootloader.
|
||||
#
|
||||
# BOS ships a syslinux BIOS boot mode on the ISO (profiledef.sh bootmodes
|
||||
# includes bios.syslinux), but this only ever ran the UEFI grub-install path —
|
||||
# a BIOS install would complete successfully and then have no bootloader at
|
||||
# all. Branch on /sys/firmware/efi (present only when booted UEFI) and install
|
||||
# the matching GRUB target; on BIOS, grub-install needs the whole disk, not a
|
||||
# partition, so it's derived from the mounted root via lsblk.
|
||||
# UEFI: 1. NVRAM entry (EFI/BOS/grubx64.efi + a firmware boot entry)
|
||||
# 2. --removable copy to EFI/BOOT/BOOTX64.EFI, so firmware that
|
||||
# ignores/loses the NVRAM entry still finds a bootloader.
|
||||
# BIOS: MBR install onto the disk hosting /.
|
||||
# ---------------------------------------------------------------------------
|
||||
if command -v grub-install &>/dev/null; then
|
||||
CRYPT_MODULES=()
|
||||
[[ "$ROOT_ENCRYPTED" == "1" ]] && CRYPT_MODULES=(--modules="cryptodisk luks luks2")
|
||||
if [[ -d /sys/firmware/efi ]]; then
|
||||
grub-install --target=x86_64-efi --efi-directory=/boot/efi \
|
||||
--bootloader-id=BOS --recheck \
|
||||
--bootloader-id=BOS --recheck "${CRYPT_MODULES[@]}" \
|
||||
|| echo "WARN: grub-install (nvram) failed"
|
||||
grub-install --target=x86_64-efi --efi-directory=/boot/efi \
|
||||
--removable --recheck \
|
||||
--removable --recheck "${CRYPT_MODULES[@]}" \
|
||||
|| echo "WARN: grub-install (removable) failed"
|
||||
else
|
||||
ROOT_DEV="$(findmnt -no SOURCE / | sed 's/\[.*\]//')"
|
||||
ROOT_DISK="$(lsblk -no pkname "$ROOT_DEV" 2>/dev/null)"
|
||||
if [[ -n "$ROOT_DISK" ]]; then
|
||||
grub-install --target=i386-pc --recheck "${CRYPT_MODULES[@]}" "/dev/$ROOT_DISK" \
|
||||
|| echo "WARN: grub-install (BIOS) failed"
|
||||
else
|
||||
echo "WARN: could not determine the disk hosting / (root device: ${ROOT_DEV:-unknown}) — BIOS grub-install skipped"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
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
|
||||
# themselves, and log/cache churn doesn't bloat @'s snapshot history).
|
||||
#
|
||||
# iso/partition.conf's `btrfsSubvolumes:` key does NOT do this — verified on
|
||||
# real hardware that it's not a recognized key in this Calamares version's
|
||||
# partition module schema at all (same class of bug as the `userShell` fix
|
||||
# below: Calamares silently ignores unknown top-level keys). Calamares' own
|
||||
# btrfs support only natively creates @ and @home; everything else has to be
|
||||
# done by hand, here, after unpackfs has populated / but before anything
|
||||
# reads/writes /var/log or /var/cache going forward. Existing content in
|
||||
# those two dirs (real files already unpacked from the squashfs) is migrated
|
||||
# into the new subvolumes before mounting over them, so nothing is lost —
|
||||
# just shadowed by the mount, same as any other mountpoint.
|
||||
# ---------------------------------------------------------------------------
|
||||
if command -v btrfs &>/dev/null; then
|
||||
ROOT_DEV="$(findmnt -no SOURCE / | sed 's/\[.*\]//')"
|
||||
ROOT_UUID="$(blkid -s UUID -o value "$ROOT_DEV" 2>/dev/null)"
|
||||
BTRFS_TOP=/.btrfs-top-tmp
|
||||
|
||||
if [[ -z "$ROOT_UUID" ]]; then
|
||||
echo "WARN: could not determine root filesystem UUID — skipping @snapshots/@log/@cache creation"
|
||||
else
|
||||
mkdir -p "$BTRFS_TOP"
|
||||
if mount -o subvolid=5 "$ROOT_DEV" "$BTRFS_TOP"; then
|
||||
for sv in @snapshots @log @cache; do
|
||||
if ! btrfs subvolume show "$BTRFS_TOP/$sv" &>/dev/null; then
|
||||
btrfs subvolume create "$BTRFS_TOP/$sv" || echo "WARN: creating $sv failed"
|
||||
fi
|
||||
done
|
||||
rsync -aAX /var/log/ "$BTRFS_TOP/@log/" || echo "WARN: migrating /var/log into @log failed"
|
||||
rsync -aAX /var/cache/ "$BTRFS_TOP/@cache/" || echo "WARN: migrating /var/cache into @cache failed"
|
||||
umount "$BTRFS_TOP"
|
||||
rmdir "$BTRFS_TOP"
|
||||
|
||||
OPTS="noatime,compress=zstd,space_cache=v2"
|
||||
grep -q "@snapshots" /etc/fstab || echo "UUID=$ROOT_UUID /.snapshots btrfs subvol=/@snapshots,$OPTS 0 0" >> /etc/fstab
|
||||
grep -q "@log" /etc/fstab || echo "UUID=$ROOT_UUID /var/log btrfs subvol=/@log,$OPTS 0 0" >> /etc/fstab
|
||||
grep -q "@cache" /etc/fstab || echo "UUID=$ROOT_UUID /var/cache btrfs subvol=/@cache,$OPTS 0 0" >> /etc/fstab
|
||||
|
||||
mkdir -p /.snapshots
|
||||
mount /.snapshots || echo "WARN: mounting /.snapshots failed"
|
||||
mount /var/log || echo "WARN: mounting /var/log failed"
|
||||
mount /var/cache || echo "WARN: mounting /var/cache failed"
|
||||
else
|
||||
echo "WARN: could not mount btrfs top-level — skipping @snapshots/@log/@cache creation"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Snapper root config (root is btrfs).
|
||||
#
|
||||
# @snapshots is now mounted at /.snapshots (created above) — a dedicated
|
||||
# top-level subvolume, a peer of @ rather than nested under it, so snapshots
|
||||
# aren't themselves recursively snapshotted. `snapper create-config` insists
|
||||
# on creating that subvolume itself and refuses whenever /.snapshots already
|
||||
# exists, mounted or not — silently, so without this dance every downstream
|
||||
# sed below is a no-op and BOS ships with its advertised auto-snapshot/
|
||||
# rollback feature entirely non-functional.
|
||||
# Unmount the real subvolume, let snapper create + own its nested one, then
|
||||
# discard that and remount the real @snapshots in its place (fstab entry was
|
||||
# added above, right after the subvolume was created).
|
||||
#
|
||||
# Confirmed on real hardware: a plain `umount /.snapshots` here can
|
||||
# transiently fail inside the Calamares chroot (the same mount unmounts
|
||||
# cleanly moments later once booted normally — a chroot-specific busy-mount
|
||||
# race, not a logic error), which cascades into snapper create-config
|
||||
# refusing because .snapshots "already exists". Retry a few times, then
|
||||
# fall back to a lazy unmount (detaches the mountpoint immediately even if
|
||||
# something still transiently references it) rather than give up.
|
||||
# ---------------------------------------------------------------------------
|
||||
if command -v snapper &>/dev/null; then
|
||||
unmounted=0
|
||||
for _ in 1 2 3 4 5; do
|
||||
if umount /.snapshots 2>/dev/null; then
|
||||
unmounted=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [[ "$unmounted" != "1" ]]; then
|
||||
echo "WARN: umount /.snapshots failed after retries, forcing lazy unmount"
|
||||
umount -l /.snapshots || echo "WARN: lazy umount /.snapshots also failed"
|
||||
fi
|
||||
rmdir /.snapshots || echo "WARN: rmdir /.snapshots failed"
|
||||
snapper -c root create-config / || echo "WARN: snapper create-config failed"
|
||||
if [[ -d /.snapshots ]]; then
|
||||
btrfs subvolume delete /.snapshots || echo "WARN: deleting snapper's own .snapshots subvolume failed"
|
||||
fi
|
||||
mkdir -p /.snapshots
|
||||
mount /.snapshots || echo "WARN: remounting the real @snapshots subvolume failed"
|
||||
if [[ -f /etc/snapper/configs/root ]]; then
|
||||
sed -i 's/TIMELINE_CREATE="yes"/TIMELINE_CREATE="no"/' /etc/snapper/configs/root
|
||||
sed -i 's/NUMBER_CLEANUP="no"/NUMBER_CLEANUP="yes"/' /etc/snapper/configs/root
|
||||
|
|
|
|||
|
|
@ -3,10 +3,16 @@ modules-search: [/etc/calamares/modules, /usr/lib/calamares/modules]
|
|||
|
||||
# Second shellprocess instance: copies the live kernel into the target /boot
|
||||
# (archiso keeps it out of the squashfs) before the bootloader step runs.
|
||||
# Third: resolves the live squashfs's real location before unpackfs runs —
|
||||
# copytoram unmounts /run/archiso/bootmnt, so a hardcoded path there can point
|
||||
# at nothing depending on how the medium was booted.
|
||||
instances:
|
||||
- id: kernel
|
||||
module: shellprocess
|
||||
config: shellprocess-kernel.conf
|
||||
- id: resolve-source
|
||||
module: shellprocess
|
||||
config: shellprocess-resolve-source.conf
|
||||
|
||||
sequence:
|
||||
- show:
|
||||
|
|
@ -19,6 +25,7 @@ sequence:
|
|||
- exec:
|
||||
- partition
|
||||
- mount
|
||||
- shellprocess@resolve-source
|
||||
- unpackfs
|
||||
- machineid
|
||||
- fstab
|
||||
|
|
@ -28,7 +35,12 @@ sequence:
|
|||
- users
|
||||
- networkcfg
|
||||
- hwclock
|
||||
- packages
|
||||
# packages module removed: it set update_db:true with no
|
||||
# skip_if_no_internet/ignore_update_db_error, so an offline install (the
|
||||
# exact case bos-welcome's nmtui step exists for) aborted here with a
|
||||
# fatal pacman -Sy failure. Its only try_install packages (pipewire-pulse,
|
||||
# pipewire-alsa) are already in packages.x86_64 and installed by
|
||||
# unpackfs, so the step did nothing useful even when it succeeded.
|
||||
# archiso strips the kernel from the squashfs; stage it, drop the archiso
|
||||
# initramfs config, and write a stock mkinitcpio preset before initcpio runs.
|
||||
- shellprocess@kernel
|
||||
|
|
|
|||
26
iso/airootfs/etc/greetd/breadgreet.example.toml
Normal file
26
iso/airootfs/etc/greetd/breadgreet.example.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# breadgreet commonly runs as the dedicated `greeter` system user (per
|
||||
# greetd's own convention), so it checks /etc/greetd/breadgreet.toml first;
|
||||
# copy this there for a real install. For local dev/testing under a normal
|
||||
# user session it falls back to ~/.config/breadgreet/breadgreet.toml.
|
||||
#
|
||||
# Every field is optional and defaults to the value shown here.
|
||||
|
||||
[background]
|
||||
mode = "color"
|
||||
path = ""
|
||||
blur = false
|
||||
|
||||
[clock]
|
||||
format = "%H:%M"
|
||||
|
||||
[font]
|
||||
family = "Varela Round"
|
||||
|
||||
[sessions]
|
||||
# Directories scanned for .desktop session entries, in order.
|
||||
wayland_dirs = ["/usr/share/wayland-sessions"]
|
||||
xsessions_dirs = ["/usr/share/xsessions"]
|
||||
# .desktop file stem (without extension) to auto-select. Falls back to the
|
||||
# first entry found if this isn't present. v1 has no session picker UI —
|
||||
# BOS only ships one session (Hyprland) today.
|
||||
default = "hyprland"
|
||||
15
iso/airootfs/etc/greetd/breadgreet.toml
Normal file
15
iso/airootfs/etc/greetd/breadgreet.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# BOS's real breadgreet config — see breadgreet.example.toml in this same
|
||||
# directory for the full set of options with explanations.
|
||||
#
|
||||
# The one setting that actually matters here: `sessions.default` MUST be
|
||||
# "bos", not breadgreet's compiled-in default of "hyprland". The `hyprland`
|
||||
# pacman package installs its own /usr/share/wayland-sessions/hyprland.desktop
|
||||
# alongside BOS's own bos.desktop, and breadgreet's session picker matches by
|
||||
# .desktop file stem — with no override it picks "hyprland.desktop" over
|
||||
# "bos.desktop", which skips bos-session's PATH fixup (adds ~/.local/bin for
|
||||
# the bakery bread apps; greetd starts no login shell, so /etc/profile.d is
|
||||
# never sourced any other way). Confirmed via breadgreet's own test suite
|
||||
# (sessions.rs: discover_prefers_configured_default_over_first_entry).
|
||||
|
||||
[sessions]
|
||||
default = "bos"
|
||||
|
|
@ -2,11 +2,15 @@
|
|||
# squashfs and enabled by post-install.sh; the live ISO instead autologins
|
||||
# liveuser on tty1 (see getty@tty1 drop-in) so the installer comes straight up.
|
||||
#
|
||||
# tuigreet shows a minimal greeter, then launches the BOS Hyprland session via
|
||||
# bos-session (which fixes up PATH for the bakery bread apps).
|
||||
# breadgreet (bread-ecosystem's own greeter) is hosted under cage — a single-
|
||||
# client kiosk Wayland compositor, since greetd itself has no display of its
|
||||
# own — and speaks greetd's IPC directly. It resolves the session to launch
|
||||
# from /usr/share/wayland-sessions/bos.desktop, which points at bos-session
|
||||
# (fixes up PATH for the bakery bread apps; greetd starts no login shell, so
|
||||
# /etc/profile.d is never sourced otherwise).
|
||||
[terminal]
|
||||
vt = 1
|
||||
|
||||
[default_session]
|
||||
command = "tuigreet --remember --time --cmd /usr/local/bin/bos-session"
|
||||
command = "cage -s -- breadgreet"
|
||||
user = "greeter"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
HOOKS=(base udev microcode modconf kms memdisk archiso archiso_loop_mnt archiso_pxe_common archiso_pxe_nbd archiso_pxe_http archiso_pxe_nfs block filesystems keyboard)
|
||||
HOOKS=(base udev plymouth microcode modconf kms memdisk archiso archiso_loop_mnt archiso_pxe_common archiso_pxe_nbd archiso_pxe_http archiso_pxe_nfs block filesystems keyboard)
|
||||
COMPRESSION="xz"
|
||||
COMPRESSION_OPTIONS=(-9e)
|
||||
|
|
|
|||
11
iso/airootfs/etc/os-release
Normal file
11
iso/airootfs/etc/os-release
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
NAME="BOS"
|
||||
PRETTY_NAME="Bread OS"
|
||||
ID=bos
|
||||
ID_LIKE=arch
|
||||
BUILD_ID=rolling
|
||||
ANSI_COLOR="38;2;23;147;209"
|
||||
HOME_URL="https://breadway.dev"
|
||||
DOCUMENTATION_URL="https://wiki.archlinux.org/"
|
||||
SUPPORT_URL="https://bbs.archlinux.org/"
|
||||
BUG_REPORT_URL="https://git.breadway.dev/Breadway/bos/issues"
|
||||
PRIVACY_POLICY_URL="https://terms.archlinux.org/docs/privacy-policy/"
|
||||
2
iso/airootfs/etc/plymouth/plymouthd.conf
Normal file
2
iso/airootfs/etc/plymouth/plymouthd.conf
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[Daemon]
|
||||
Theme=bos
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
// Allow members of the wheel group to perform snapper rollback via pkexec
|
||||
// without a password prompt. Other snapper operations (list/create/delete)
|
||||
// are controlled by ALLOW_USERS in /etc/snapper/configs/root.
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (action.id == "io.opensuse.Snapper.Rollback" &&
|
||||
subject.local &&
|
||||
subject.active &&
|
||||
subject.isInGroup("wheel")) {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
1
iso/airootfs/etc/skel/.cache/wal/wal
Normal file
1
iso/airootfs/etc/skel/.cache/wal/wal
Normal file
|
|
@ -0,0 +1 @@
|
|||
/usr/share/backgrounds/bos/bread-background.png
|
||||
|
|
@ -1 +1,5 @@
|
|||
bread.activate_profile("default")
|
||||
bread.once("bread.system.startup", function()
|
||||
bread.profile.activate("default")
|
||||
end)
|
||||
|
||||
return bread
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
[[context]]
|
||||
name = "default"
|
||||
apps = ["firefox", "foot", "nautilus", "code"]
|
||||
priority = ["Zen Browser", "kitty", "Files"]
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
[[profile]]
|
||||
name = "home"
|
||||
ssids = []
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# Copy to ~/.config/breadlock/breadlock.toml — every field is optional and
|
||||
# defaults to the value shown here if omitted or the file doesn't exist.
|
||||
|
||||
[background]
|
||||
# "color" (bread-theme palette background) or "image" (a PNG, cover-fit)
|
||||
mode = "color"
|
||||
path = ""
|
||||
# v2 feature — accepted but currently just logs a warning and shows the
|
||||
# background unblurred (needs a wlr-screencopy capture, not implemented yet).
|
||||
blur = false
|
||||
|
||||
[clock]
|
||||
# strftime format
|
||||
format = "%H:%M"
|
||||
|
||||
[font]
|
||||
family = "Varela Round"
|
||||
|
||||
[input]
|
||||
# How long the "wrong password" state (red pill) shows before input
|
||||
# re-enables, in milliseconds.
|
||||
fail_timeout_ms = 800
|
||||
20
iso/airootfs/etc/skel/.config/fastfetch/bread.txt
Normal file
20
iso/airootfs/etc/skel/.config/fastfetch/bread.txt
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[0m[38;2;0;0;0m [38;2;2;2;2m [38;2;10;10;11m [38;2;12;13;14m [38;2;14;16;17m [38;2;16;17;19m [38;2;18;20;22m [38;2;18;20;21m [38;2;16;17;19m [38;2;14;16;17m [38;2;10;11;12m [38;2;7;7;8m [38;2;0;0;0m [38;2;0;0;0m [0m
|
||||
[0m[38;2;0;0;0m [38;2;2;2;2m [38;2;9;10;11m [38;2;15;17;18m [38;2;20;23;25m [38;2;25;28;30m [38;2;28;32;35m.[38;2;31;36;39m.[38;2;35;40;43m.[38;2;51;56;59m.[38;2;65;69;72m'[38;2;75;79;83m,[38;2;83;87;90m,[38;2;88;92;95m;[38;2;91;95;98m;[38;2;91;95;98m;[38;2;89;92;95m;[38;2;84;88;91m,[38;2;76;80;83m,[38;2;67;71;75m'[38;2;54;58;61m.[38;2;36;40;44m.[38;2;30;34;37m.[38;2;27;30;33m.[38;2;23;26;29m [38;2;20;23;25m [38;2;16;17;19m [38;2;10;11;12m [38;2;1;2;2m [38;2;0;0;0m [0m
|
||||
[0m[38;2;0;0;0m [38;2;9;10;10m [38;2;19;22;24m [38;2;28;32;35m.[38;2;35;40;44m.[38;2;71;75;78m'[38;2;116;119;122mc[38;2;150;152;154md[38;2;177;179;181mk[38;2;203;204;205mK[38;2;225;226;226mX[38;2;245;245;245mW[38;2;255;255;255mMM[38;2;251;251;251mM[38;2;242;242;243mW[38;2;235;235;236mN[38;2;230;230;231mN[38;2;228;229;229mN[38;2;229;230;230mN[38;2;235;235;235mN[38;2;242;243;243mW[38;2;252;252;252mM[38;2;255;255;255mM[38;2;250;250;250mM[38;2;232;232;233mN[38;2;210;211;212mK[38;2;184;186;187mO[38;2;153;156;158md[38;2;116;119;121mc[38;2;72;76;79m'[38;2;38;42;46m.[38;2;28;32;35m.[38;2;20;22;24m [38;2;10;11;11m [38;2;0;0;0m [38;2;0;0;0m [0m
|
||||
[0m[38;2;0;0;0m [38;2;8;8;9m [38;2;28;32;35m.[38;2;36;41;45m.[38;2;93;97;100m;[38;2;165;167;169mx[38;2;229;229;230mN[38;2;244;244;245mW[38;2;200;201;202m0[38;2;160;162;164mx[38;2;131;134;136mo[38;2;109;113;115mc[38;2;91;95;98m;[38;2;76;80;83m,[38;2;62;66;70m'[38;2;49;54;58m.[38;2;39;43;47m.[38;2;34;39;43m.......[38;2;39;44;48m.[38;2;51;55;59m.[38;2;65;69;72m'[38;2;82;86;89m,[38;2;105;108;111m:[38;2;133;136;138mo[38;2;166;168;169mx[38;2;204;205;206mK[38;2;244;244;245mW[38;2;236;236;237mN[38;2;180;182;183mO[38;2;110;113;116mc[38;2;41;45;49m.[38;2;30;34;37m.[38;2;10;11;11m [38;2;0;0;0m [0m
|
||||
[0m[38;2;4;5;5m [38;2;35;40;44m.[38;2;65;69;73m'[38;2;215;216;216mX[38;2;247;247;248mW[38;2;158;160;162mx[38;2;87;91;94m;[38;2;40;45;49m.[38;2;34;39;43m........................[38;2;39;44;48m.[38;2;81;85;88m,[38;2;148;150;152md[38;2;242;242;242mW[38;2;226;226;227mN[38;2;75;79;83m,[38;2;35;40;44m.[38;2;9;9;10m [0m
|
||||
[0m[38;2;11;13;14m [38;2;34;39;43m.[38;2;161;163;164mx[38;2;255;255;255mM[38;2;133;136;138mo[38;2;34;39;43m..............................[38;2;114;117;119mc[38;2;255;255;255mM[38;2;174;176;178mk[38;2;34;39;43m.[38;2;17;19;20m [0m
|
||||
[0m[38;2;17;17;17m [38;2;33;38;41m.[38;2;84;88;91m;[38;2;244;245;245mW[38;2;206;207;208mK[38;2;64;69;72m'[38;2;34;39;43m............................[38;2;60;65;68m'[38;2;186;188;189mO[38;2;249;250;250mM[38;2;100;104;107m:[38;2;34;39;43m.[38;2;1;1;1m [0m
|
||||
[0m[38;2;0;0;0m [38;2;17;17;17m [38;2;5;6;6m [38;2;65;69;73m'[38;2;143;145;147mo[38;2;245;245;245mW[38;2;199;201;202m0[38;2;45;49;53m.[38;2;34;39;43m...[38;2;55;60;63m.[38;2;149;151;153md[38;2;100;104;107m:[38;2;35;40;44m.[38;2;34;39;43m.................[38;2;38;43;47m.[38;2;191;193;194m0[38;2;249;250;250mM[38;2;150;152;154md[38;2;72;76;79m'[38;2;8;10;10m [38;2;19;19;19m [38;2;0;0;0m [0m
|
||||
[0m[38;2;0;0;0m [38;2;2;2;2m [38;2;21;23;25m [38;2;34;39;43m.[38;2;171;173;175mk[38;2;255;255;255mM[38;2;100;103;106m:[38;2;34;39;43m...[38;2;126;129;131ml[38;2;232;233;233mN[38;2;255;255;255mM[38;2;211;212;212mK[38;2;109;112;115mc[38;2;37;42;46m.[38;2;34;39;43m...............[38;2;83;87;90m,[38;2;255;255;255mM[38;2;190;191;193mO[38;2;34;39;43m.[38;2;24;27;29m [38;2;4;4;4m [38;2;0;0;0m [0m
|
||||
[0m[38;2;0;0;0m [38;2;14;16;17m [38;2;34;39;43m.[38;2;149;152;154md[38;2;255;255;255mM[38;2;117;120;122mc[38;2;34;39;43m....[38;2;42;47;51m.[38;2;123;126;128ml[38;2;223;223;224mX[38;2;255;255;255mM[38;2;222;223;223mX[38;2;125;128;130ml[38;2;43;48;52m.[38;2;34;39;43m.............[38;2;101;104;107m:[38;2;255;255;255mM[38;2;168;170;172mk[38;2;34;39;43m.[38;2;16;18;19m [38;2;0;0;0m [0m
|
||||
[0m[38;2;0;0;0m [38;2;16;17;18m [38;2;34;39;43m.[38;2;157;159;161mx[38;2;255;255;255mM[38;2;113;116;118mc[38;2;34;39;43m......[38;2;40;44;48m.[38;2;177;178;180mk[38;2;255;255;255mMM[38;2;223;224;225mX[38;2;62;66;70m'[38;2;34;39;43m............[38;2;95;98;101m;[38;2;255;255;255mM[38;2;177;179;180mk[38;2;34;39;43m.[38;2;14;16;18m [38;2;0;0;0m [0m
|
||||
[0m[38;2;0;0;0m [38;2;17;19;20m [38;2;34;39;43m.[38;2;177;179;180mk[38;2;255;255;255mM[38;2;96;100;103m:[38;2;34;39;43m....[38;2;37;42;46m.[38;2;107;110;113m:[38;2;209;210;211mK[38;2;255;255;255mM[38;2;234;234;235mN[38;2;141;144;146mo[38;2;51;56;60m.[38;2;34;39;43m.............[38;2;79;83;86m,[38;2;255;255;255mM[38;2;197;198;199m0[38;2;34;39;43m.[38;2;19;22;23m [38;2;0;0;0m [0m
|
||||
[0m[38;2;0;0;0m [38;2;22;25;27m [38;2;34;39;43m.[38;2;202;203;204mK[38;2;255;255;255mM[38;2;75;79;82m,[38;2;34;39;43m...[38;2;117;120;122mc[38;2;220;221;221mX[38;2;255;255;255mM[38;2;224;225;226mX[38;2;125;128;130ml[38;2;43;48;52m.[38;2;34;39;43m..[38;2;44;49;53m.[38;2;122;126;128ml[38;2;124;127;129mllllllll[38;2;44;49;53m.[38;2;34;39;43m..[38;2;57;61;65m.[38;2;255;255;255mM[38;2;223;223;224mX[38;2;34;39;43m.[38;2;26;29;31m [38;2;0;0;0m [0m
|
||||
[0m[38;2;0;0;0m [38;2;32;36;39m.[38;2;34;39;43m.[38;2;229;230;230mN[38;2;255;255;255mM[38;2;50;55;58m.[38;2;34;39;43m...[38;2;73;77;80m,[38;2;173;174;176mk[38;2;117;120;122mc[38;2;39;44;48m.[38;2;34;39;43m....[38;2;55;60;63m.[38;2;196;197;198m0[38;2;195;197;198m0[38;2;194;195;196m0[38;2;192;193;194m0[38;2;190;191;193mO[38;2;188;190;191mO[38;2;186;188;189mO[38;2;185;186;187mO[38;2;183;184;186mO[38;2;63;67;71m'[38;2;34;39;43m..[38;2;36;41;45m.[38;2;251;251;251mM[38;2;249;249;249mW[38;2;36;41;45m.[38;2;32;36;39m.[38;2;0;0;0m [0m
|
||||
[0m[38;2;0;0;0m [38;2;0;0;0m [38;2;37;42;45m.[38;2;38;43;47m.[38;2;252;252;252mM[38;2;245;246;246mW[38;2;34;39;43m.[38;2;34;39;43m.........................[38;2;230;230;231mN[38;2;255;255;255mM[38;2;57;61;65m.[38;2;37;41;45m.[38;2;1;1;1m [38;2;0;0;0m [0m
|
||||
[0m[38;2;0;0;0m [38;2;7;7;7m [38;2;34;39;43m.[38;2;57;61;65m.[38;2;255;255;255mM[38;2;224;225;226mX[38;2;34;39;43m..........................[38;2;212;213;214mK[38;2;255;255;255mM[38;2;79;83;86m,[38;2;34;39;43m.[38;2;8;8;8m [38;2;0;0;0m [0m
|
||||
[0m[38;2;0;0;0m [38;2;12;12;12m [38;2;34;39;43m.[38;2;64;69;72m'[38;2;255;255;255mM[38;2;225;225;226mX[38;2;34;39;43m..........................[38;2;216;216;217mX[38;2;255;255;255mM[38;2;88;91;95m;[38;2;34;39;43m.[38;2;1;2;2m [38;2;0;0;0m [0m
|
||||
[0m[38;2;0;0;0m [38;2;3;3;3m [38;2;23;26;29m [38;2;40;45;49m.[38;2;219;220;221mX[38;2;255;255;255mM[38;2;207;208;208mK[38;2;183;184;186mO[38;2;171;173;174mk[38;2;163;165;167mx[38;2;157;159;161mx[38;2;153;155;157md[38;2;149;152;154md[38;2;146;149;151md[38;2;144;147;149md[38;2;143;145;147mo[38;2;142;145;147moooo[38;2;143;145;147mo[38;2;144;147;149md[38;2;146;148;150md[38;2;148;150;152md[38;2;151;153;155md[38;2;154;156;158md[38;2;157;159;161mx[38;2;161;164;165mx[38;2;167;169;170mk[38;2;173;175;176mk[38;2;182;184;185mO[38;2;200;201;202m0[38;2;254;254;254mM[38;2;233;233;234mN[38;2;54;58;62m.[38;2;29;33;36m [38;2;7;7;7m [38;2;0;0;0m [0m
|
||||
[0m[38;2;0;0;0m [38;2;11;11;11m [38;2;27;27;27m [38;2;35;35;35m [38;2;62;62;62m [38;2;4;5;5m [38;2;81;84;87m,[38;2;101;104;107m:[38;2;108;111;114mc[38;2;114;117;119mc[38;2;118;121;124mc[38;2;122;125;127ml[38;2;125;128;130ml[38;2;128;131;133ml[38;2;130;132;135ml[38;2;130;133;135ml[38;2;132;135;137mo[38;2;132;135;137mo[38;2;132;135;137mo[38;2;130;133;135ml[38;2;129;132;135ml[38;2;128;131;133ml[38;2;125;128;131ml[38;2;123;126;128ml[38;2;119;122;124mc[38;2;115;118;120mc[38;2;110;113;116mc[38;2;104;107;110m:[38;2;97;100;103m:[38;2;88;92;95m;[38;2;31;33;34m.[38;2;63;63;63m [38;2;37;37;37m [38;2;27;27;27m [38;2;13;13;13m [38;2;0;0;0m [0m
|
||||
[0m[38;2;0;0;0m [38;2;2;2;2m [38;2;7;7;7m [38;2;9;9;9m [38;2;13;13;13m [38;2;12;12;12m [38;2;16;16;16m [38;2;12;12;12m [38;2;20;20;20m [38;2;18;18;18m [38;2;15;15;15m [38;2;16;16;16m [38;2;23;23;23m [38;2;23;23;23m [38;2;14;14;14m [38;2;16;16;16m [38;2;19;19;19m [38;2;16;16;16m [38;2;15;15;15m [38;2;13;13;13m [38;2;12;12;12m [38;2;8;8;8m [38;2;5;5;5m [38;2;0;0;0m [0m
|
||||
38
iso/airootfs/etc/skel/.config/fastfetch/config.jsonc
Normal file
38
iso/airootfs/etc/skel/.config/fastfetch/config.jsonc
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"$schema": "https://github.com/fastfetch-cli/fastfetch/raw/dev/doc/json_schema.json",
|
||||
"logo": {
|
||||
"type": "file",
|
||||
"source": "~/.config/fastfetch/bread.txt",
|
||||
"width": 30,
|
||||
"padding": {
|
||||
"right": 5
|
||||
}
|
||||
},
|
||||
"display": {
|
||||
"separator": " ",
|
||||
"color": {
|
||||
"keys": "cyan",
|
||||
"title": "bright_blue"
|
||||
}
|
||||
},
|
||||
"modules": [
|
||||
"title",
|
||||
"separator",
|
||||
"os",
|
||||
"host",
|
||||
"kernel",
|
||||
"uptime",
|
||||
"packages",
|
||||
"shell",
|
||||
"display",
|
||||
"wm",
|
||||
"terminal",
|
||||
"cpu",
|
||||
"gpu",
|
||||
"memory",
|
||||
"swap",
|
||||
"disk",
|
||||
"battery",
|
||||
"colors"
|
||||
]
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
# playback, etc. won't dim or suspend the machine). Vendor-neutral: nothing here
|
||||
# is Intel/AMD specific.
|
||||
general {
|
||||
lock_cmd = pidof hyprlock || hyprlock
|
||||
lock_cmd = pidof breadlock || breadlock
|
||||
before_sleep_cmd = loginctl lock-session
|
||||
after_sleep_cmd = hyprctl dispatch dpms on
|
||||
ignore_dbus_inhibit = false
|
||||
|
|
|
|||
|
|
@ -124,8 +124,16 @@ hl.bind(mod .. " + comma", hl.dsp.exec_cmd("bos-settings"))
|
|||
hl.bind(mod .. " + slash", hl.dsp.exec_cmd("bos-keybinds"))
|
||||
hl.bind(mod .. " + L", hl.dsp.exec_cmd("loginctl lock-session"))
|
||||
hl.bind(mod .. " + F", hl.dsp.window.fullscreen({ action = "toggle" }))
|
||||
hl.bind(mod .. " + V", hl.dsp.window.float({ action = "toggle" }))
|
||||
hl.bind(mod .. " + SHIFT + V", hl.dsp.exec_cmd([[bash -c 'cliphist list | fzf --reverse --prompt="Clipboard > " | cliphist decode | wl-copy']]))
|
||||
hl.bind(mod .. " + I", hl.dsp.window.float({ action = "toggle" }))
|
||||
hl.bind(mod .. " + P", hl.dsp.window.pseudo({ action = "toggle" }))
|
||||
hl.bind(mod .. " + R", hl.dsp.window.resize())
|
||||
-- breadclip (its own gtk4-layer-shell popup — not a TUI, so no terminal
|
||||
-- needed). Previously piped cliphist through fzf directly from the
|
||||
-- compositor with no terminal attached, which was a silent no-op.
|
||||
-- Bound on both V (breadclip's own suggested default, freed up now that
|
||||
-- float toggle moved to I) and SHIFT+V (kept for muscle memory).
|
||||
hl.bind(mod .. " + V", hl.dsp.exec_cmd("breadclip"))
|
||||
hl.bind(mod .. " + SHIFT + V", hl.dsp.exec_cmd("breadclip"))
|
||||
hl.bind(mod .. " + T", hl.dsp.layout("togglesplit"))
|
||||
hl.bind(mod .. " + Tab", hl.dsp.focus({ urgent_or_last = true }))
|
||||
hl.bind(mod .. " + N", hl.dsp.exit())
|
||||
|
|
@ -182,6 +190,7 @@ hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"
|
|||
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true })
|
||||
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true })
|
||||
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true })
|
||||
hl.bind("XF86Calculator", hl.dsp.exec_cmd("gnome-calculator"))
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Autostart. polkit agent + the bread ecosystem + idle daemon + wallpaper.
|
||||
|
|
@ -198,11 +207,22 @@ hl.on("hyprland.start", function()
|
|||
"gsettings set org.gnome.desktop.interface icon-theme Papirus-Dark",
|
||||
"gsettings set org.gnome.desktop.interface cursor-theme Bibata-Modern-Ice",
|
||||
"gsettings set org.gnome.desktop.interface cursor-size 24",
|
||||
-- Clipboard history daemon (feeds SUPER+V history picker via wl-paste).
|
||||
"wl-paste --type text --watch cliphist store",
|
||||
-- Clipboard history is breadclipd, a bakery-managed systemd --user
|
||||
-- service (auto-started via skel — see build-local.sh's service bake)
|
||||
-- rather than an exec-once here.
|
||||
"/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1",
|
||||
"awww-daemon",
|
||||
-- set the default wallpaper once the daemon is up (retry until ready)
|
||||
-- Set the default wallpaper once the daemon is up (retry until ready).
|
||||
-- Raw `awww img`, NOT `breadpaper set` — breadpaper set also runs real
|
||||
-- pywal against the image, which would clobber the curated black-base
|
||||
-- colors.json baked into skel (.cache/wal/colors.json: #0c0c0c bg,
|
||||
-- bread-toned browns reserved for accent slots only) with colors
|
||||
-- actually extracted from bread-background.png — which is an all-beige
|
||||
-- photo, so every bread-theme app (breadbar included) turns brown.
|
||||
-- `breadpaper get` still works on a fresh install without ever running
|
||||
-- pywal: .cache/wal/wal (pywal's own "last image" marker, which is all
|
||||
-- breadpaper reads) is baked into skel too, right beside colors.json.
|
||||
-- pywal only runs for real once the user picks a wallpaper themselves.
|
||||
[[bash -c 'until awww img /usr/share/backgrounds/bos/bread-background.png 2>/dev/null; do sleep 0.3; done']],
|
||||
-- breadd runs as a systemd user service (~/.config/systemd/user/breadd.service,
|
||||
-- enabled in skel). It autostarts at login but before Hyprland exists, so
|
||||
|
|
@ -210,8 +230,20 @@ hl.on("hyprland.start", function()
|
|||
-- to pick it up — that's how it gets HYPRLAND_INSTANCE_SIGNATURE to talk to Hyprland.
|
||||
"dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE",
|
||||
"systemctl --user restart breadd",
|
||||
-- graphical-session.target ships with RefuseManualStart=yes (systemd
|
||||
-- convention — only a session manager like uwsm is meant to activate
|
||||
-- it), confirmed on real hardware: `systemctl --user start
|
||||
-- graphical-session.target` fails outright ("Operation refused").
|
||||
-- BOS doesn't use uwsm, and nothing else activates that target, so
|
||||
-- breadclipd (WantedBy=graphical-session.target) never started.
|
||||
-- Start it directly instead. If more graphical-session.target
|
||||
-- services show up later, add them here too.
|
||||
"systemctl --user start breadclipd.service",
|
||||
"breadbar",
|
||||
"breadbox-sync",
|
||||
-- breadbox-sync is a Type=oneshot systemd --user service
|
||||
-- (WantedBy=default.target, no Hyprland IPC dependency) — it
|
||||
-- already runs on login via the unit baked into skel; exec'ing it
|
||||
-- again here would just start it twice.
|
||||
"hypridle",
|
||||
-- first-boot onboarding (self-gates after the first run)
|
||||
"bos-welcome",
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
# Lock screen (hyprlock). Solid dark background (no runtime-generated wallpaper
|
||||
# dependency), accent matched to the Hyprland border colour.
|
||||
general {
|
||||
hide_cursor = true
|
||||
}
|
||||
|
||||
background {
|
||||
monitor =
|
||||
color = rgba(46, 52, 64, 1.0)
|
||||
blur_passes = 0
|
||||
}
|
||||
|
||||
input-field {
|
||||
monitor =
|
||||
size = 20%, 5%
|
||||
outline_thickness = 3
|
||||
inner_color = rgba(0, 0, 0, 0.2)
|
||||
outer_color = rgba(136, 192, 208, 0.8)
|
||||
check_color = rgba(120, 220, 140, 0.95)
|
||||
fail_color = rgba(255, 90, 90, 0.95)
|
||||
font_color = rgba(255, 255, 255, 0.95)
|
||||
fade_on_empty = false
|
||||
rounding = 12
|
||||
placeholder_text = <i>Password…</i>
|
||||
position = 0, -20
|
||||
halign = center
|
||||
valign = center
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
[Desktop Entry]
|
||||
Name=breadclip
|
||||
Comment=Clipboard history
|
||||
Exec=breadclip
|
||||
Icon=edit-paste
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Utility;
|
||||
StartupWMClass=breadclip
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
[Desktop Entry]
|
||||
Name=breadman
|
||||
Comment=Notes and reminders manager
|
||||
Exec=breadman
|
||||
Icon=accessories-text-editor
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Utility;Office;
|
||||
StartupWMClass=breadman
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
[Desktop Entry]
|
||||
Name=breadmon
|
||||
Comment=Monitor layout manager (TUI)
|
||||
Exec=breadmon
|
||||
Icon=video-display
|
||||
Terminal=true
|
||||
Type=Application
|
||||
Categories=Settings;System;
|
||||
StartupWMClass=breadmon
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
[Desktop Entry]
|
||||
Name=breadsearch
|
||||
Comment=Semantic system-wide search
|
||||
Exec=breadsearch
|
||||
Icon=system-search
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Utility;
|
||||
StartupWMClass=breadsearch
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
# Auto-start Hyprland on tty1 in the live session
|
||||
if [[ "$(tty)" == "/dev/tty1" ]] && [[ -z "$WAYLAND_DISPLAY" ]]; then
|
||||
# Allow a software-rendering fallback so the live session comes up even
|
||||
# without a GPU (VMs, headless, exotic hardware). On real hardware wlroots
|
||||
# still selects the hardware renderer; this only permits llvmpipe when no
|
||||
# GPU renderer is available. Must be exported before Hyprland starts —
|
||||
# wlroots reads it at renderer init, earlier than any Hyprland `env=` line.
|
||||
export WLR_RENDERER_ALLOW_SOFTWARE=1
|
||||
# Software cursors: hardware-cursor planes are often unusable in VMs and
|
||||
# show as invisible/garbled; this is the reliable choice for a live medium.
|
||||
export WLR_NO_HARDWARE_CURSORS=1
|
||||
# Run the compositor, capturing its output so a failed live boot is
|
||||
# diagnosable (Hyprland also keeps its own log under $XDG_RUNTIME_DIR/hypr/).
|
||||
# On exit, drop to an interactive shell with the error in view instead of
|
||||
# letting the getty autologin respawn-loop hide it behind a blank cursor.
|
||||
Hyprland &>/var/log/hyprland-live.log
|
||||
echo "Hyprland exited (rc=$?). Log: /var/log/hyprland-live.log"
|
||||
exec bash -i
|
||||
fi
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
# Live-session Hyprland config — launches Calamares on start.
|
||||
# This is NOT the installed system config; that lives in dotfiles/hypr/.
|
||||
|
||||
monitor=,preferred,auto,1
|
||||
|
||||
exec-once = calamares
|
||||
|
||||
general {
|
||||
border_size = 2
|
||||
col.active_border = rgba(88c0d0ff)
|
||||
col.inactive_border = rgba(4c566aff)
|
||||
}
|
||||
|
||||
decoration {
|
||||
rounding = 4
|
||||
}
|
||||
|
||||
input {
|
||||
kb_layout = us
|
||||
follow_mouse = 1
|
||||
}
|
||||
|
||||
misc {
|
||||
disable_hyprland_logo = true
|
||||
disable_splash_rendering = true
|
||||
# Keep compositor running if calamares exits (user can relaunch)
|
||||
exit_window_request_force = false
|
||||
}
|
||||
|
|
@ -7,19 +7,40 @@
|
|||
# (the stock linux.preset points ALL_kver at /boot/vmlinuz-linux) and before the
|
||||
# `bootloader` module runs grub — otherwise the installed system is unbootable.
|
||||
#
|
||||
# Runs in the LIVE environment (Calamares shellprocess, dontChroot) so it can
|
||||
# read /run/archiso/bootmnt; the target root mount point is passed as $1.
|
||||
# Runs in the LIVE environment (Calamares shellprocess, dontChroot); the
|
||||
# target root mount point is passed as $1.
|
||||
#
|
||||
# The kernel is read from /usr/lib/modules/$(uname -r)/vmlinuz — part of the
|
||||
# live squashfs itself (the linux package always installs it there), not from
|
||||
# the ISO's separate arch/boot/x86_64/ dir under /run/archiso/bootmnt. That
|
||||
# mount gets torn down by archiso's own copytoram handling, which self-enables
|
||||
# on most real hardware, so depending on it here would leave $ROOT/boot empty
|
||||
# and the install unbootable on exactly the machines this is meant to run on.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="${1:?target root required}"
|
||||
SRC="/run/archiso/bootmnt/arch/boot/x86_64"
|
||||
KVER="$(uname -r)"
|
||||
SRC_KERNEL="/usr/lib/modules/${KVER}/vmlinuz"
|
||||
SRC_BOOT="/run/archiso/bootmnt/arch/boot/x86_64"
|
||||
|
||||
install -d -m 0755 "$ROOT/boot"
|
||||
cp -f "$SRC/vmlinuz-linux" "$ROOT/boot/vmlinuz-linux"
|
||||
|
||||
# Microcode, if the live medium carries it (grub-mkconfig picks it up).
|
||||
if [ -f "$SRC_KERNEL" ]; then
|
||||
cp -f "$SRC_KERNEL" "$ROOT/boot/vmlinuz-linux"
|
||||
elif [ -f "$SRC_BOOT/vmlinuz-linux" ]; then
|
||||
echo "WARN: $SRC_KERNEL missing, falling back to $SRC_BOOT"
|
||||
cp -f "$SRC_BOOT/vmlinuz-linux" "$ROOT/boot/vmlinuz-linux"
|
||||
else
|
||||
echo "ERROR: no kernel image found (checked $SRC_KERNEL and $SRC_BOOT/vmlinuz-linux) — install would be unbootable" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Microcode, if the live medium carries it (grub-mkconfig picks it up). This
|
||||
# is best-effort: post-install.sh's mkinitcpio `microcode` HOOKS entry embeds
|
||||
# microcode from /usr/lib/firmware directly, so a missing standalone ucode.img
|
||||
# here is not boot-critical, unlike the kernel image above.
|
||||
for u in amd-ucode.img intel-ucode.img; do
|
||||
[ -f "$SRC/$u" ] && cp -f "$SRC/$u" "$ROOT/boot/$u"
|
||||
[ -f "$SRC_BOOT/$u" ] && cp -f "$SRC_BOOT/$u" "$ROOT/boot/$u"
|
||||
done
|
||||
|
||||
# Replace the archiso initramfs setup that unpackfs copied from the live medium.
|
||||
|
|
|
|||
|
|
@ -18,31 +18,36 @@ if ! id liveuser &>/dev/null; then
|
|||
passwd -d liveuser >/dev/null
|
||||
fi
|
||||
|
||||
# Layer the installer onto the live desktop: auto-launch it, and bind Super+I to
|
||||
# relaunch it after it's been closed. Appended (in Lua) to the skel hyprland.lua
|
||||
# native config so the full desktop stays intact.
|
||||
# Layer the installer onto the live desktop: auto-launch it, and bind
|
||||
# Super+Shift+I to relaunch it after it's been closed. Appended (in Lua) to
|
||||
# the skel hyprland.lua native config so the full desktop stays intact.
|
||||
# NOTE: plain SUPER+I is float-toggle in the skel config — don't reuse it
|
||||
# here, it was tried once and silently double-bound both actions.
|
||||
HYPR=/home/liveuser/.config/hypr/hyprland.lua
|
||||
install -d -m 0755 -o liveuser -g liveuser /home/liveuser/.config/hypr
|
||||
if ! grep -q bos-launch-calamares "$HYPR" 2>/dev/null; then
|
||||
cat >>"$HYPR" <<'EOF'
|
||||
|
||||
-- --- live-media installer (added by bos-live-setup; absent on installed system) ---
|
||||
hl.bind("SUPER + I", hl.dsp.exec_cmd("bos-launch-calamares"))
|
||||
hl.bind("SUPER + SHIFT + I", hl.dsp.exec_cmd("bos-launch-calamares"))
|
||||
hl.on("hyprland.start", function() hl.dispatch(hl.dsp.exec_cmd("bos-launch-calamares")) end)
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Start Hyprland on tty1 login; capture output and fall back to a shell so a
|
||||
# failed compositor start is visible rather than a blank looping cursor.
|
||||
cat >/home/liveuser/.bash_profile <<'EOF'
|
||||
# liveuser's shell is zsh (see useradd above), which never sources
|
||||
# .bash_profile — this must be .zprofile (zsh's login-shell hook) or it never
|
||||
# runs at all and the live session boots to a bare console.
|
||||
cat >/home/liveuser/.zprofile <<'EOF'
|
||||
if [[ "$(tty)" == /dev/tty1 ]] && [[ -z "$WAYLAND_DISPLAY" ]]; then
|
||||
export WLR_RENDERER_ALLOW_SOFTWARE=1
|
||||
export WLR_NO_HARDWARE_CURSORS=1
|
||||
# Log to a user-writable path (/var/log is root-only; redirecting there
|
||||
# would fail and silently keep the compositor from ever launching).
|
||||
Hyprland &>/tmp/hyprland-live.log
|
||||
start-hyprland &>/tmp/hyprland-live.log
|
||||
echo "Hyprland exited (rc=$?). Log: /tmp/hyprland-live.log"
|
||||
exec bash -i
|
||||
exec zsh -i
|
||||
fi
|
||||
EOF
|
||||
|
||||
|
|
|
|||
29
iso/airootfs/usr/local/bin/bos-resolve-airootfs
Normal file
29
iso/airootfs/usr/local/bin/bos-resolve-airootfs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
#!/bin/bash
|
||||
# Resolve the real location of the live squashfs before Calamares' unpackfs
|
||||
# module runs, and leave a stable symlink for it to unpack from.
|
||||
#
|
||||
# archiso's own initcpio hook unmounts /run/archiso/bootmnt once copytoram has
|
||||
# copied the image to a tmpfs (/run/archiso/copytoram/airootfs.sfs) — and
|
||||
# copytoram=auto self-enables on most real hardware (non-optical boot device,
|
||||
# image under 4 GiB, enough free RAM), not just when explicitly requested. A
|
||||
# Calamares unpackfs.conf source path hardcoded to /run/archiso/bootmnt/...
|
||||
# would then point at nothing. Check both locations here, in a real script,
|
||||
# and hand unpackfs.conf one fixed path that always resolves.
|
||||
set -uo pipefail
|
||||
|
||||
DEST="/run/archiso/resolved-airootfs.sfs"
|
||||
CANDIDATES=(
|
||||
"/run/archiso/copytoram/airootfs.sfs"
|
||||
"/run/archiso/bootmnt/arch/x86_64/airootfs.sfs"
|
||||
)
|
||||
|
||||
for c in "${CANDIDATES[@]}"; do
|
||||
if [ -f "$c" ]; then
|
||||
ln -sf "$c" "$DEST"
|
||||
echo "resolved live squashfs: $c -> $DEST"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
echo "ERROR: could not find the live squashfs in any known location (checked: ${CANDIDATES[*]})" >&2
|
||||
exit 1
|
||||
|
|
@ -7,9 +7,14 @@
|
|||
# breadbox-sync, …) would be missing from PATH and the Hyprland `exec-once`
|
||||
# launches would fail. Source the login profile here so PATH is correct, set the
|
||||
# Wayland session hints, then hand off to Hyprland.
|
||||
#
|
||||
# Launched via start-hyprland (ships with the hyprland package) rather than the
|
||||
# raw Hyprland binary — Hyprland upstream no longer recommends exec'ing it
|
||||
# directly; start-hyprland wraps it in a watchdog process that also gives us
|
||||
# crash recovery for free.
|
||||
source /etc/profile 2>/dev/null
|
||||
|
||||
export XDG_SESSION_TYPE=wayland
|
||||
export XDG_CURRENT_DESKTOP=Hyprland
|
||||
|
||||
exec Hyprland
|
||||
exec start-hyprland
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@
|
|||
# 1. pacman — Arch base/desktop + the [breadway] repo (bos-settings, etc.).
|
||||
# Every transaction is snapshotted by snap-pac, so you can roll
|
||||
# back from the GRUB "snapshots" submenu or BOS Settings.
|
||||
# 2. bakery — the bread ecosystem apps in ~/.local/bin (bread, breadbar,
|
||||
# breadbox, breadcrumbs, breadpad, breadman, bread-theme).
|
||||
# 2. bakery — the bread ecosystem apps in ~/.local/bin (whatever `bakery list`
|
||||
# reports as installed — bread, breadbar, breadbox, breadcrumbs,
|
||||
# breadpad, breadman, bread-theme, breadpaper, breadmon,
|
||||
# breadsearch, breadclip, breadshot, ...).
|
||||
#
|
||||
# Best-effort: a failure in one channel doesn't abort the other.
|
||||
set -uo pipefail
|
||||
|
|
|
|||
|
|
@ -1,24 +1,36 @@
|
|||
#!/bin/bash
|
||||
# First-run welcome. Shows a short getting-started message once, then drops a
|
||||
# marker so it never shows again. Launched from the Hyprland autostart; the
|
||||
# bos-welcome window class is floated/centred by a Hyprland window rule.
|
||||
# First-run welcome + first-run/every-login network check. Launched from the
|
||||
# Hyprland autostart; the bos-welcome window class is floated/centred by a
|
||||
# Hyprland window rule.
|
||||
set -u
|
||||
|
||||
# Never run in the live/installer session — only on an installed system.
|
||||
[[ "$(id -un)" == "liveuser" ]] && exit 0
|
||||
|
||||
marker="${XDG_CONFIG_HOME:-$HOME/.config}/bos/.welcomed"
|
||||
[[ -f "$marker" ]] && exit 0
|
||||
mkdir -p "$(dirname "$marker")"
|
||||
welcomed_marker="${XDG_CONFIG_HOME:-$HOME/.config}/bos/.welcomed"
|
||||
mkdir -p "$(dirname "$welcomed_marker")"
|
||||
|
||||
# First-run network check. A fresh install usually boots with no connection
|
||||
# (Wi-Fi isn't configured during install), and the first `bos-update`/pacman run
|
||||
# Network check. A fresh install usually boots with no connection (Wi-Fi
|
||||
# isn't configured during install), and the first `bos-update`/pacman run
|
||||
# then fails with confusing DNS/"could not resolve host" errors. If
|
||||
# NetworkManager reports we're not fully online, open nmtui so the user can join
|
||||
# a network before anything else. Best-effort: missing nmcli/nmtui/kitty, or the
|
||||
# user quitting nmtui, must never block the welcome below.
|
||||
# NetworkManager reports we're not fully online, open nmtui so the user can
|
||||
# join a network before anything else. This runs on EVERY login, not just
|
||||
# the first, and isn't gated by any marker — it keeps re-prompting until the
|
||||
# machine is actually online, then naturally stops (the "full" check below
|
||||
# short-circuits). Best-effort: missing nmcli/nmtui/kitty, or the user
|
||||
# quitting nmtui, must never block the welcome text below.
|
||||
if command -v nmcli &>/dev/null; then
|
||||
conn="$(nmcli networking connectivity check 2>/dev/null)"
|
||||
# NetworkManager may still be associating right at compositor start —
|
||||
# give it a few short retries before concluding we're actually offline,
|
||||
# so a machine with working Wi-Fi doesn't get a spurious nmtui popup.
|
||||
tries=0
|
||||
while [[ "$conn" != "full" && "$tries" -lt 3 ]]; do
|
||||
sleep 1
|
||||
conn="$(nmcli networking connectivity check 2>/dev/null)"
|
||||
tries=$((tries + 1))
|
||||
done
|
||||
|
||||
if [[ "$conn" != "full" ]]; then
|
||||
notify-send -u normal "BOS" "No internet yet — opening network setup so updates work." 2>/dev/null || true
|
||||
if command -v nmtui &>/dev/null; then
|
||||
|
|
@ -27,8 +39,10 @@ if command -v nmcli &>/dev/null; then
|
|||
fi
|
||||
fi
|
||||
|
||||
# Mark welcomed only now, so an interrupted/aborted network step still re-prompts
|
||||
# next login rather than being suppressed forever.
|
||||
touch "$marker"
|
||||
# Welcome text: shown once ever, independent of network status above (an
|
||||
# offline machine still gets useful onboarding text — it just also keeps
|
||||
# getting the network prompt on future logins until it connects).
|
||||
[[ -f "$welcomed_marker" ]] && exit 0
|
||||
touch "$welcomed_marker"
|
||||
|
||||
exec kitty --class bos-welcome --title "Welcome to BOS" -- less -R /usr/share/bos/welcome.txt
|
||||
|
|
|
|||
|
|
@ -10,14 +10,16 @@
|
|||
SUPER + E files (nautilus)
|
||||
SUPER + B browser (zen)
|
||||
SUPER + U notes / reminders (breadpad)
|
||||
SUPER + M package manager (breadman)
|
||||
SUPER + M notes / task manager (breadman)
|
||||
SUPER + , BOS Settings
|
||||
SUPER + / this keybind cheatsheet
|
||||
SUPER + L lock screen
|
||||
SUPER + Backspace close window
|
||||
SUPER + F fullscreen
|
||||
SUPER + V toggle floating
|
||||
SUPER + Shift + V clipboard history
|
||||
SUPER + I toggle floating
|
||||
SUPER + P toggle pseudotile
|
||||
SUPER + R resize mode
|
||||
SUPER + V / Shift + V clipboard history (breadclip)
|
||||
SUPER + T toggle split direction
|
||||
SUPER + Tab last window
|
||||
SUPER + N exit Hyprland (log out)
|
||||
|
|
@ -45,6 +47,7 @@
|
|||
|
||||
MEDIA & HARDWARE KEYS
|
||||
volume / brightness / play-pause / next / prev (work on lock screen)
|
||||
calculator key opens gnome-calculator
|
||||
|
||||
──────────────────────────────────────────────────────────
|
||||
Press q to close. Configure everything in BOS Settings (SUPER + ,).
|
||||
|
|
|
|||
5
iso/airootfs/usr/share/wayland-sessions/bos.desktop
Normal file
5
iso/airootfs/usr/share/wayland-sessions/bos.desktop
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
[Desktop Entry]
|
||||
Name=BOS
|
||||
Comment=Bread OS (Hyprland)
|
||||
Exec=/usr/local/bin/bos-session
|
||||
Type=Application
|
||||
|
|
@ -2,4 +2,4 @@ title Bread OS install medium (copy to RAM, UEFI)
|
|||
sort-key 015
|
||||
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux
|
||||
initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux.img
|
||||
options archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% copytoram=y
|
||||
options archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% copytoram=y quiet splash vt.global_cursor_default=0 systemd.show_status=false rd.systemd.show_status=false rd.udev.log_level=3
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@ title Bread OS install medium (%ARCH%, UEFI)
|
|||
sort-key 01
|
||||
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux
|
||||
initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux.img
|
||||
options archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID%
|
||||
options archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash vt.global_cursor_default=0 systemd.show_status=false rd.systemd.show_status=false rd.udev.log_level=3
|
||||
|
|
|
|||
|
|
@ -11,14 +11,18 @@ amd-ucode
|
|||
intel-ucode
|
||||
|
||||
# Power management (vendor-neutral; works on Intel and AMD). tlp auto-tunes power
|
||||
# by AC/battery and CPU driver; hypridle/hyprlock handle idle dim/off/lock/suspend
|
||||
# by AC/battery and CPU driver; hypridle/breadlock handle idle dim/off/lock/suspend
|
||||
# and the lock screen; upower exposes battery state. NOT power-profiles-daemon —
|
||||
# it conflicts with tlp.
|
||||
tlp
|
||||
tlp-rdw
|
||||
upower
|
||||
hypridle
|
||||
hyprlock
|
||||
# breadlock replaces hyprlock (locker) and tuigreet (greetd's greeter) — see
|
||||
# /etc/greetd/config.toml and skel's hypridle.conf lock_cmd. cage hosts
|
||||
# breadgreet as a single-client kiosk compositor under greetd.
|
||||
breadlock
|
||||
cage
|
||||
|
||||
# Bootloader + filesystem
|
||||
grub
|
||||
|
|
@ -26,6 +30,15 @@ efibootmgr
|
|||
btrfs-progs
|
||||
dosfstools
|
||||
mtools
|
||||
# LUKS full-disk encryption — Calamares' partition module has encryption
|
||||
# support built in and enabled by default, but needs cryptsetup actually
|
||||
# present (live, to create the container; installed, to unlock at boot via
|
||||
# mkinitcpio's encrypt hook) or the checkbox leads to an unbootable system.
|
||||
cryptsetup
|
||||
# Secure Boot key enrollment/signing (self-signed — see post-install.sh).
|
||||
# Ships its own pacman hook (zz-sbctl.hook) that re-signs the kernel/
|
||||
# bootloader automatically on every future update once enrolled.
|
||||
sbctl
|
||||
# squashfs-tools: provides unsquashfs, which Calamares' unpackfs module uses
|
||||
# to extract airootfs.sfs onto the target during install.
|
||||
squashfs-tools
|
||||
|
|
@ -56,9 +69,8 @@ xdg-desktop-portal-hyprland
|
|||
# and Firefox-based apps (Zen). Without it those apps get no file dialog.
|
||||
xdg-desktop-portal-gtk
|
||||
# Login manager for the installed system (Wayland-native; enabled by
|
||||
# post-install.sh, launches the Hyprland session via tuigreet → bos-session).
|
||||
# post-install.sh, launches the Hyprland session via breadgreet → bos-session).
|
||||
greetd
|
||||
greetd-tuigreet
|
||||
xdg-utils
|
||||
xdg-user-dirs
|
||||
polkit
|
||||
|
|
@ -158,6 +170,12 @@ mailcap
|
|||
# (calamares 3.4.x is already Qt6; there is no separate calamares-qt6 package)
|
||||
calamares
|
||||
|
||||
# AUR helper — yay-bin is AUR-only (no AUR helper ships in the official
|
||||
# repos), so it's republished to [breadway] the same way (see
|
||||
# packaging/yay-bin). Lets users reach the wider AUR beyond bakery's bread
|
||||
# ecosystem + [breadway]'s own small set of republished packages.
|
||||
yay-bin
|
||||
|
||||
# Bread ecosystem.
|
||||
#
|
||||
# The bread apps themselves (bakery, bread, breadbar, breadbox, breadcrumbs,
|
||||
|
|
@ -178,10 +196,10 @@ brightnessctl
|
|||
grim
|
||||
slurp
|
||||
# Clipboard (Wayland copy/paste; also clipboard screenshots) and media keys.
|
||||
# Clipboard history is breadclip/breadclipd (bakery-managed, SUPER+SHIFT+V),
|
||||
# not cliphist — wl-clipboard is still needed directly by breadclip/breadshot.
|
||||
wl-clipboard
|
||||
playerctl
|
||||
# Clipboard history daemon (stores wl-clipboard events; breadbox bind replays them).
|
||||
cliphist
|
||||
# Wallpaper daemon + pywal (drives the bread* colour palette from the wallpaper).
|
||||
awww
|
||||
python-pywal
|
||||
|
|
@ -269,6 +287,15 @@ system-config-printer
|
|||
# remote post-install (needs network); the runtime is shipped ready.
|
||||
flatpak
|
||||
|
||||
# Graphical alternatives to terminal-only tools, so users who want more
|
||||
# graphical control aren't funneled to a shell for everyday things.
|
||||
# gnome-disk-utility: partition/format/SMART-health GUI for gnome-disks.
|
||||
# gufw: GUI front-end for the ufw firewall bos already enables by default.
|
||||
# mission-center: graphical task manager (CPU/mem/disk/net + process list).
|
||||
gnome-disk-utility
|
||||
gufw
|
||||
mission-center
|
||||
|
||||
# Firewall — ufw, enabled deny-incoming in post-install.sh (mDNS allowed so
|
||||
# printer discovery still works).
|
||||
ufw
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ file_permissions=(
|
|||
["/usr/local/bin/bos-live-setup"]="0:0:755"
|
||||
["/usr/local/bin/bos-launch-calamares"]="0:0:755"
|
||||
["/usr/local/bin/bos-copy-kernel"]="0:0:755"
|
||||
["/usr/local/bin/bos-resolve-airootfs"]="0:0:755"
|
||||
["/usr/local/bin/bos-session"]="0:0:755"
|
||||
["/usr/local/bin/bos-keybinds"]="0:0:755"
|
||||
["/usr/local/bin/bos-welcome"]="0:0:755"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ ENDTEXT
|
|||
MENU LABEL Bread OS install medium (%ARCH%, BIOS)
|
||||
LINUX /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux
|
||||
INITRD /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux.img
|
||||
APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID%
|
||||
APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash vt.global_cursor_default=0 systemd.show_status=false rd.systemd.show_status=false rd.udev.log_level=3
|
||||
|
||||
# Copy-to-RAM boot option — loads airootfs.sfs entirely into RAM, so the
|
||||
# installer reads from memory rather than a possibly-flaky USB (avoids SquashFS
|
||||
|
|
@ -19,7 +19,7 @@ ENDTEXT
|
|||
MENU LABEL Bread OS install medium (%ARCH%, BIOS) ^copy to RAM
|
||||
LINUX /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux
|
||||
INITRD /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux.img
|
||||
APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% copytoram=y
|
||||
APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% copytoram=y quiet splash vt.global_cursor_default=0 systemd.show_status=false rd.systemd.show_status=false rd.udev.log_level=3
|
||||
|
||||
# Accessibility boot option
|
||||
LABEL archspeech
|
||||
|
|
|
|||
40
packaging/yay-bin/PKGBUILD
Normal file
40
packaging/yay-bin/PKGBUILD
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# BOS in-house rebuild of yay-bin (AUR-only upstream — no AUR helper is in
|
||||
# the official Arch repos, including yay itself). Republished to the
|
||||
# [breadway] repo so the ISO build can pull it via pacman (same pattern as
|
||||
# bibata-cursor-theme and calamares). Prebuilt release tarball — no build step.
|
||||
# Upstream maintainer: Jguer <pkgbuilds at jguer.space>
|
||||
pkgname=yay-bin
|
||||
pkgver=13.0.1
|
||||
pkgrel=1
|
||||
pkgdesc="Yet another yogurt. Pacman wrapper and AUR helper written in go. Pre-compiled."
|
||||
arch=('x86_64')
|
||||
url="https://github.com/Jguer/yay"
|
||||
license=('GPL-3.0-or-later')
|
||||
depends=(
|
||||
'pacman>6.1'
|
||||
'git'
|
||||
)
|
||||
optdepends=(
|
||||
'sudo: privilege elevation'
|
||||
'doas: privilege elevation'
|
||||
)
|
||||
provides=('yay')
|
||||
conflicts=('yay')
|
||||
|
||||
source=("https://github.com/Jguer/yay/releases/download/v${pkgver}/${pkgname/-bin/}_${pkgver}_x86_64.tar.gz")
|
||||
sha256sums=('1fdfcb5f7f387bc858d3a5754bdf4e4575bfbddac9560535a716d0ed7189c057')
|
||||
|
||||
package() {
|
||||
_output="${srcdir}/${pkgname/-bin/}_${pkgver}_${CARCH}"
|
||||
install -Dm755 "${_output}/${pkgname/-bin/}" "${pkgdir}/usr/bin/${pkgname/-bin/}"
|
||||
install -Dm644 "${_output}/yay.8" "${pkgdir}/usr/share/man/man8/yay.8"
|
||||
|
||||
install -Dm644 "${_output}/bash" "${pkgdir}/usr/share/bash-completion/completions/yay"
|
||||
install -Dm644 "${_output}/zsh" "${pkgdir}/usr/share/zsh/site-functions/_yay"
|
||||
install -Dm644 "${_output}/fish" "${pkgdir}/usr/share/fish/vendor_completions.d/yay.fish"
|
||||
|
||||
LANGS="ca cs de en es eu fr_FR he id it_IT ja ko pl_PL pt_BR pt ru_RU ru sv tr uk zh_CN zh_TW"
|
||||
for lang in ${LANGS}; do
|
||||
install -Dm644 "${_output}/${lang}.mo" "${pkgdir}/usr/share/locale/${lang}/LC_MESSAGES/yay.mo"
|
||||
done
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue