dev #2
64 changed files with 1033 additions and 6283 deletions
|
|
@ -24,7 +24,12 @@ jobs:
|
||||||
kcoreaddons kpmcore libpwquality qt6-declarative qt6-svg yaml-cpp
|
kcoreaddons kpmcore libpwquality qt6-declarative qt6-svg yaml-cpp
|
||||||
useradd -m builder
|
useradd -m builder
|
||||||
git config --global --add safe.directory '*'
|
git config --global --add safe.directory '*'
|
||||||
git clone --depth 1 "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src
|
# Clone the branch/tag that triggered this run (not the default
|
||||||
|
# branch) — same as bibata.yml/powerlevel10k.yml/yay-bin.yml, so a
|
||||||
|
# push to a feature branch (or a release tag) builds and publishes
|
||||||
|
# from that ref, not whatever happens to be on the default branch.
|
||||||
|
git clone --depth 1 --branch "${GITHUB_REF_NAME}" \
|
||||||
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src
|
||||||
chown -R builder:builder /home/builder/src
|
chown -R builder:builder /home/builder/src
|
||||||
su builder -c "cd /home/builder/src/packaging/calamares && makepkg -f --noconfirm --nocheck"
|
su builder -c "cd /home/builder/src/packaging/calamares && makepkg -f --noconfirm --nocheck"
|
||||||
PKG=$(find /home/builder/src/packaging/calamares -name '*.pkg.tar.zst' | head -1)
|
PKG=$(find /home/builder/src/packaging/calamares -name '*.pkg.tar.zst' | head -1)
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
name: Build and publish package
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ['v*']
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
package:
|
|
||||||
runs-on: [self-hosted, hestia]
|
|
||||||
container:
|
|
||||||
image: archlinux:latest
|
|
||||||
steps:
|
|
||||||
# Note: no actions/checkout — the archlinux image has no Node, which JS
|
|
||||||
# actions require. Everything runs as shell steps and clones manually.
|
|
||||||
- name: Build and publish
|
|
||||||
env:
|
|
||||||
PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
|
||||||
pacman -Syu --noconfirm base-devel git rust cargo gtk4 glib2
|
|
||||||
useradd -m builder
|
|
||||||
git config --global --add safe.directory '*'
|
|
||||||
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
|
||||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src
|
|
||||||
cd /home/builder/src
|
|
||||||
git archive --format=tar.gz --prefix="bos-settings-${VERSION}/" HEAD \
|
|
||||||
> packaging/arch/bos-settings-${VERSION}.tar.gz
|
|
||||||
SHA=$(sha256sum packaging/arch/bos-settings-${VERSION}.tar.gz | awk '{print $1}')
|
|
||||||
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD
|
|
||||||
sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD
|
|
||||||
chown -R builder:builder /home/builder/src
|
|
||||||
# --nocheck: packaging builds the artifact; tests belong in a CI job.
|
|
||||||
su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck"
|
|
||||||
PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1)
|
|
||||||
curl -fsS -X PUT \
|
|
||||||
-H "Authorization: token ${PUBLISH_TOKEN}" \
|
|
||||||
-H "Content-Type: application/octet-stream" \
|
|
||||||
--data-binary "@${PKG}" \
|
|
||||||
"https://git.breadway.dev/api/packages/Breadway/arch/os"
|
|
||||||
|
|
@ -111,8 +111,18 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# bread-theme is not in the bakery index; build it at the tag pinned
|
# bread-theme is not in the bakery index; build it at the tag pinned
|
||||||
# in bos-settings/Cargo.toml so the CLI matches the library version.
|
# in bos-settings' Cargo.toml so the CLI matches the library version
|
||||||
THEME_TAG=$(grep 'bread-theme.*tag' /bos/bos-settings/Cargo.toml \
|
# the bos-settings package (and breadbar/breadbox/breadpad) were
|
||||||
|
# built against. bos-settings used to live at bos-settings/Cargo.toml
|
||||||
|
# inside this repo; it's since been split into its own repo
|
||||||
|
# (git.breadway.dev/Breadway/bos-settings), so fetch its Cargo.toml
|
||||||
|
# from there instead of a path that no longer exists in this
|
||||||
|
# checkout. Uses bos-settings' default branch (dev) — the branch its
|
||||||
|
# own CI actually publishes the `bos-settings` pacman package from.
|
||||||
|
REPO_OWNER="${GITHUB_REPOSITORY%%/*}"
|
||||||
|
curl -fsSL "https://git.breadway.dev/${REPO_OWNER}/bos-settings/raw/branch/dev/Cargo.toml" \
|
||||||
|
-o /tmp/bos-settings-Cargo.toml
|
||||||
|
THEME_TAG=$(grep 'bread-theme.*tag' /tmp/bos-settings-Cargo.toml \
|
||||||
| grep -oP '"v[^"]+"' | tr -d '"')
|
| grep -oP '"v[^"]+"' | tr -d '"')
|
||||||
echo "Building bread-theme @ $THEME_TAG"
|
echo "Building bread-theme @ $THEME_TAG"
|
||||||
git clone --branch "$THEME_TAG" --depth 1 \
|
git clone --branch "$THEME_TAG" --depth 1 \
|
||||||
|
|
|
||||||
1039
Cargo.lock
generated
1039
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +0,0 @@
|
||||||
[workspace]
|
|
||||||
members = ["bos-settings"]
|
|
||||||
resolver = "2"
|
|
||||||
77
README.md
77
README.md
|
|
@ -1,7 +1,7 @@
|
||||||
# BOS — Bread Operating System
|
# BOS — Bread Operating System
|
||||||
|
|
||||||
An Arch-based, Hyprland desktop distribution that ships the [bread
|
An Arch-based, Hyprland desktop distribution that ships the [bread
|
||||||
ecosystem](https://github.com/Breadway) preconfigured. One Calamares install
|
ecosystem](https://git.breadway.dev/Breadway) preconfigured. One Calamares install
|
||||||
produces a themed, bootable Wayland desktop — no manual Arch bootstrap, no
|
produces a themed, bootable Wayland desktop — no manual Arch bootstrap, no
|
||||||
wiring up dotfiles, no per-tool bakery installs.
|
wiring up dotfiles, no per-tool bakery installs.
|
||||||
|
|
||||||
|
|
@ -14,9 +14,15 @@ wiring up dotfiles, no per-tool bakery installs.
|
||||||
- **Compositor**: Hyprland with a native-Lua config (`hyprland.lua`), curated
|
- **Compositor**: Hyprland with a native-Lua config (`hyprland.lua`), curated
|
||||||
keybinds, snappy animations, blur, and pywal-driven colours on a black base.
|
keybinds, snappy animations, blur, and pywal-driven colours on a black base.
|
||||||
- **bread ecosystem**, baked into `/etc/skel` from bakery-managed binaries
|
- **bread ecosystem**, baked into `/etc/skel` from bakery-managed binaries
|
||||||
(no network needed at install time): `bread`/`breadd`, `breadbar` (status bar
|
(no network needed at install time): the `bread`/`breadd` automation daemon,
|
||||||
+ notification daemon), `breadbox` (launcher), `breadcrumbs` (Wi-Fi profiles),
|
`breadbar` (status bar + notifications), `breadbox` (launcher), `breadclip`
|
||||||
`breadpad` (notes/reminders), `breadman`, and the `bakery` package manager.
|
(clipboard history), `breadcrumbs` (Wi-Fi profiles), `breadpad`/`breadman`
|
||||||
|
(notes), `breadpaper` (wallpaper + theme), `breadsearch` (system search),
|
||||||
|
`breadmon` (monitor layout TUI), `breadshot` (screenshots), `bread-theme`
|
||||||
|
(the shared palette engine), and the `bakery` package manager. `breadlock`
|
||||||
|
(lock screen + greeter) ships as its own pacman package alongside
|
||||||
|
`bos-settings`, not through bakery. See [below](#the-bread-ecosystem) for
|
||||||
|
what each one actually does.
|
||||||
- **bos-settings**: a GTK4 control panel that configures every bread\* app's
|
- **bos-settings**: a GTK4 control panel that configures every bread\* app's
|
||||||
config from a GUI (non-destructively), plus snapshot rollback and bakery
|
config from a GUI (non-destructively), plus snapshot rollback and bakery
|
||||||
updates. See below.
|
updates. See below.
|
||||||
|
|
@ -162,18 +168,49 @@ cargo build --release -p bos-settings
|
||||||
cargo test -p bos-settings # includes config round-trip tests
|
cargo test -p bos-settings # includes config round-trip tests
|
||||||
```
|
```
|
||||||
|
|
||||||
## The bread ecosystem at a glance
|
## The bread ecosystem
|
||||||
|
|
||||||
|
Everything below is a separate bakery-distributed project with its own repo
|
||||||
|
and release cadence, baked into `/etc/skel` at ISO build time so a fresh
|
||||||
|
install has them all with no network round-trip. Some ship more than one
|
||||||
|
binary from a single package — that's noted where it applies. Most have a
|
||||||
|
corresponding **bos-settings** panel for configuration; this table is about
|
||||||
|
*using* the app directly.
|
||||||
|
|
||||||
|
**Desktop shell**
|
||||||
|
|
||||||
| Tool | Role | Launch |
|
| Tool | Role | Launch |
|
||||||
|------|------|--------|
|
|------|------|--------|
|
||||||
| `bread` / `breadd` | Reactive automation daemon — normalises hardware/compositor signals into events dispatched to Lua modules | runs at login |
|
| `bread` / `breadd` | Reactive automation daemon — normalises hardware/compositor/power/network signals into events dispatched to Lua modules (`~/.config/bread/`). Everything else in the ecosystem can subscribe to its events. | runs at login (`breadd.service`) |
|
||||||
| `breadbar` | Top status bar (workspaces, clock, stats, tray) **and** the notification daemon | runs at login |
|
| `breadbar` | Top status bar: workspaces, clock, system stats, tray, **and** the notification daemon — one process, not two | runs at login |
|
||||||
| `breadbox` | Application launcher | `SUPER+Space` |
|
| `breadbox` | Application launcher (fuzzy search, per-context results via `breadbox-sync`) | `SUPER+Space` |
|
||||||
| `breadpad` | Notes & reminders (AI-classified, optional CalDAV sync) | `SUPER+U` |
|
| `breadlock` | Idle lock screen. Also provides `breadgreet`, the login greeter hosted under `cage` via greetd — same project, two binaries, one visual identity from login to lock | `SUPER+L` (via `loginctl lock-session`, picked up by `hypridle`); `breadgreet` runs automatically at boot |
|
||||||
| `breadman` | Package-manager UI | `SUPER+M` |
|
| `bread-theme` | The shared palette engine every bread app renders through: fixed dark base colors, with only the accent slots following the current wallpaper's pywal palette. `bread-theme generate` regenerates the stylesheet; hyprland.lua calls it automatically on wallpaper change. | invoked automatically, rarely run by hand |
|
||||||
| `breadcrumbs` | Wi-Fi profile state machine (location-aware) | CLI / BOS Settings |
|
|
||||||
| `bakery` | CLI package manager for the ecosystem | `bakery` |
|
**Productivity**
|
||||||
| `bos-settings` | Unified GTK4 control panel for all of the above + snapshots + updates | `SUPER+,` |
|
|
||||||
|
| Tool | Role | Launch |
|
||||||
|
|------|------|--------|
|
||||||
|
| `breadpad` | Quick-capture scratchpad/notes popup with AI classification and optional CalDAV calendar sync | `SUPER+U` |
|
||||||
|
| `breadman` | The fuller notes manager view (browse/organize) — ships from the same `breadpad` package as a second binary | `SUPER+M` |
|
||||||
|
| `breadclip` | Clipboard history. `breadclipd` is the background daemon that actually records history; `breadclip` is the GTK4 popup that browses it | `SUPER+V` / `SUPER+Shift+V` |
|
||||||
|
| `breadsearch` | Semantic system-wide search (indexes files/notes, embeds locally — CPU/ROCm/CUDA backend configurable). `breadmill` is its indexing daemon. | via breadbox, or BOS Settings → File Search |
|
||||||
|
|
||||||
|
**System**
|
||||||
|
|
||||||
|
| Tool | Role | Launch |
|
||||||
|
|------|------|--------|
|
||||||
|
| `breadcrumbs` | Location-aware Wi-Fi profile state machine, with optional Tailscale integration — switches network behavior based on which saved network you're on | CLI, or BOS Settings → Wi-Fi Profiles |
|
||||||
|
| `breadpaper` | Wallpaper manager — sets the wallpaper via `awww`, generates the pywal accent palette from it, and reloads every bread-theme app | BOS Settings → Wallpaper |
|
||||||
|
| `breadmon` | TUI monitor layout manager (resolution/position/scaling) — the interactive counterpart to BOS Settings' read-only Display panel | `breadmon` in a terminal |
|
||||||
|
| `breadshot` | Screenshot utility wrapping `grim`/`slurp`/`wl-copy` with Hyprland-aware geometry (multi-monitor-safe region select) | `breadshot`, or `SUPER+Shift+S/C/P` |
|
||||||
|
|
||||||
|
**Tooling**
|
||||||
|
|
||||||
|
| Tool | Role | Launch |
|
||||||
|
|------|------|--------|
|
||||||
|
| `bakery` | CLI package manager for the whole ecosystem — install/update/list, tracks installed binaries + versions independently of pacman | `bakery` |
|
||||||
|
| `bos-settings` | Unified GTK4 control panel: live system state + control (network, power, firewall, users, packages, firmware, AUR, snapshots) plus non-destructive config editing for every app above | `SUPER+,` |
|
||||||
|
|
||||||
## Keyboard shortcuts
|
## Keyboard shortcuts
|
||||||
|
|
||||||
|
|
@ -185,12 +222,16 @@ cheatsheet in-session; first boot shows a short welcome (once).
|
||||||
| `SUPER+Return` | Terminal (kitty) |
|
| `SUPER+Return` | Terminal (kitty) |
|
||||||
| `SUPER+Space` | App launcher (breadbox) |
|
| `SUPER+Space` | App launcher (breadbox) |
|
||||||
| `SUPER+E` / `SUPER+B` | Files (nautilus) / Browser (zen) |
|
| `SUPER+E` / `SUPER+B` | Files (nautilus) / Browser (zen) |
|
||||||
| `SUPER+U` / `SUPER+M` | breadpad / breadman |
|
| `SUPER+U` / `SUPER+M` | Notes (breadpad) / notes manager (breadman) |
|
||||||
| `SUPER+,` / `SUPER+/` | BOS Settings / keybind cheatsheet |
|
| `SUPER+,` / `SUPER+/` | BOS Settings / keybind cheatsheet |
|
||||||
| `SUPER+L` / `SUPER+N` | Lock / log out |
|
| `SUPER+L` / `SUPER+N` | Lock / log out |
|
||||||
| `SUPER+Backspace` | Close window |
|
| `SUPER+Backspace` | Close window |
|
||||||
| `SUPER+F` / `SUPER+V` / `SUPER+T` | Fullscreen / float / toggle split |
|
| `SUPER+F` | Fullscreen |
|
||||||
| `SUPER+Shift+V` | Clipboard history |
|
| `SUPER+I` | Toggle floating |
|
||||||
|
| `SUPER+P` | Toggle pseudotile |
|
||||||
|
| `SUPER+R` | Resize mode |
|
||||||
|
| `SUPER+T` | Toggle split direction |
|
||||||
|
| `SUPER+V` / `SUPER+Shift+V` | Clipboard history (breadclip) |
|
||||||
| `SUPER+Tab` | Last window |
|
| `SUPER+Tab` | Last window |
|
||||||
| `SUPER+Shift+S/C/P` | Screenshot region→file / region→clipboard / screen→file |
|
| `SUPER+Shift+S/C/P` | Screenshot region→file / region→clipboard / screen→file |
|
||||||
| `SUPER+arrows` | Move focus |
|
| `SUPER+arrows` | Move focus |
|
||||||
|
|
@ -199,7 +240,11 @@ cheatsheet in-session; first boot shows a short welcome (once).
|
||||||
| `SUPER+1..0` | Switch to workspace 1–10 |
|
| `SUPER+1..0` | Switch to workspace 1–10 |
|
||||||
| `SUPER+Shift+1..0` | Move window to workspace |
|
| `SUPER+Shift+1..0` | Move window to workspace |
|
||||||
| `SUPER+[ / ]` | Previous / next workspace |
|
| `SUPER+[ / ]` | Previous / next workspace |
|
||||||
|
| `SUPER+Shift+[ / ]` | Move window to previous / next workspace |
|
||||||
|
| `SUPER+scroll` | Cycle workspaces |
|
||||||
| `SUPER+left/right-drag` | Move / resize window with the mouse |
|
| `SUPER+left/right-drag` | Move / resize window with the mouse |
|
||||||
|
| Volume / brightness / play-pause / next / prev | Media keys — work even on the lock screen |
|
||||||
|
| Calculator key | Opens gnome-calculator |
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
|
|
|
||||||
12
bakery.toml
12
bakery.toml
|
|
@ -1,12 +0,0 @@
|
||||||
name = "bos-settings"
|
|
||||||
description = "System settings app for Bread OS"
|
|
||||||
binaries = ["bos-settings"]
|
|
||||||
system_deps = ["gtk4", "glib2"]
|
|
||||||
optional_system_deps = ["snapper"]
|
|
||||||
bread_deps = []
|
|
||||||
|
|
||||||
[config]
|
|
||||||
dir = "~/.config"
|
|
||||||
|
|
||||||
[install]
|
|
||||||
post_install = []
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "bos-settings"
|
|
||||||
version = "0.6.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
|
||||||
glib = "0.22"
|
|
||||||
# Shared ecosystem theming — bos-settings loads the same generated stylesheet as
|
|
||||||
# breadbar/breadbox/breadpad so the whole desktop looks consistent.
|
|
||||||
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] }
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
toml = "0.8"
|
|
||||||
# toml_edit drives non-destructive config editing: it preserves comments and
|
|
||||||
# any keys the UI doesn't model, so saving a single field never rewrites or
|
|
||||||
# drops the rest of the user's config file.
|
|
||||||
toml_edit = "0.22"
|
|
||||||
async-channel = "2"
|
|
||||||
|
|
@ -1,213 +0,0 @@
|
||||||
//! Non-destructive config editing.
|
|
||||||
//!
|
|
||||||
//! Every bread* app owns a TOML config that may contain keys, sections, and
|
|
||||||
//! comments this settings app does not model (e.g. breadpad's calendar
|
|
||||||
//! credentials, breadcrumbs' saved-network passwords). To edit safely we parse
|
|
||||||
//! the file into a `toml_edit::DocumentMut`, mutate only the specific keys the
|
|
||||||
//! UI exposes, and write the document back — preserving everything else,
|
|
||||||
//! formatting and comments included.
|
|
||||||
|
|
||||||
use std::error::Error;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use toml_edit::{value, Array, DocumentMut, Item, Table, Value};
|
|
||||||
|
|
||||||
/// Load a TOML file into an editable document. A missing file yields an
|
|
||||||
/// empty document so the UI still renders with defaults — normal for a fresh
|
|
||||||
/// install. A file that *exists* but fails to parse is far more dangerous:
|
|
||||||
/// falling back to an empty document there means the next Save (see
|
|
||||||
/// `save_doc`) overwrites it with only the UI-modelled keys, silently
|
|
||||||
/// destroying anything else in the file (breadpad's calendar credentials,
|
|
||||||
/// breadcrumbs' saved network passwords, ...). Back up the unparseable file
|
|
||||||
/// once before falling back, so a bad edit is always recoverable.
|
|
||||||
pub fn load_doc(path: &Path) -> DocumentMut {
|
|
||||||
let Ok(text) = std::fs::read_to_string(path) else {
|
|
||||||
return DocumentMut::default();
|
|
||||||
};
|
|
||||||
match text.parse::<DocumentMut>() {
|
|
||||||
Ok(doc) => doc,
|
|
||||||
Err(e) => {
|
|
||||||
let backup = PathBuf::from(format!("{}.bak", path.display()));
|
|
||||||
eprintln!(
|
|
||||||
"bos-settings: {} failed to parse ({e}); backed up to {} before falling back to defaults",
|
|
||||||
path.display(),
|
|
||||||
backup.display()
|
|
||||||
);
|
|
||||||
let _ = std::fs::write(&backup, &text);
|
|
||||||
DocumentMut::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write the document back to disk, creating parent dirs as needed.
|
|
||||||
pub fn save_doc(path: &Path, doc: &DocumentMut) -> Result<(), Box<dyn Error>> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
std::fs::write(path, doc.to_string())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn config_dir() -> PathBuf {
|
|
||||||
// Honour XDG_CONFIG_HOME if set; otherwise fall back to $HOME/.config.
|
|
||||||
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
|
|
||||||
let p = PathBuf::from(xdg);
|
|
||||||
if p.is_absolute() {
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
|
|
||||||
PathBuf::from(home).join(".config")
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- typed readers (walk a dotted path, return None if absent/wrong type) ---
|
|
||||||
|
|
||||||
fn get<'a>(doc: &'a DocumentMut, path: &[&str]) -> Option<&'a Item> {
|
|
||||||
let mut tbl = doc.as_table();
|
|
||||||
let (last, parents) = path.split_last()?;
|
|
||||||
for key in parents {
|
|
||||||
tbl = tbl.get(key)?.as_table()?;
|
|
||||||
}
|
|
||||||
tbl.get(last)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_bool(doc: &DocumentMut, path: &[&str]) -> Option<bool> {
|
|
||||||
get(doc, path)?.as_bool()
|
|
||||||
}
|
|
||||||
pub fn get_str(doc: &DocumentMut, path: &[&str]) -> Option<String> {
|
|
||||||
get(doc, path)?.as_str().map(str::to_string)
|
|
||||||
}
|
|
||||||
pub fn get_i64(doc: &DocumentMut, path: &[&str]) -> Option<i64> {
|
|
||||||
get(doc, path)?.as_integer()
|
|
||||||
}
|
|
||||||
pub fn get_f64(doc: &DocumentMut, path: &[&str]) -> Option<f64> {
|
|
||||||
let item = get(doc, path)?;
|
|
||||||
item.as_float().or_else(|| item.as_integer().map(|i| i as f64))
|
|
||||||
}
|
|
||||||
/// Read an array of strings (e.g. modules.disable, contexts[].priority).
|
|
||||||
pub fn get_str_list(doc: &DocumentMut, path: &[&str]) -> Vec<String> {
|
|
||||||
match get(doc, path).and_then(Item::as_array) {
|
|
||||||
Some(arr) => arr
|
|
||||||
.iter()
|
|
||||||
.filter_map(|v| v.as_str().map(str::to_string))
|
|
||||||
.collect(),
|
|
||||||
None => Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- setters (auto-create intermediate tables, replace only the leaf) ---
|
|
||||||
|
|
||||||
fn table_at_mut<'a>(doc: &'a mut DocumentMut, parents: &[&str]) -> &'a mut Table {
|
|
||||||
let mut tbl = doc.as_table_mut();
|
|
||||||
for key in parents {
|
|
||||||
let entry = tbl.entry(key).or_insert_with(|| Item::Table(Table::new()));
|
|
||||||
if !entry.is_table() {
|
|
||||||
*entry = Item::Table(Table::new());
|
|
||||||
}
|
|
||||||
tbl = entry.as_table_mut().expect("just ensured table");
|
|
||||||
}
|
|
||||||
tbl
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_item(doc: &mut DocumentMut, path: &[&str], item: Item) {
|
|
||||||
let Some((last, parents)) = path.split_last() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
table_at_mut(doc, parents).insert(last, item);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_bool(doc: &mut DocumentMut, path: &[&str], v: bool) {
|
|
||||||
set_item(doc, path, value(v));
|
|
||||||
}
|
|
||||||
pub fn set_str(doc: &mut DocumentMut, path: &[&str], v: &str) {
|
|
||||||
set_item(doc, path, value(v));
|
|
||||||
}
|
|
||||||
pub fn set_i64(doc: &mut DocumentMut, path: &[&str], v: i64) {
|
|
||||||
set_item(doc, path, value(v));
|
|
||||||
}
|
|
||||||
pub fn set_f64(doc: &mut DocumentMut, path: &[&str], v: f64) {
|
|
||||||
set_item(doc, path, value(v));
|
|
||||||
}
|
|
||||||
pub fn set_str_list(doc: &mut DocumentMut, path: &[&str], items: &[String]) {
|
|
||||||
let mut arr = Array::new();
|
|
||||||
for s in items {
|
|
||||||
arr.push(s.as_str());
|
|
||||||
}
|
|
||||||
set_item(doc, path, Item::Value(Value::Array(arr)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set a string key, or remove it entirely when the value is empty — keeps
|
|
||||||
/// optional fields out of the file rather than persisting `key = ""`.
|
|
||||||
pub fn set_str_or_remove(doc: &mut DocumentMut, path: &[&str], v: &str) {
|
|
||||||
if v.is_empty() {
|
|
||||||
remove(doc, path);
|
|
||||||
} else {
|
|
||||||
set_str(doc, path, v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove(doc: &mut DocumentMut, path: &[&str]) {
|
|
||||||
if let Some((last, parents)) = path.split_last() {
|
|
||||||
let mut tbl = doc.as_table_mut();
|
|
||||||
for key in parents {
|
|
||||||
match tbl.get_mut(key).and_then(Item::as_table_mut) {
|
|
||||||
Some(t) => tbl = t,
|
|
||||||
None => return,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tbl.remove(last);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn edits_preserve_unmodelled_keys_and_comments() {
|
|
||||||
let src = "\
|
|
||||||
# a leading comment
|
|
||||||
[daemon]
|
|
||||||
log_level = \"info\"
|
|
||||||
|
|
||||||
[calendar]
|
|
||||||
password = \"secret\" # keep me
|
|
||||||
";
|
|
||||||
let mut doc: DocumentMut = src.parse().unwrap();
|
|
||||||
// Modify a single modelled key.
|
|
||||||
set_str(&mut doc, &["daemon", "log_level"], "debug");
|
|
||||||
// A key/section the UI never touches must survive untouched.
|
|
||||||
let out = doc.to_string();
|
|
||||||
assert!(out.contains("log_level = \"debug\""));
|
|
||||||
assert!(out.contains("password = \"secret\""));
|
|
||||||
assert!(out.contains("# keep me"));
|
|
||||||
assert!(out.contains("# a leading comment"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn setters_create_missing_tables() {
|
|
||||||
let mut doc = DocumentMut::new();
|
|
||||||
set_bool(&mut doc, &["adapters", "power", "enabled"], false);
|
|
||||||
set_i64(&mut doc, &["adapters", "power", "poll_interval_secs"], 45);
|
|
||||||
assert_eq!(get_bool(&doc, &["adapters", "power", "enabled"]), Some(false));
|
|
||||||
assert_eq!(
|
|
||||||
get_i64(&doc, &["adapters", "power", "poll_interval_secs"]),
|
|
||||||
Some(45)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_string_removes_key() {
|
|
||||||
let mut doc: DocumentMut = "[calendar]\nurl = \"x\"\n".parse().unwrap();
|
|
||||||
set_str_or_remove(&mut doc, &["calendar", "url"], "");
|
|
||||||
assert_eq!(get_str(&doc, &["calendar", "url"]), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn str_list_roundtrips() {
|
|
||||||
let mut doc = DocumentMut::new();
|
|
||||||
let items = vec!["a".to_string(), "b".to_string()];
|
|
||||||
set_str_list(&mut doc, &["modules", "disable"], &items);
|
|
||||||
assert_eq!(get_str_list(&doc, &["modules", "disable"]), items);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
mod config;
|
|
||||||
mod theme;
|
|
||||||
mod ui;
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let app = gtk4::Application::builder()
|
|
||||||
.application_id("com.breadway.bos-settings")
|
|
||||||
.build();
|
|
||||||
app.connect_activate(ui::window::build_ui);
|
|
||||||
app.run();
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
//! Theming for bos-settings.
|
|
||||||
//!
|
|
||||||
//! bos-settings deliberately owns almost no styling: it loads the ecosystem's
|
|
||||||
//! shared stylesheet (the same one breadbar/breadbox/breadpad use, generated by
|
|
||||||
//! `bread-theme` from the pywal palette) and adds only the few layout rules
|
|
||||||
//! specific to this app's sidebar + content shell. This keeps it visually
|
|
||||||
//! identical to the rest of the bread desktop and live-recolouring for free.
|
|
||||||
|
|
||||||
use gtk4::CssProvider;
|
|
||||||
use std::cell::RefCell;
|
|
||||||
|
|
||||||
// App-specific layout only — everything visual (colours, buttons, entries,
|
|
||||||
// switches, sidebar/row styling, cards, scrollbars) comes from the shared sheet.
|
|
||||||
const APP_CSS: &str = "\
|
|
||||||
.view-content { padding: 24px; }\n\
|
|
||||||
.view-content > label.title { margin-bottom: 16px; }\n\
|
|
||||||
/* 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! {
|
|
||||||
static APP_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load(_display: >k4::gdk::Display) {
|
|
||||||
// Shared ecosystem stylesheet (loads the generated file or a rendered
|
|
||||||
// fallback, and live-reloads when the palette changes).
|
|
||||||
bread_theme::gtk::apply_shared();
|
|
||||||
|
|
||||||
// bos-settings layout, layered on top at APPLICATION priority.
|
|
||||||
APP_PROVIDER.with(|cell| bread_theme::gtk::apply_css(APP_CSS, cell));
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
pub mod sidebar;
|
|
||||||
pub mod views;
|
|
||||||
pub mod widgets;
|
|
||||||
pub mod window;
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
use gtk4::prelude::*;
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
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] = &[
|
|
||||||
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 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(210);
|
|
||||||
|
|
||||||
let list = ListBox::new();
|
|
||||||
list.set_selection_mode(gtk4::SelectionMode::Single);
|
|
||||||
list.add_css_class("sidebar");
|
|
||||||
|
|
||||||
append_section(&list, "System", SYSTEM_ITEMS);
|
|
||||||
append_section(&list, "Personalization", PERSONALIZATION_ITEMS);
|
|
||||||
append_section(&list, "Maintenance", MAINTENANCE_ITEMS);
|
|
||||||
append_section(&list, None, ABOUT_ITEMS);
|
|
||||||
|
|
||||||
let mut i = 0;
|
|
||||||
loop {
|
|
||||||
match list.row_at_index(i) {
|
|
||||||
None => break,
|
|
||||||
Some(row) if row.widget_name() == default_id => {
|
|
||||||
list.select_row(Some(&row));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
_ => i += 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
vbox.append(&list);
|
|
||||||
(vbox, list)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn append_section(list: &ListBox, title: impl Into<Option<&'static str>>, items: &[SidebarItem]) {
|
|
||||||
if let Some(title) = title.into() {
|
|
||||||
let header_row = ListBoxRow::new();
|
|
||||||
header_row.set_selectable(false);
|
|
||||||
header_row.set_activatable(false);
|
|
||||||
let header_lbl = Label::new(Some(title));
|
|
||||||
header_lbl.add_css_class("section-header");
|
|
||||||
header_lbl.set_xalign(0.0);
|
|
||||||
header_row.set_child(Some(&header_lbl));
|
|
||||||
list.append(&header_row);
|
|
||||||
}
|
|
||||||
|
|
||||||
for entry in items {
|
|
||||||
let row = ListBoxRow::new();
|
|
||||||
row.set_widget_name(entry.id);
|
|
||||||
|
|
||||||
let hbox = GBox::new(Orientation::Horizontal, 10);
|
|
||||||
hbox.set_margin_top(4);
|
|
||||||
hbox.set_margin_bottom(4);
|
|
||||||
|
|
||||||
let icon = Image::from_icon_name(entry.icon);
|
|
||||||
icon.set_pixel_size(16);
|
|
||||||
hbox.append(&icon);
|
|
||||||
|
|
||||||
let labels = GBox::new(Orientation::Vertical, 0);
|
|
||||||
let lbl = Label::new(Some(entry.label));
|
|
||||||
lbl.set_xalign(0.0);
|
|
||||||
labels.append(&lbl);
|
|
||||||
if let Some(sub) = entry.sublabel {
|
|
||||||
let sub_lbl = Label::new(Some(sub));
|
|
||||||
sub_lbl.add_css_class("dim-label");
|
|
||||||
sub_lbl.set_xalign(0.0);
|
|
||||||
// Match the sidebar's smaller "section-header" scale rather than
|
|
||||||
// the shared sheet's default dim-label size, so it reads as a
|
|
||||||
// caption under the row label, not a second full-size label.
|
|
||||||
sub_lbl.add_css_class("caption");
|
|
||||||
labels.append(&sub_lbl);
|
|
||||||
}
|
|
||||||
hbox.append(&labels);
|
|
||||||
|
|
||||||
row.set_child(Some(&hbox));
|
|
||||||
list.append(&row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
//! Read-only system info, plus the one thing worth making writable: hostname.
|
|
||||||
//! BOS is a rolling release (no fixed version number to show — `os-release`
|
|
||||||
//! ships `BUILD_ID=rolling` on purpose), so there's no "BOS 1.2.3" readout
|
|
||||||
//! here the way a point-release distro's About panel would have one.
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{Box as GBox, Button, Entry, Label, Orientation};
|
|
||||||
use std::fs;
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
use crate::ui::widgets as w;
|
|
||||||
|
|
||||||
fn os_pretty_name() -> String {
|
|
||||||
fs::read_to_string("/etc/os-release")
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| {
|
|
||||||
s.lines()
|
|
||||||
.find_map(|l| l.strip_prefix("PRETTY_NAME=").map(|v| v.trim_matches('"').to_string()))
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "BOS".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hostname() -> String {
|
|
||||||
fs::read_to_string("/etc/hostname")
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.unwrap_or_else(|_| "unknown".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn kernel() -> String {
|
|
||||||
Command::new("uname")
|
|
||||||
.arg("-r")
|
|
||||||
.output()
|
|
||||||
.ok()
|
|
||||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
|
||||||
.unwrap_or_else(|| "unknown".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cpu() -> String {
|
|
||||||
let model = fs::read_to_string("/proc/cpuinfo")
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| {
|
|
||||||
s.lines()
|
|
||||||
.find_map(|l| l.strip_prefix("model name").map(|v| v.trim_start_matches([':', ' ', '\t']).to_string()))
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
|
||||||
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
|
|
||||||
if cores > 0 {
|
|
||||||
format!("{model} ({cores} threads)")
|
|
||||||
} else {
|
|
||||||
model
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn memory() -> String {
|
|
||||||
let kb = fs::read_to_string("/proc/meminfo")
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| {
|
|
||||||
s.lines()
|
|
||||||
.find(|l| l.starts_with("MemTotal:"))
|
|
||||||
.and_then(|l| l.split_whitespace().nth(1))
|
|
||||||
.and_then(|v| v.parse::<u64>().ok())
|
|
||||||
});
|
|
||||||
match kb {
|
|
||||||
Some(kb) => format!("{:.1} GiB", kb as f64 / 1024.0 / 1024.0),
|
|
||||||
None => "unknown".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn gpu() -> String {
|
|
||||||
let Ok(output) = Command::new("lspci").output() else {
|
|
||||||
return "unknown".to_string();
|
|
||||||
};
|
|
||||||
let text = String::from_utf8_lossy(&output.stdout);
|
|
||||||
text.lines()
|
|
||||||
// "Display controller" covers integrated GPUs some laptop chipsets
|
|
||||||
// (this dev laptop's AMD Radeon 860M included) report under instead
|
|
||||||
// of "VGA compatible controller" — without it those show "unknown".
|
|
||||||
.find(|l| {
|
|
||||||
l.contains("VGA compatible controller")
|
|
||||||
|| l.contains("3D controller")
|
|
||||||
|| l.contains("Display controller")
|
|
||||||
})
|
|
||||||
.and_then(|l| l.split(": ").nth(1))
|
|
||||||
.unwrap_or("unknown")
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn disk_usage() -> String {
|
|
||||||
let Ok(output) = Command::new("df").args(["-h", "--output=used,size,pcent", "/"]).output() else {
|
|
||||||
return "unknown".to_string();
|
|
||||||
};
|
|
||||||
let text = String::from_utf8_lossy(&output.stdout);
|
|
||||||
text.lines()
|
|
||||||
.nth(1)
|
|
||||||
.map(|l| {
|
|
||||||
let cols: Vec<&str> = l.split_whitespace().collect();
|
|
||||||
match cols.as_slice() {
|
|
||||||
[used, size, pcent] => format!("{used} of {size} used ({pcent})"),
|
|
||||||
_ => l.trim().to_string(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "unknown".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uptime() -> String {
|
|
||||||
Command::new("uptime")
|
|
||||||
.arg("-p")
|
|
||||||
.output()
|
|
||||||
.ok()
|
|
||||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
|
||||||
.unwrap_or_else(|| "unknown".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
|
||||||
let (outer, content) = w::view_scaffold("About");
|
|
||||||
|
|
||||||
content.append(&w::info_row("Operating system", &os_pretty_name()));
|
|
||||||
content.append(&w::info_row("Kernel", &kernel()));
|
|
||||||
content.append(&w::info_row("CPU", &cpu()));
|
|
||||||
content.append(&w::info_row("GPU", &gpu()));
|
|
||||||
content.append(&w::info_row("Memory", &memory()));
|
|
||||||
content.append(&w::info_row("Disk (/)", &disk_usage()));
|
|
||||||
content.append(&w::info_row("Uptime", &uptime()));
|
|
||||||
|
|
||||||
content.append(&w::section("Hostname"));
|
|
||||||
content.append(&w::hint(
|
|
||||||
"Changes the machine's network name. Takes effect immediately; \
|
|
||||||
needs your password (polkit).",
|
|
||||||
));
|
|
||||||
|
|
||||||
let hn_row = GBox::new(Orientation::Horizontal, 12);
|
|
||||||
let entry = Entry::new();
|
|
||||||
entry.set_text(&hostname());
|
|
||||||
entry.set_hexpand(true);
|
|
||||||
let apply_btn = Button::with_label("Apply");
|
|
||||||
let status = Label::new(None);
|
|
||||||
status.add_css_class("dim-label");
|
|
||||||
|
|
||||||
{
|
|
||||||
let entry = entry.clone();
|
|
||||||
let status = status.clone();
|
|
||||||
apply_btn.connect_clicked(move |_| {
|
|
||||||
let name = entry.text().to_string();
|
|
||||||
if name.trim().is_empty() {
|
|
||||||
status.set_text("Hostname can't be empty");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let log_buf = gtk4::TextBuffer::new(None);
|
|
||||||
let status2 = status.clone();
|
|
||||||
status.set_text("Applying…");
|
|
||||||
w::stream_command_then(
|
|
||||||
&["pkexec", "hostnamectl", "set-hostname", name.trim()],
|
|
||||||
log_buf.clone(),
|
|
||||||
move || {
|
|
||||||
let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false);
|
|
||||||
if text.trim().is_empty() {
|
|
||||||
status2.set_text("Applied");
|
|
||||||
} else {
|
|
||||||
status2.set_text(&format!("Error: {}", text.trim()));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
hn_row.append(&entry);
|
|
||||||
hn_row.append(&apply_btn);
|
|
||||||
content.append(&hn_row);
|
|
||||||
content.append(&status);
|
|
||||||
|
|
||||||
outer
|
|
||||||
}
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
//! AUR search via yay — graphical discovery for the wider AUR beyond
|
|
||||||
//! bakery's bread ecosystem and [breadway]'s own republished packages.
|
|
||||||
//!
|
|
||||||
//! Search and browsing are fully graphical; actually installing a package
|
|
||||||
//! opens a terminal running `yay -S <pkg>` instead of a silent `--noconfirm`
|
|
||||||
//! install. That's deliberate, not a shortcut we didn't get around to:
|
|
||||||
//! AUR packages run arbitrary maintainer-supplied build scripts, and yay's
|
|
||||||
//! interactive PKGBUILD diff review (plus the sudo password prompt) is the
|
|
||||||
//! actual safety mechanism against a malicious/compromised AUR package —
|
|
||||||
//! automating it away in the name of "no terminal" would remove the one
|
|
||||||
//! step that exists to catch that.
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow};
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
use crate::ui::widgets as w;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct AurResult {
|
|
||||||
name: String,
|
|
||||||
version: String,
|
|
||||||
description: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn search(query: &str) -> Vec<AurResult> {
|
|
||||||
let Ok(output) = Command::new("yay").args(["-Ss", "--aur", query]).output() else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
let text = String::from_utf8_lossy(&output.stdout);
|
|
||||||
let mut results = Vec::new();
|
|
||||||
let mut lines = text.lines().peekable();
|
|
||||||
while let Some(header) = lines.next() {
|
|
||||||
// "aur/name version (+votes score) [Orphaned]" — name/version are
|
|
||||||
// always the first two whitespace-separated fields after the repo/.
|
|
||||||
let Some(rest) = header.strip_prefix("aur/") else { continue };
|
|
||||||
let mut parts = rest.split_whitespace();
|
|
||||||
let Some(name) = parts.next() else { continue };
|
|
||||||
let version = parts.next().unwrap_or("").to_string();
|
|
||||||
let description = lines.next().unwrap_or("").trim().to_string();
|
|
||||||
results.push(AurResult { name: name.to_string(), version, description });
|
|
||||||
}
|
|
||||||
results
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_in_terminal(pkg: &str) {
|
|
||||||
let _ = Command::new("kitty").args(["-e", "yay", "-S", pkg]).spawn();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
|
||||||
let (outer, content) = w::view_scaffold("AUR");
|
|
||||||
content.append(&w::hint(
|
|
||||||
"Search the Arch User Repository via yay. Installing opens a \
|
|
||||||
terminal — AUR packages run arbitrary build scripts, and reviewing \
|
|
||||||
what yay is about to do (and entering your password) is a real \
|
|
||||||
safety step, not just a formality.",
|
|
||||||
));
|
|
||||||
|
|
||||||
let search_row = GBox::new(Orientation::Horizontal, 8);
|
|
||||||
let search_entry = Entry::new();
|
|
||||||
search_entry.set_hexpand(true);
|
|
||||||
search_entry.set_placeholder_text(Some("Search the AUR…"));
|
|
||||||
let search_btn = Button::with_label("Search");
|
|
||||||
search_btn.add_css_class("suggested-action");
|
|
||||||
search_row.append(&search_entry);
|
|
||||||
search_row.append(&search_btn);
|
|
||||||
content.append(&search_row);
|
|
||||||
|
|
||||||
let status = w::hint("Search for a package to see results here.");
|
|
||||||
content.append(&status);
|
|
||||||
|
|
||||||
let list = ListBox::new();
|
|
||||||
list.set_selection_mode(gtk4::SelectionMode::None);
|
|
||||||
let scroll = ScrolledWindow::new();
|
|
||||||
scroll.set_vexpand(true);
|
|
||||||
scroll.set_min_content_height(320);
|
|
||||||
scroll.set_child(Some(&list));
|
|
||||||
content.append(&scroll);
|
|
||||||
|
|
||||||
let run_search = {
|
|
||||||
let list = list.clone();
|
|
||||||
let status = status.clone();
|
|
||||||
let search_entry = search_entry.clone();
|
|
||||||
move |btn: Option<Button>| {
|
|
||||||
let query = search_entry.text().to_string();
|
|
||||||
if query.trim().is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Some(b) = &btn {
|
|
||||||
b.set_sensitive(false);
|
|
||||||
}
|
|
||||||
status.set_text("Searching…");
|
|
||||||
|
|
||||||
let (tx, rx) = async_channel::bounded::<Vec<AurResult>>(1);
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let _ = tx.send_blocking(search(&query));
|
|
||||||
});
|
|
||||||
|
|
||||||
let list = list.clone();
|
|
||||||
let status = status.clone();
|
|
||||||
let btn = btn.clone();
|
|
||||||
glib::spawn_future_local(async move {
|
|
||||||
if let Ok(results) = rx.recv().await {
|
|
||||||
while let Some(child) = list.first_child() {
|
|
||||||
list.remove(&child);
|
|
||||||
}
|
|
||||||
if results.is_empty() {
|
|
||||||
let row = ListBoxRow::new();
|
|
||||||
row.set_selectable(false);
|
|
||||||
row.set_child(Some(&w::empty_state(
|
|
||||||
"system-search-symbolic",
|
|
||||||
"No results",
|
|
||||||
"Try a different search term.",
|
|
||||||
)));
|
|
||||||
list.append(&row);
|
|
||||||
status.set_text("No results.");
|
|
||||||
} else {
|
|
||||||
status.set_text(&format!("{} result(s)", results.len()));
|
|
||||||
for r in results.into_iter().take(50) {
|
|
||||||
let row = ListBoxRow::new();
|
|
||||||
row.set_selectable(false);
|
|
||||||
let vbox = GBox::new(Orientation::Vertical, 2);
|
|
||||||
vbox.add_css_class("card");
|
|
||||||
vbox.set_margin_top(3);
|
|
||||||
vbox.set_margin_bottom(3);
|
|
||||||
|
|
||||||
let top = GBox::new(Orientation::Horizontal, 12);
|
|
||||||
let name_lbl = Label::new(Some(&format!("{} {}", r.name, r.version)));
|
|
||||||
name_lbl.set_hexpand(true);
|
|
||||||
name_lbl.set_xalign(0.0);
|
|
||||||
let install_btn = Button::with_label("Install");
|
|
||||||
{
|
|
||||||
let pkg = r.name.clone();
|
|
||||||
install_btn.connect_clicked(move |_| install_in_terminal(&pkg));
|
|
||||||
}
|
|
||||||
top.append(&name_lbl);
|
|
||||||
top.append(&install_btn);
|
|
||||||
vbox.append(&top);
|
|
||||||
|
|
||||||
let desc_lbl = Label::new(Some(&r.description));
|
|
||||||
desc_lbl.add_css_class("dim-label");
|
|
||||||
desc_lbl.set_xalign(0.0);
|
|
||||||
desc_lbl.set_wrap(true);
|
|
||||||
desc_lbl.set_max_width_chars(64);
|
|
||||||
vbox.append(&desc_lbl);
|
|
||||||
|
|
||||||
row.set_child(Some(&vbox));
|
|
||||||
list.append(&row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(b) = &btn {
|
|
||||||
b.set_sensitive(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
{
|
|
||||||
let run_search = run_search.clone();
|
|
||||||
search_btn.connect_clicked(move |btn| run_search(Some(btn.clone())));
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let run_search = run_search.clone();
|
|
||||||
search_entry.connect_activate(move |_| run_search(None));
|
|
||||||
}
|
|
||||||
|
|
||||||
outer
|
|
||||||
}
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
//! breadd.toml — the bread daemon config.
|
|
||||||
//! Schema mirrors breadd/src/core/config.rs (daemon, lua, modules, adapters,
|
|
||||||
//! events, notifications). Edited non-destructively via the shared document.
|
|
||||||
|
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::Box as GBox;
|
|
||||||
|
|
||||||
use crate::config;
|
|
||||||
use crate::ui::widgets as w;
|
|
||||||
|
|
||||||
fn config_path() -> std::path::PathBuf {
|
|
||||||
config::config_dir().join("bread/breadd.toml")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
|
||||||
let path = config_path();
|
|
||||||
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
|
|
||||||
|
|
||||||
let (outer, c) = w::view_scaffold("Daemon");
|
|
||||||
|
|
||||||
c.append(&w::service_control("breadd.service", true, true));
|
|
||||||
|
|
||||||
c.append(&w::section("Daemon"));
|
|
||||||
c.append(&w::dropdown_row(
|
|
||||||
"Log level",
|
|
||||||
&doc,
|
|
||||||
&["daemon", "log_level"],
|
|
||||||
&["error", "warn", "info", "debug", "trace"],
|
|
||||||
"info",
|
|
||||||
));
|
|
||||||
c.append(&w::entry_row(
|
|
||||||
"Socket path",
|
|
||||||
&doc,
|
|
||||||
&["daemon", "socket_path"],
|
|
||||||
"default (XDG runtime dir)",
|
|
||||||
"",
|
|
||||||
));
|
|
||||||
|
|
||||||
c.append(&w::section("Lua"));
|
|
||||||
c.append(&w::entry_row(
|
|
||||||
"Entry point",
|
|
||||||
&doc,
|
|
||||||
&["lua", "entry_point"],
|
|
||||||
"~/.config/bread/init.lua",
|
|
||||||
"",
|
|
||||||
));
|
|
||||||
c.append(&w::entry_row(
|
|
||||||
"Module path",
|
|
||||||
&doc,
|
|
||||||
&["lua", "module_path"],
|
|
||||||
"~/.config/bread/modules",
|
|
||||||
"",
|
|
||||||
));
|
|
||||||
|
|
||||||
c.append(&w::section("Modules"));
|
|
||||||
c.append(&w::switch_row(
|
|
||||||
"Load built-in modules",
|
|
||||||
&doc,
|
|
||||||
&["modules", "builtin"],
|
|
||||||
true,
|
|
||||||
));
|
|
||||||
c.append(&w::csv_row(
|
|
||||||
"Disabled modules",
|
|
||||||
&doc,
|
|
||||||
&["modules", "disable"],
|
|
||||||
"module-a, module-b",
|
|
||||||
));
|
|
||||||
|
|
||||||
c.append(&w::section("Adapters"));
|
|
||||||
c.append(&w::hint(
|
|
||||||
"Sources breadd normalises into events. Disable any you don't use.",
|
|
||||||
));
|
|
||||||
c.append(&w::switch_row(
|
|
||||||
"Hyprland",
|
|
||||||
&doc,
|
|
||||||
&["adapters", "hyprland", "enabled"],
|
|
||||||
true,
|
|
||||||
));
|
|
||||||
c.append(&w::switch_row(
|
|
||||||
"udev (devices)",
|
|
||||||
&doc,
|
|
||||||
&["adapters", "udev", "enabled"],
|
|
||||||
true,
|
|
||||||
));
|
|
||||||
c.append(&w::csv_row(
|
|
||||||
"udev subsystems",
|
|
||||||
&doc,
|
|
||||||
&["adapters", "udev", "subsystems"],
|
|
||||||
"usb, input, power_supply",
|
|
||||||
));
|
|
||||||
c.append(&w::switch_row(
|
|
||||||
"Power",
|
|
||||||
&doc,
|
|
||||||
&["adapters", "power", "enabled"],
|
|
||||||
true,
|
|
||||||
));
|
|
||||||
c.append(&w::spin_row(
|
|
||||||
"Power poll interval (s)",
|
|
||||||
&doc,
|
|
||||||
&["adapters", "power", "poll_interval_secs"],
|
|
||||||
1.0,
|
|
||||||
3600.0,
|
|
||||||
1.0,
|
|
||||||
30,
|
|
||||||
));
|
|
||||||
c.append(&w::switch_row(
|
|
||||||
"Network",
|
|
||||||
&doc,
|
|
||||||
&["adapters", "network", "enabled"],
|
|
||||||
true,
|
|
||||||
));
|
|
||||||
c.append(&w::switch_row(
|
|
||||||
"Bluetooth",
|
|
||||||
&doc,
|
|
||||||
&["adapters", "bluetooth", "enabled"],
|
|
||||||
true,
|
|
||||||
));
|
|
||||||
|
|
||||||
c.append(&w::section("Events"));
|
|
||||||
c.append(&w::spin_row(
|
|
||||||
"Dedup window (ms)",
|
|
||||||
&doc,
|
|
||||||
&["events", "dedup_window_ms"],
|
|
||||||
0.0,
|
|
||||||
10000.0,
|
|
||||||
50.0,
|
|
||||||
250,
|
|
||||||
));
|
|
||||||
|
|
||||||
c.append(&w::section("Notifications"));
|
|
||||||
c.append(&w::spin_row(
|
|
||||||
"Default timeout (ms)",
|
|
||||||
&doc,
|
|
||||||
&["notifications", "default_timeout_ms"],
|
|
||||||
0.0,
|
|
||||||
60000.0,
|
|
||||||
500.0,
|
|
||||||
5000,
|
|
||||||
));
|
|
||||||
c.append(&w::dropdown_row(
|
|
||||||
"Default urgency",
|
|
||||||
&doc,
|
|
||||||
&["notifications", "default_urgency"],
|
|
||||||
&["low", "normal", "critical"],
|
|
||||||
"normal",
|
|
||||||
));
|
|
||||||
c.append(&w::entry_row(
|
|
||||||
"notify-send path",
|
|
||||||
&doc,
|
|
||||||
&["notifications", "notify_send_path"],
|
|
||||||
"auto-detected",
|
|
||||||
"",
|
|
||||||
));
|
|
||||||
|
|
||||||
outer.append(&w::save_button(&doc, path));
|
|
||||||
outer
|
|
||||||
}
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
|
||||||
let path = css_path();
|
|
||||||
let existing_css = std::fs::read_to_string(&path).unwrap_or_default();
|
|
||||||
|
|
||||||
let (outer, content) = w::view_scaffold("Bar");
|
|
||||||
content.append(&w::hint(
|
|
||||||
"CSS overrides for breadbar. Leave empty to use the default bread theme. \
|
|
||||||
Reloads live on save — no need to restart the bar.",
|
|
||||||
));
|
|
||||||
|
|
||||||
let buf = gtk4::TextBuffer::new(None);
|
|
||||||
buf.set_text(&existing_css);
|
|
||||||
|
|
||||||
let text_view = TextView::with_buffer(&buf);
|
|
||||||
text_view.set_monospace(true);
|
|
||||||
text_view.set_top_margin(12);
|
|
||||||
text_view.set_bottom_margin(12);
|
|
||||||
text_view.set_left_margin(12);
|
|
||||||
text_view.set_right_margin(12);
|
|
||||||
|
|
||||||
// A borderless full-bleed textview reads as a rendering bug, not an
|
|
||||||
// editor — give it the same card framing every other panel's content
|
|
||||||
// gets, and cap its height so it doesn't compete with the button row
|
|
||||||
// for the one screen's worth of space.
|
|
||||||
let editor_card = GBox::new(Orientation::Vertical, 0);
|
|
||||||
editor_card.add_css_class("card");
|
|
||||||
editor_card.set_vexpand(true);
|
|
||||||
|
|
||||||
let scroll = ScrolledWindow::new();
|
|
||||||
scroll.set_vexpand(true);
|
|
||||||
scroll.set_min_content_height(360);
|
|
||||||
scroll.set_child(Some(&text_view));
|
|
||||||
editor_card.append(&scroll);
|
|
||||||
content.append(&editor_card);
|
|
||||||
|
|
||||||
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");
|
|
||||||
|
|
||||||
{
|
|
||||||
let path = path.clone();
|
|
||||||
let status_lbl = status_lbl.clone();
|
|
||||||
save_btn.connect_clicked(move |_| {
|
|
||||||
let (start, end) = buf.bounds();
|
|
||||||
let text = buf.text(&start, &end, false);
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
let _ = std::fs::create_dir_all(parent);
|
|
||||||
}
|
|
||||||
match std::fs::write(&path, text.as_str()) {
|
|
||||||
Ok(()) => {
|
|
||||||
// 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("");
|
|
||||||
glib::ControlFlow::Break
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e) => status_lbl.set_text(&format!("Error: {e}")),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
btn_row.append(&save_btn);
|
|
||||||
btn_row.append(&status_lbl);
|
|
||||||
outer.append(&btn_row);
|
|
||||||
|
|
||||||
outer
|
|
||||||
}
|
|
||||||
|
|
@ -1,210 +0,0 @@
|
||||||
//! breadbox config.toml — launcher contexts.
|
|
||||||
//! Schema mirrors breadbox-shared (`#[serde(rename = "context")]` — the TOML
|
|
||||||
//! key is `[[context]]`, singular, despite the Rust field being `contexts`),
|
|
||||||
//! with `name` + `priority`, an ordered list of app/category hints. The
|
|
||||||
//! context array is rewritten on save; any other top-level keys/comments in
|
|
||||||
//! the file are preserved.
|
|
||||||
|
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{
|
|
||||||
Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow,
|
|
||||||
};
|
|
||||||
use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
|
|
||||||
|
|
||||||
use crate::config;
|
|
||||||
use crate::ui::widgets as w;
|
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
|
||||||
struct Context {
|
|
||||||
name: String,
|
|
||||||
priority: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn config_path() -> std::path::PathBuf {
|
|
||||||
config::config_dir().join("breadbox/config.toml")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_contexts(doc: &DocumentMut) -> Vec<Context> {
|
|
||||||
let Some(aot) = doc.get("context").and_then(Item::as_array_of_tables) else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
aot.iter()
|
|
||||||
.map(|t| Context {
|
|
||||||
name: t.get("name").and_then(Item::as_str).unwrap_or("").to_string(),
|
|
||||||
priority: t
|
|
||||||
.get("priority")
|
|
||||||
.and_then(Item::as_array)
|
|
||||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rewrite only the `contexts` array-of-tables, leaving the rest of the doc.
|
|
||||||
fn write_contexts(doc: &mut DocumentMut, ctxs: &[Context]) {
|
|
||||||
let mut aot = ArrayOfTables::new();
|
|
||||||
for ctx in ctxs {
|
|
||||||
let mut t = Table::new();
|
|
||||||
t.insert("name", value(&ctx.name));
|
|
||||||
let mut arr = Array::new();
|
|
||||||
for p in &ctx.priority {
|
|
||||||
arr.push(p.as_str());
|
|
||||||
}
|
|
||||||
t.insert("priority", value(arr));
|
|
||||||
aot.push(t);
|
|
||||||
}
|
|
||||||
doc.as_table_mut().insert("context", Item::ArrayOfTables(aot));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rebuild_list(list: &ListBox, model: &Rc<RefCell<Vec<Context>>>) {
|
|
||||||
while let Some(child) = list.first_child() {
|
|
||||||
list.remove(&child);
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
|
|
||||||
let hbox = GBox::new(Orientation::Horizontal, 8);
|
|
||||||
hbox.set_margin_top(6);
|
|
||||||
hbox.set_margin_bottom(6);
|
|
||||||
hbox.set_margin_start(8);
|
|
||||||
hbox.set_margin_end(8);
|
|
||||||
|
|
||||||
let name_entry = Entry::new();
|
|
||||||
name_entry.set_text(&ctx.name);
|
|
||||||
name_entry.set_width_chars(14);
|
|
||||||
name_entry.set_placeholder_text(Some("name"));
|
|
||||||
|
|
||||||
let prio_entry = Entry::new();
|
|
||||||
prio_entry.set_text(&ctx.priority.join(", "));
|
|
||||||
prio_entry.set_hexpand(true);
|
|
||||||
prio_entry.set_placeholder_text(Some("firefox, code, Development, ..."));
|
|
||||||
|
|
||||||
let remove_btn = Button::with_label("Remove");
|
|
||||||
remove_btn.add_css_class("destructive-action");
|
|
||||||
|
|
||||||
{
|
|
||||||
let model = model.clone();
|
|
||||||
name_entry.connect_changed(move |e| {
|
|
||||||
if let Some(c) = model.borrow_mut().get_mut(i) {
|
|
||||||
c.name = e.text().to_string();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let model = model.clone();
|
|
||||||
prio_entry.connect_changed(move |e| {
|
|
||||||
if let Some(c) = model.borrow_mut().get_mut(i) {
|
|
||||||
c.priority = e
|
|
||||||
.text()
|
|
||||||
.split(',')
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let model = model.clone();
|
|
||||||
let list = list.clone();
|
|
||||||
remove_btn.connect_clicked(move |_| {
|
|
||||||
model.borrow_mut().remove(i);
|
|
||||||
rebuild_list(&list, &model);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
hbox.append(&name_entry);
|
|
||||||
hbox.append(&prio_entry);
|
|
||||||
hbox.append(&remove_btn);
|
|
||||||
row.set_child(Some(&hbox));
|
|
||||||
list.append(&row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
|
||||||
let path = config_path();
|
|
||||||
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
|
|
||||||
let model = Rc::new(RefCell::new(read_contexts(&doc.borrow())));
|
|
||||||
|
|
||||||
let (outer, content) = w::view_scaffold("Launcher");
|
|
||||||
content.append(&w::service_control("breadbox-sync.service", false, true));
|
|
||||||
|
|
||||||
content.append(&w::section("Contexts"));
|
|
||||||
content.append(&w::hint(
|
|
||||||
"Launcher contexts — each lists, in priority order, the apps/categories surfaced first.",
|
|
||||||
));
|
|
||||||
|
|
||||||
let list = ListBox::new();
|
|
||||||
list.set_selection_mode(gtk4::SelectionMode::None);
|
|
||||||
rebuild_list(&list, &model);
|
|
||||||
|
|
||||||
let scroll = ScrolledWindow::new();
|
|
||||||
scroll.set_vexpand(true);
|
|
||||||
scroll.set_child(Some(&list));
|
|
||||||
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();
|
|
||||||
add_btn.connect_clicked(move |_| {
|
|
||||||
model.borrow_mut().push(Context {
|
|
||||||
name: "new".to_string(),
|
|
||||||
priority: Vec::new(),
|
|
||||||
});
|
|
||||||
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();
|
|
||||||
let path = path.clone();
|
|
||||||
let status_lbl = status_lbl.clone();
|
|
||||||
save_btn.connect_clicked(move |_| {
|
|
||||||
write_contexts(&mut doc.borrow_mut(), &model.borrow());
|
|
||||||
match config::save_doc(&path, &doc.borrow()) {
|
|
||||||
Ok(()) => {
|
|
||||||
status_lbl.set_text("Saved");
|
|
||||||
let lbl = status_lbl.clone();
|
|
||||||
glib::timeout_add_seconds_local(3, move || {
|
|
||||||
lbl.set_text("");
|
|
||||||
glib::ControlFlow::Break
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e) => status_lbl.set_text(&format!("Error: {e}")),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
btn_row.append(&save_btn);
|
|
||||||
btn_row.append(&status_lbl);
|
|
||||||
outer.append(&btn_row);
|
|
||||||
|
|
||||||
outer
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
//! breadclip — clipboard history popup + its background daemon (breadclipd).
|
|
||||||
//! No config file to edit: breadclip takes no persistent settings. This
|
|
||||||
//! panel exists purely so the daemon backing it is visible/controllable
|
|
||||||
//! from bos-settings instead of only from a terminal — previously breadclip
|
|
||||||
//! had no presence in Settings at all despite running its own service.
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{Box as GBox, Button};
|
|
||||||
|
|
||||||
use crate::ui::widgets as w;
|
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
|
||||||
let (outer, c) = w::view_scaffold("Clipboard");
|
|
||||||
|
|
||||||
c.append(&w::hint(
|
|
||||||
"Keeps a history of copied text/images and shows it as a popup. \
|
|
||||||
breadclipd (below) is the background daemon that watches the \
|
|
||||||
clipboard — breadclip itself is just the popup UI, launched on \
|
|
||||||
demand by the keybind or the button below.",
|
|
||||||
));
|
|
||||||
|
|
||||||
let open_btn = Button::with_label("Open history (SUPER+V)");
|
|
||||||
open_btn.add_css_class("suggested-action");
|
|
||||||
open_btn.set_halign(gtk4::Align::Start);
|
|
||||||
open_btn.connect_clicked(|_| {
|
|
||||||
let _ = std::process::Command::new("breadclip").spawn();
|
|
||||||
});
|
|
||||||
c.append(&open_btn);
|
|
||||||
|
|
||||||
c.append(&w::service_control("breadclipd.service", false, false));
|
|
||||||
|
|
||||||
outer
|
|
||||||
}
|
|
||||||
|
|
@ -1,467 +0,0 @@
|
||||||
//! breadcrumbs.toml — Wi-Fi profile state machine.
|
|
||||||
//! Schema mirrors breadcrumbs/src/config.rs:
|
|
||||||
//! [settings] scalar tunables
|
|
||||||
//! [[networks]] saved networks (ssid / password / hidden)
|
|
||||||
//! [profiles.<name>] per-location profile (networks, tailscale, …)
|
|
||||||
//! `[settings]` is edited in place; the `networks` array and `profiles` table
|
|
||||||
//! are rewritten from their editors on save. Other keys/comments are preserved.
|
|
||||||
|
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{
|
|
||||||
Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, Switch,
|
|
||||||
};
|
|
||||||
use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
|
|
||||||
|
|
||||||
use crate::config;
|
|
||||||
use crate::ui::widgets as w;
|
|
||||||
|
|
||||||
fn config_path() -> std::path::PathBuf {
|
|
||||||
config::config_dir().join("breadcrumbs/breadcrumbs.toml")
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- networks ---------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
|
||||||
struct Network {
|
|
||||||
ssid: String,
|
|
||||||
password: String,
|
|
||||||
hidden: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_networks(doc: &DocumentMut) -> Vec<Network> {
|
|
||||||
let Some(aot) = doc.get("networks").and_then(Item::as_array_of_tables) else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
aot.iter()
|
|
||||||
.map(|t| Network {
|
|
||||||
ssid: t.get("ssid").and_then(Item::as_str).unwrap_or("").to_string(),
|
|
||||||
password: t
|
|
||||||
.get("password")
|
|
||||||
.and_then(Item::as_str)
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string(),
|
|
||||||
hidden: t.get("hidden").and_then(Item::as_bool).unwrap_or(false),
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_networks(doc: &mut DocumentMut, nets: &[Network]) {
|
|
||||||
let mut aot = ArrayOfTables::new();
|
|
||||||
for n in nets {
|
|
||||||
let mut t = Table::new();
|
|
||||||
t.insert("ssid", value(&n.ssid));
|
|
||||||
t.insert("password", value(&n.password));
|
|
||||||
t.insert("hidden", value(n.hidden));
|
|
||||||
aot.push(t);
|
|
||||||
}
|
|
||||||
doc.as_table_mut().insert("networks", Item::ArrayOfTables(aot));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rebuild_networks(list: &ListBox, model: &Rc<RefCell<Vec<Network>>>) {
|
|
||||||
while let Some(child) = list.first_child() {
|
|
||||||
list.remove(&child);
|
|
||||||
}
|
|
||||||
for (i, n) in model.borrow().iter().enumerate() {
|
|
||||||
let row = ListBoxRow::new();
|
|
||||||
row.set_selectable(false);
|
|
||||||
let hbox = GBox::new(Orientation::Horizontal, 8);
|
|
||||||
hbox.set_margin_top(6);
|
|
||||||
hbox.set_margin_bottom(6);
|
|
||||||
hbox.set_margin_start(8);
|
|
||||||
hbox.set_margin_end(8);
|
|
||||||
|
|
||||||
let ssid = Entry::new();
|
|
||||||
ssid.set_text(&n.ssid);
|
|
||||||
ssid.set_width_chars(16);
|
|
||||||
ssid.set_placeholder_text(Some("SSID"));
|
|
||||||
|
|
||||||
let pass = Entry::new();
|
|
||||||
pass.set_text(&n.password);
|
|
||||||
pass.set_hexpand(true);
|
|
||||||
pass.set_visibility(false);
|
|
||||||
pass.set_input_purpose(gtk4::InputPurpose::Password);
|
|
||||||
pass.set_placeholder_text(Some("password"));
|
|
||||||
|
|
||||||
let hidden = Switch::new();
|
|
||||||
hidden.set_active(n.hidden);
|
|
||||||
hidden.set_valign(gtk4::Align::Center);
|
|
||||||
hidden.set_tooltip_text(Some("Hidden network"));
|
|
||||||
|
|
||||||
let remove = Button::with_label("Remove");
|
|
||||||
remove.add_css_class("destructive-action");
|
|
||||||
|
|
||||||
{
|
|
||||||
let model = model.clone();
|
|
||||||
ssid.connect_changed(move |e| {
|
|
||||||
if let Some(n) = model.borrow_mut().get_mut(i) {
|
|
||||||
n.ssid = e.text().to_string();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let model = model.clone();
|
|
||||||
pass.connect_changed(move |e| {
|
|
||||||
if let Some(n) = model.borrow_mut().get_mut(i) {
|
|
||||||
n.password = e.text().to_string();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let model = model.clone();
|
|
||||||
hidden.connect_active_notify(move |s| {
|
|
||||||
if let Some(n) = model.borrow_mut().get_mut(i) {
|
|
||||||
n.hidden = s.is_active();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let model = model.clone();
|
|
||||||
let list = list.clone();
|
|
||||||
remove.connect_clicked(move |_| {
|
|
||||||
model.borrow_mut().remove(i);
|
|
||||||
rebuild_networks(&list, &model);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
hbox.append(&ssid);
|
|
||||||
hbox.append(&pass);
|
|
||||||
hbox.append(&Label::new(Some("hidden")));
|
|
||||||
hbox.append(&hidden);
|
|
||||||
hbox.append(&remove);
|
|
||||||
row.set_child(Some(&hbox));
|
|
||||||
list.append(&row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- profiles ---------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
|
||||||
struct Profile {
|
|
||||||
name: String,
|
|
||||||
networks: Vec<String>,
|
|
||||||
detect_ssids: Vec<String>,
|
|
||||||
bootstrap: String,
|
|
||||||
exit_node: String,
|
|
||||||
tailscale: bool,
|
|
||||||
include_all_known: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_profiles(doc: &DocumentMut) -> Vec<Profile> {
|
|
||||||
let Some(tbl) = doc.get("profiles").and_then(Item::as_table) else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
let str_list = |item: Option<&Item>| -> Vec<String> {
|
|
||||||
item.and_then(Item::as_array)
|
|
||||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
|
||||||
.unwrap_or_default()
|
|
||||||
};
|
|
||||||
tbl.iter()
|
|
||||||
.filter_map(|(name, item)| {
|
|
||||||
let p = item.as_table()?;
|
|
||||||
Some(Profile {
|
|
||||||
name: name.to_string(),
|
|
||||||
networks: str_list(p.get("networks")),
|
|
||||||
detect_ssids: str_list(p.get("detect_ssids")),
|
|
||||||
bootstrap: p.get("bootstrap").and_then(Item::as_str).unwrap_or("").to_string(),
|
|
||||||
exit_node: p.get("exit_node").and_then(Item::as_str).unwrap_or("").to_string(),
|
|
||||||
tailscale: p.get("tailscale").and_then(Item::as_bool).unwrap_or(false),
|
|
||||||
include_all_known: p
|
|
||||||
.get("include_all_known")
|
|
||||||
.and_then(Item::as_bool)
|
|
||||||
.unwrap_or(false),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_profiles(doc: &mut DocumentMut, profiles: &[Profile]) {
|
|
||||||
let mut tbl = Table::new();
|
|
||||||
let to_arr = |items: &[String]| {
|
|
||||||
let mut a = Array::new();
|
|
||||||
for s in items {
|
|
||||||
a.push(s.as_str());
|
|
||||||
}
|
|
||||||
a
|
|
||||||
};
|
|
||||||
for p in profiles {
|
|
||||||
if p.name.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut t = Table::new();
|
|
||||||
t.insert("networks", value(to_arr(&p.networks)));
|
|
||||||
t.insert("tailscale", value(p.tailscale));
|
|
||||||
t.insert("include_all_known", value(p.include_all_known));
|
|
||||||
if !p.detect_ssids.is_empty() {
|
|
||||||
t.insert("detect_ssids", value(to_arr(&p.detect_ssids)));
|
|
||||||
}
|
|
||||||
if !p.bootstrap.is_empty() {
|
|
||||||
t.insert("bootstrap", value(&p.bootstrap));
|
|
||||||
}
|
|
||||||
if !p.exit_node.is_empty() {
|
|
||||||
t.insert("exit_node", value(&p.exit_node));
|
|
||||||
}
|
|
||||||
tbl.insert(&p.name, Item::Table(t));
|
|
||||||
}
|
|
||||||
doc.as_table_mut().insert("profiles", Item::Table(tbl));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn field(label: &str, control: &impl IsA<gtk4::Widget>) -> GBox {
|
|
||||||
let row = GBox::new(Orientation::Horizontal, 12);
|
|
||||||
let lbl = Label::new(Some(label));
|
|
||||||
lbl.set_xalign(0.0);
|
|
||||||
lbl.set_width_chars(16);
|
|
||||||
row.append(&lbl);
|
|
||||||
control.set_hexpand(true);
|
|
||||||
row.append(control);
|
|
||||||
row
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rebuild_profiles(container: &GBox, model: &Rc<RefCell<Vec<Profile>>>) {
|
|
||||||
while let Some(child) = container.first_child() {
|
|
||||||
container.remove(&child);
|
|
||||||
}
|
|
||||||
for (i, p) in model.borrow().iter().enumerate() {
|
|
||||||
let card = GBox::new(Orientation::Vertical, 6);
|
|
||||||
card.add_css_class("card");
|
|
||||||
card.set_margin_top(6);
|
|
||||||
card.set_margin_bottom(6);
|
|
||||||
|
|
||||||
let header = GBox::new(Orientation::Horizontal, 8);
|
|
||||||
let name = Entry::new();
|
|
||||||
name.set_text(&p.name);
|
|
||||||
name.set_hexpand(true);
|
|
||||||
name.set_placeholder_text(Some("profile name (e.g. home)"));
|
|
||||||
let remove = Button::with_label("Remove");
|
|
||||||
remove.add_css_class("destructive-action");
|
|
||||||
header.append(&name);
|
|
||||||
header.append(&remove);
|
|
||||||
card.append(&header);
|
|
||||||
|
|
||||||
let networks = Entry::new();
|
|
||||||
networks.set_text(&p.networks.join(", "));
|
|
||||||
networks.set_placeholder_text(Some("SSID1, SSID2"));
|
|
||||||
card.append(&field("Networks", &networks));
|
|
||||||
|
|
||||||
let detect = Entry::new();
|
|
||||||
detect.set_text(&p.detect_ssids.join(", "));
|
|
||||||
detect.set_placeholder_text(Some("SSIDs that auto-select this profile"));
|
|
||||||
card.append(&field("Detect SSIDs", &detect));
|
|
||||||
|
|
||||||
let exit_node = Entry::new();
|
|
||||||
exit_node.set_text(&p.exit_node);
|
|
||||||
exit_node.set_placeholder_text(Some("tailscale exit node (optional)"));
|
|
||||||
card.append(&field("Exit node", &exit_node));
|
|
||||||
|
|
||||||
let bootstrap = Entry::new();
|
|
||||||
bootstrap.set_text(&p.bootstrap);
|
|
||||||
bootstrap.set_placeholder_text(Some("bootstrap command (optional)"));
|
|
||||||
card.append(&field("Bootstrap", &bootstrap));
|
|
||||||
|
|
||||||
let tailscale = Switch::new();
|
|
||||||
tailscale.set_active(p.tailscale);
|
|
||||||
tailscale.set_halign(gtk4::Align::Start);
|
|
||||||
card.append(&field("Tailscale", &tailscale));
|
|
||||||
|
|
||||||
let include_all = Switch::new();
|
|
||||||
include_all.set_active(p.include_all_known);
|
|
||||||
include_all.set_halign(gtk4::Align::Start);
|
|
||||||
card.append(&field("Include all known", &include_all));
|
|
||||||
|
|
||||||
// bind each control to the in-memory model entry
|
|
||||||
macro_rules! bind_csv {
|
|
||||||
($entry:ident, $f:ident) => {{
|
|
||||||
let model = model.clone();
|
|
||||||
$entry.connect_changed(move |e| {
|
|
||||||
if let Some(p) = model.borrow_mut().get_mut(i) {
|
|
||||||
p.$f = e
|
|
||||||
.text()
|
|
||||||
.split(',')
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}};
|
|
||||||
}
|
|
||||||
macro_rules! bind_str {
|
|
||||||
($entry:ident, $f:ident) => {{
|
|
||||||
let model = model.clone();
|
|
||||||
$entry.connect_changed(move |e| {
|
|
||||||
if let Some(p) = model.borrow_mut().get_mut(i) {
|
|
||||||
p.$f = e.text().to_string();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}};
|
|
||||||
}
|
|
||||||
macro_rules! bind_bool {
|
|
||||||
($sw:ident, $f:ident) => {{
|
|
||||||
let model = model.clone();
|
|
||||||
$sw.connect_active_notify(move |s| {
|
|
||||||
if let Some(p) = model.borrow_mut().get_mut(i) {
|
|
||||||
p.$f = s.is_active();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}};
|
|
||||||
}
|
|
||||||
bind_str!(name, name);
|
|
||||||
bind_csv!(networks, networks);
|
|
||||||
bind_csv!(detect, detect_ssids);
|
|
||||||
bind_str!(exit_node, exit_node);
|
|
||||||
bind_str!(bootstrap, bootstrap);
|
|
||||||
bind_bool!(tailscale, tailscale);
|
|
||||||
bind_bool!(include_all, include_all_known);
|
|
||||||
{
|
|
||||||
let model = model.clone();
|
|
||||||
let container = container.clone();
|
|
||||||
remove.connect_clicked(move |_| {
|
|
||||||
model.borrow_mut().remove(i);
|
|
||||||
rebuild_profiles(&container, &model);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
container.append(&card);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- view -------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
|
||||||
let path = config_path();
|
|
||||||
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
|
|
||||||
let nets = Rc::new(RefCell::new(read_networks(&doc.borrow())));
|
|
||||||
let profiles = Rc::new(RefCell::new(read_profiles(&doc.borrow())));
|
|
||||||
|
|
||||||
let (outer, 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"));
|
|
||||||
content.append(&w::dropdown_row(
|
|
||||||
"Default profile",
|
|
||||||
&doc,
|
|
||||||
&["settings", "default_profile"],
|
|
||||||
&["home", "away"],
|
|
||||||
// breadcrumbs' own default_profile_name() is "away", not "home" —
|
|
||||||
// this was showing the wrong value for an unset key.
|
|
||||||
"away",
|
|
||||||
));
|
|
||||||
content.append(&w::entry_row("DNS", &doc, &["settings", "dns"], "1.1.1.1", ""));
|
|
||||||
content.append(&w::entry_row(
|
|
||||||
"Exit node",
|
|
||||||
&doc,
|
|
||||||
&["settings", "exit_node"],
|
|
||||||
"tailscale exit node",
|
|
||||||
"",
|
|
||||||
));
|
|
||||||
content.append(&w::entry_row(
|
|
||||||
"Ping host",
|
|
||||||
&doc,
|
|
||||||
&["settings", "ping_host"],
|
|
||||||
"1.1.1.1",
|
|
||||||
"",
|
|
||||||
));
|
|
||||||
content.append(&w::entry_row(
|
|
||||||
"Connectivity URL",
|
|
||||||
&doc,
|
|
||||||
&["settings", "connectivity_url"],
|
|
||||||
"http://connectivitycheck.gstatic.com/generate_204",
|
|
||||||
"",
|
|
||||||
));
|
|
||||||
content.append(&w::spin_row(
|
|
||||||
"nmcli wait (s)",
|
|
||||||
&doc,
|
|
||||||
&["settings", "nmcli_wait"],
|
|
||||||
1.0,
|
|
||||||
120.0,
|
|
||||||
1.0,
|
|
||||||
8,
|
|
||||||
));
|
|
||||||
content.append(&w::spin_row(
|
|
||||||
"Watch interval (s)",
|
|
||||||
&doc,
|
|
||||||
&["settings", "watch_interval"],
|
|
||||||
1.0,
|
|
||||||
600.0,
|
|
||||||
1.0,
|
|
||||||
12,
|
|
||||||
));
|
|
||||||
|
|
||||||
// [[networks]]
|
|
||||||
content.append(&w::section("Saved networks"));
|
|
||||||
let net_list = ListBox::new();
|
|
||||||
net_list.set_selection_mode(gtk4::SelectionMode::None);
|
|
||||||
rebuild_networks(&net_list, &nets);
|
|
||||||
content.append(&net_list);
|
|
||||||
let add_net = Button::with_label("Add network");
|
|
||||||
add_net.set_halign(gtk4::Align::Start);
|
|
||||||
{
|
|
||||||
let nets = nets.clone();
|
|
||||||
let net_list = net_list.clone();
|
|
||||||
add_net.connect_clicked(move |_| {
|
|
||||||
nets.borrow_mut().push(Network::default());
|
|
||||||
rebuild_networks(&net_list, &nets);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
content.append(&add_net);
|
|
||||||
|
|
||||||
// [profiles.*]
|
|
||||||
content.append(&w::section("Profiles"));
|
|
||||||
let prof_box = GBox::new(Orientation::Vertical, 4);
|
|
||||||
rebuild_profiles(&prof_box, &profiles);
|
|
||||||
content.append(&prof_box);
|
|
||||||
let add_prof = Button::with_label("Add profile");
|
|
||||||
add_prof.set_halign(gtk4::Align::Start);
|
|
||||||
{
|
|
||||||
let profiles = profiles.clone();
|
|
||||||
let prof_box = prof_box.clone();
|
|
||||||
add_prof.connect_clicked(move |_| {
|
|
||||||
profiles.borrow_mut().push(Profile {
|
|
||||||
name: "new".to_string(),
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
rebuild_profiles(&prof_box, &profiles);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
content.append(&add_prof);
|
|
||||||
|
|
||||||
// Save — fold the network + profile editors back into the doc, then write.
|
|
||||||
let btn_row = GBox::new(Orientation::Horizontal, 12);
|
|
||||||
btn_row.set_margin_top(16);
|
|
||||||
let save_btn = Button::with_label("Save");
|
|
||||||
save_btn.add_css_class("suggested-action");
|
|
||||||
let status = Label::new(None);
|
|
||||||
status.add_css_class("dim-label");
|
|
||||||
{
|
|
||||||
let doc = doc.clone();
|
|
||||||
let nets = nets.clone();
|
|
||||||
let profiles = profiles.clone();
|
|
||||||
let path = path.clone();
|
|
||||||
let status = status.clone();
|
|
||||||
save_btn.connect_clicked(move |_| {
|
|
||||||
{
|
|
||||||
let mut d = doc.borrow_mut();
|
|
||||||
write_networks(&mut d, &nets.borrow());
|
|
||||||
write_profiles(&mut d, &profiles.borrow());
|
|
||||||
}
|
|
||||||
match config::save_doc(&path, &doc.borrow()) {
|
|
||||||
Ok(()) => {
|
|
||||||
status.set_text("Saved");
|
|
||||||
let lbl = status.clone();
|
|
||||||
glib::timeout_add_seconds_local(3, move || {
|
|
||||||
lbl.set_text("");
|
|
||||||
glib::ControlFlow::Break
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e) => status.set_text(&format!("Error: {e}")),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
btn_row.append(&save_btn);
|
|
||||||
btn_row.append(&status);
|
|
||||||
outer.append(&btn_row);
|
|
||||||
|
|
||||||
outer
|
|
||||||
}
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
//! breadpad.toml — the breadpad notes/reminders config.
|
|
||||||
//! Schema mirrors breadpad-shared/src/config.rs (settings, model + model.ollama,
|
|
||||||
//! reminders, calendar). Edited non-destructively (the calendar password and
|
|
||||||
//! model paths are preserved across saves).
|
|
||||||
|
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::Box as GBox;
|
|
||||||
|
|
||||||
use crate::config;
|
|
||||||
use crate::ui::widgets as w;
|
|
||||||
|
|
||||||
fn config_path() -> std::path::PathBuf {
|
|
||||||
config::config_dir().join("breadpad/breadpad.toml")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
|
||||||
let path = config_path();
|
|
||||||
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
|
|
||||||
|
|
||||||
let (outer, c) = w::view_scaffold("Notes");
|
|
||||||
|
|
||||||
c.append(&w::section("Capture"));
|
|
||||||
c.append(&w::dropdown_row(
|
|
||||||
"Default note type",
|
|
||||||
&doc,
|
|
||||||
&["settings", "default_type"],
|
|
||||||
&["note", "reminder", "task"],
|
|
||||||
"note",
|
|
||||||
));
|
|
||||||
c.append(&w::switch_row(
|
|
||||||
"Tag with active workspace",
|
|
||||||
&doc,
|
|
||||||
&["settings", "workspace_tag"],
|
|
||||||
true,
|
|
||||||
));
|
|
||||||
c.append(&w::csv_row(
|
|
||||||
"Snooze options",
|
|
||||||
&doc,
|
|
||||||
&["settings", "snooze_options"],
|
|
||||||
"15m, 1h, tomorrow_morning",
|
|
||||||
));
|
|
||||||
c.append(&w::spin_row(
|
|
||||||
"Archive after (days)",
|
|
||||||
&doc,
|
|
||||||
&["settings", "archive_after_days"],
|
|
||||||
0.0,
|
|
||||||
3650.0,
|
|
||||||
1.0,
|
|
||||||
30,
|
|
||||||
));
|
|
||||||
|
|
||||||
c.append(&w::section("Classifier model"));
|
|
||||||
c.append(&w::entry_row(
|
|
||||||
"ONNX model path",
|
|
||||||
&doc,
|
|
||||||
&["model", "path"],
|
|
||||||
"~/.local/share/breadpad/model/classifier.onnx",
|
|
||||||
"",
|
|
||||||
));
|
|
||||||
c.append(&w::entry_row(
|
|
||||||
"Tokenizer path",
|
|
||||||
&doc,
|
|
||||||
&["model", "tokenizer"],
|
|
||||||
"~/.local/share/breadpad/model/tokenizer.json",
|
|
||||||
"",
|
|
||||||
));
|
|
||||||
|
|
||||||
c.append(&w::section("Ollama (LLM classifier)"));
|
|
||||||
c.append(&w::switch_row(
|
|
||||||
"Use Ollama",
|
|
||||||
&doc,
|
|
||||||
&["model", "ollama", "enabled"],
|
|
||||||
true,
|
|
||||||
));
|
|
||||||
c.append(&w::entry_row(
|
|
||||||
"Endpoint",
|
|
||||||
&doc,
|
|
||||||
&["model", "ollama", "endpoint"],
|
|
||||||
"http://localhost:11434",
|
|
||||||
"",
|
|
||||||
));
|
|
||||||
c.append(&w::entry_row(
|
|
||||||
"Model",
|
|
||||||
&doc,
|
|
||||||
&["model", "ollama", "model"],
|
|
||||||
"e.g. fastflowlm",
|
|
||||||
"",
|
|
||||||
));
|
|
||||||
c.append(&w::spin_f64_row(
|
|
||||||
"Confidence threshold",
|
|
||||||
&doc,
|
|
||||||
&["model", "ollama", "confidence_threshold"],
|
|
||||||
0.0,
|
|
||||||
1.0,
|
|
||||||
0.05,
|
|
||||||
2,
|
|
||||||
0.6,
|
|
||||||
));
|
|
||||||
|
|
||||||
c.append(&w::section("Reminders"));
|
|
||||||
c.append(&w::entry_row(
|
|
||||||
"Default morning time",
|
|
||||||
&doc,
|
|
||||||
&["reminders", "default_morning"],
|
|
||||||
"7:00",
|
|
||||||
"",
|
|
||||||
));
|
|
||||||
c.append(&w::spin_row(
|
|
||||||
"Missed grace (minutes)",
|
|
||||||
&doc,
|
|
||||||
&["reminders", "missed_grace_minutes"],
|
|
||||||
0.0,
|
|
||||||
1440.0,
|
|
||||||
5.0,
|
|
||||||
60,
|
|
||||||
));
|
|
||||||
|
|
||||||
c.append(&w::section("Calendar (CalDAV)"));
|
|
||||||
c.append(&w::switch_row(
|
|
||||||
"Sync to calendar",
|
|
||||||
&doc,
|
|
||||||
&["calendar", "enabled"],
|
|
||||||
false,
|
|
||||||
));
|
|
||||||
c.append(&w::entry_row(
|
|
||||||
"CalDAV URL",
|
|
||||||
&doc,
|
|
||||||
&["calendar", "url"],
|
|
||||||
"https://host/remote.php/dav/calendars/...",
|
|
||||||
"",
|
|
||||||
));
|
|
||||||
c.append(&w::entry_row(
|
|
||||||
"Username",
|
|
||||||
&doc,
|
|
||||||
&["calendar", "username"],
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
));
|
|
||||||
c.append(&w::password_row("Password", &doc, &["calendar", "password"]));
|
|
||||||
|
|
||||||
outer.append(&w::save_button(&doc, path));
|
|
||||||
outer
|
|
||||||
}
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
//! breadpaper — wallpaper manager. No config file to edit here; breadpaper
|
|
||||||
//! takes no persistent settings, just an image path via its CLI (`breadpaper
|
|
||||||
//! set <path>` / `breadpaper get`). This panel is a thin GUI front-end for
|
|
||||||
//! that CLI so wallpaper (and the pywal-driven theme it generates) has a
|
|
||||||
//! discoverable home in BOS Settings instead of only being reachable from a
|
|
||||||
//! terminal.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{
|
|
||||||
Box as GBox, Button, FileChooserAction, FileChooserDialog, Image, Label, Orientation,
|
|
||||||
ResponseType,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::ui::widgets as w;
|
|
||||||
|
|
||||||
fn current_wallpaper() -> Option<PathBuf> {
|
|
||||||
let out = Command::new("breadpaper").arg("get").output().ok()?;
|
|
||||||
if !out.status.success() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
||||||
if s.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(PathBuf::from(s))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn refresh_preview(preview: &Image, path_lbl: &Label) {
|
|
||||||
match current_wallpaper() {
|
|
||||||
Some(path) => {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
//! breadsearch/config.toml — semantic search indexer (breadmill) + GUI.
|
|
||||||
//! Schema mirrors breadsearch-shared::Config ([index], [search], [model], [power]).
|
|
||||||
|
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::Box as GBox;
|
|
||||||
|
|
||||||
use crate::config;
|
|
||||||
use crate::ui::widgets as w;
|
|
||||||
|
|
||||||
fn config_path() -> std::path::PathBuf {
|
|
||||||
config::config_dir().join("breadsearch/config.toml")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
|
||||||
let path = config_path();
|
|
||||||
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
|
|
||||||
|
|
||||||
let (outer, c) = w::view_scaffold("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
|
|
||||||
}
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
//! Timezone + NTP, over `timedatectl`. `systemd-timesyncd` is enabled by
|
|
||||||
//! default (see `post-install.sh`), so NTP sync is on out of the box — this
|
|
||||||
//! panel is mostly for picking a timezone and confirming sync is healthy.
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{Box as GBox, DropDown, Expression, Label, StringList, Switch};
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
use crate::ui::widgets as w;
|
|
||||||
|
|
||||||
fn show_property(prop: &str) -> String {
|
|
||||||
Command::new("timedatectl")
|
|
||||||
.args(["show", &format!("--property={prop}")])
|
|
||||||
.output()
|
|
||||||
.ok()
|
|
||||||
.and_then(|o| {
|
|
||||||
String::from_utf8_lossy(&o.stdout)
|
|
||||||
.trim()
|
|
||||||
.strip_prefix(&format!("{prop}="))
|
|
||||||
.map(str::to_string)
|
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list_timezones() -> Vec<String> {
|
|
||||||
Command::new("timedatectl")
|
|
||||||
.arg("list-timezones")
|
|
||||||
.output()
|
|
||||||
.ok()
|
|
||||||
.map(|o| String::from_utf8_lossy(&o.stdout).lines().map(str::to_string).collect())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_time_label() -> String {
|
|
||||||
Command::new("date")
|
|
||||||
.arg("+%A, %d %B %Y %H:%M")
|
|
||||||
.output()
|
|
||||||
.ok()
|
|
||||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
|
||||||
let (outer, content) = w::view_scaffold("Date & Time");
|
|
||||||
|
|
||||||
content.append(&w::info_row("Current time", ¤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
|
|
||||||
}
|
|
||||||
|
|
@ -1,246 +0,0 @@
|
||||||
//! ufw firewall rules — BOS enables ufw by default (deny incoming, allow
|
|
||||||
//! outgoing, mDNS allowed for printer discovery; see post-install.sh) but
|
|
||||||
//! previously offered no graphical way to add/remove a rule for something
|
|
||||||
//! like a local dev server or a LAN game, only a terminal.
|
|
||||||
//!
|
|
||||||
//! `ufw status` itself requires root — confirmed against the installed
|
|
||||||
//! ufw script, which exits with "ERROR: You need to be root to run this
|
|
||||||
//! script" for a plain status check, not just for changes. Every other
|
|
||||||
//! panel's read-only state is free to query eagerly in `build()`, but doing
|
|
||||||
//! that here would mean a polkit password prompt on every single
|
|
||||||
//! bos-settings launch (every view is constructed immediately at startup —
|
|
||||||
//! see window.rs). So this panel starts blank and loads state only when the
|
|
||||||
//! user clicks Refresh, deferring the one unavoidable prompt to an explicit
|
|
||||||
//! action instead of forcing it on app open.
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch};
|
|
||||||
use std::cell::Cell;
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use crate::ui::widgets as w;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct Rule {
|
|
||||||
number: String,
|
|
||||||
text: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Status {
|
|
||||||
active: bool,
|
|
||||||
rules: Vec<Rule>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One `pkexec ufw status numbered` call, parsed for both the active/inactive
|
|
||||||
/// line and the numbered rules — a single privileged read instead of two.
|
|
||||||
fn fetch_status() -> Option<Status> {
|
|
||||||
let output = std::process::Command::new("pkexec")
|
|
||||||
.args(["ufw", "status", "numbered"])
|
|
||||||
.output()
|
|
||||||
.ok()?;
|
|
||||||
if !output.status.success() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let text = String::from_utf8_lossy(&output.stdout);
|
|
||||||
let active = text.lines().next().is_some_and(|l| l.trim() == "Status: active");
|
|
||||||
let rules = text
|
|
||||||
.lines()
|
|
||||||
.filter_map(|l| {
|
|
||||||
let l = l.trim_start();
|
|
||||||
if !l.starts_with('[') {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let (num, rest) = l.split_once(']')?;
|
|
||||||
let number = num.trim_start_matches('[').trim().to_string();
|
|
||||||
Some(Rule { number, text: rest.trim().to_string() })
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
Some(Status { active, rules })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_rules(list: &ListBox, rules: &[Rule], programmatic: &Rc<Cell<bool>>) {
|
|
||||||
while let Some(child) = list.first_child() {
|
|
||||||
list.remove(&child);
|
|
||||||
}
|
|
||||||
if rules.is_empty() {
|
|
||||||
let row = ListBoxRow::new();
|
|
||||||
row.set_selectable(false);
|
|
||||||
row.set_child(Some(&w::empty_state(
|
|
||||||
"security-high-symbolic",
|
|
||||||
"No rules",
|
|
||||||
"Only the default policy (deny incoming, allow outgoing) applies.",
|
|
||||||
)));
|
|
||||||
list.append(&row);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for rule in rules {
|
|
||||||
let row = ListBoxRow::new();
|
|
||||||
row.set_selectable(false);
|
|
||||||
let hbox = GBox::new(Orientation::Horizontal, 16);
|
|
||||||
hbox.add_css_class("card");
|
|
||||||
hbox.set_margin_top(3);
|
|
||||||
hbox.set_margin_bottom(3);
|
|
||||||
|
|
||||||
let text_lbl = Label::new(Some(&rule.text));
|
|
||||||
text_lbl.set_hexpand(true);
|
|
||||||
text_lbl.set_xalign(0.0);
|
|
||||||
text_lbl.set_wrap(true);
|
|
||||||
|
|
||||||
let remove_btn = Button::with_label("Remove");
|
|
||||||
remove_btn.add_css_class("destructive-action");
|
|
||||||
{
|
|
||||||
let list = list.clone();
|
|
||||||
let number = rule.number.clone();
|
|
||||||
let programmatic = programmatic.clone();
|
|
||||||
remove_btn.connect_clicked(move |_| {
|
|
||||||
let log_buf = gtk4::TextBuffer::new(None);
|
|
||||||
let list2 = list.clone();
|
|
||||||
let programmatic2 = programmatic.clone();
|
|
||||||
w::stream_command_then(
|
|
||||||
&["pkexec", "ufw", "--force", "delete", &number],
|
|
||||||
log_buf,
|
|
||||||
move || refresh(&list2, None, &programmatic2),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
hbox.append(&text_lbl);
|
|
||||||
hbox.append(&remove_btn);
|
|
||||||
row.set_child(Some(&hbox));
|
|
||||||
list.append(&row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Re-fetch status on a background thread and update the list (+ switch, if
|
|
||||||
/// given) on completion. `enabled_sw` is `None` when called from a row
|
|
||||||
/// action (delete/add) where the enabled state can't have changed.
|
|
||||||
///
|
|
||||||
/// `programmatic` guards against `set_active` below re-triggering the
|
|
||||||
/// switch's own `connect_active_notify` handler (which calls `ufw enable`/
|
|
||||||
/// `disable`) — without it, the very first Refresh after ufw turns out to
|
|
||||||
/// already be active would immediately fire an unwanted `ufw enable` the
|
|
||||||
/// moment `set_active(true)` flips a switch that just became sensitive.
|
|
||||||
fn refresh(list: &ListBox, enabled_sw: Option<&Switch>, programmatic: &Rc<Cell<bool>>) {
|
|
||||||
let (tx, rx) = async_channel::bounded::<Option<Status>>(1);
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let _ = tx.send_blocking(fetch_status());
|
|
||||||
});
|
|
||||||
let list = list.clone();
|
|
||||||
let enabled_sw = enabled_sw.cloned();
|
|
||||||
let programmatic = programmatic.clone();
|
|
||||||
glib::spawn_future_local(async move {
|
|
||||||
if let Ok(Some(status)) = rx.recv().await {
|
|
||||||
render_rules(&list, &status.rules, &programmatic);
|
|
||||||
if let Some(sw) = &enabled_sw {
|
|
||||||
programmatic.set(true);
|
|
||||||
sw.set_sensitive(true);
|
|
||||||
sw.set_active(status.active);
|
|
||||||
programmatic.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
|
||||||
let (outer, content) = w::view_scaffold("Firewall");
|
|
||||||
content.append(&w::hint(
|
|
||||||
"Reading and changing firewall state needs your password (polkit) \
|
|
||||||
— ufw requires root even just to check status. Click Refresh to \
|
|
||||||
load the current state.",
|
|
||||||
));
|
|
||||||
|
|
||||||
let programmatic = Rc::new(Cell::new(false));
|
|
||||||
|
|
||||||
let enabled_sw = Switch::new();
|
|
||||||
enabled_sw.set_sensitive(false);
|
|
||||||
content.append(&w::row("Firewall enabled", &enabled_sw));
|
|
||||||
content.append(&w::hint(
|
|
||||||
"Default policy: deny incoming, allow outgoing. mDNS (5353/udp) is \
|
|
||||||
allowed by default so printer/network discovery keeps working.",
|
|
||||||
));
|
|
||||||
|
|
||||||
let list = ListBox::new();
|
|
||||||
list.set_selection_mode(gtk4::SelectionMode::None);
|
|
||||||
{
|
|
||||||
let row = ListBoxRow::new();
|
|
||||||
row.set_selectable(false);
|
|
||||||
row.set_child(Some(&w::empty_state(
|
|
||||||
"security-high-symbolic",
|
|
||||||
"Status not loaded",
|
|
||||||
"Click Refresh below to check the firewall's current state.",
|
|
||||||
)));
|
|
||||||
list.append(&row);
|
|
||||||
}
|
|
||||||
let scroll = ScrolledWindow::new();
|
|
||||||
scroll.set_vexpand(true);
|
|
||||||
scroll.set_min_content_height(260);
|
|
||||||
scroll.set_child(Some(&list));
|
|
||||||
content.append(&scroll);
|
|
||||||
|
|
||||||
let refresh_btn = Button::with_label("Refresh status");
|
|
||||||
{
|
|
||||||
let list = list.clone();
|
|
||||||
let enabled_sw = enabled_sw.clone();
|
|
||||||
let programmatic = programmatic.clone();
|
|
||||||
refresh_btn.connect_clicked(move |_| refresh(&list, Some(&enabled_sw), &programmatic));
|
|
||||||
}
|
|
||||||
content.append(&refresh_btn);
|
|
||||||
|
|
||||||
{
|
|
||||||
let list = list.clone();
|
|
||||||
let programmatic = programmatic.clone();
|
|
||||||
enabled_sw.connect_active_notify(move |s| {
|
|
||||||
// Skip both the pre-refresh insensitive state and any
|
|
||||||
// programmatic set_active() from refresh() itself — only a
|
|
||||||
// real user click should call out to ufw enable/disable.
|
|
||||||
if !s.is_sensitive() || programmatic.get() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let verb = if s.is_active() { "enable" } else { "disable" };
|
|
||||||
let log_buf = gtk4::TextBuffer::new(None);
|
|
||||||
let list2 = list.clone();
|
|
||||||
let programmatic2 = programmatic.clone();
|
|
||||||
w::stream_command_then(&["pkexec", "ufw", "--force", verb], log_buf, move || {
|
|
||||||
refresh(&list2, None, &programmatic2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
content.append(&w::section("Add rule"));
|
|
||||||
content.append(&w::hint(
|
|
||||||
"e.g. \"8080/tcp\", \"22/tcp\", or a service name like \"OpenSSH\".",
|
|
||||||
));
|
|
||||||
let add_row = GBox::new(Orientation::Horizontal, 8);
|
|
||||||
let add_entry = Entry::new();
|
|
||||||
add_entry.set_hexpand(true);
|
|
||||||
add_entry.set_placeholder_text(Some("port/proto or service name"));
|
|
||||||
let add_btn = Button::with_label("Allow");
|
|
||||||
{
|
|
||||||
let list = list.clone();
|
|
||||||
let add_entry = add_entry.clone();
|
|
||||||
let programmatic = programmatic.clone();
|
|
||||||
add_btn.connect_clicked(move |_| {
|
|
||||||
let rule = add_entry.text().to_string();
|
|
||||||
if rule.trim().is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let log_buf = gtk4::TextBuffer::new(None);
|
|
||||||
let list2 = list.clone();
|
|
||||||
let add_entry2 = add_entry.clone();
|
|
||||||
let programmatic2 = programmatic.clone();
|
|
||||||
w::stream_command_then(
|
|
||||||
&["pkexec", "ufw", "allow", rule.trim()],
|
|
||||||
log_buf,
|
|
||||||
move || {
|
|
||||||
add_entry2.set_text("");
|
|
||||||
refresh(&list2, None, &programmatic2);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
add_row.append(&add_entry);
|
|
||||||
add_row.append(&add_btn);
|
|
||||||
content.append(&add_row);
|
|
||||||
|
|
||||||
outer
|
|
||||||
}
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
//! fwupd firmware updates — thin GUI over `fwupdmgr`, following the same
|
|
||||||
//! "stream command output, refresh the list on completion" pattern as
|
|
||||||
//! Packages. fwupd itself (the daemon + CLI) is always installed;
|
|
||||||
//! `fwupd-refresh.timer` already keeps the metadata current in the
|
|
||||||
//! background, this panel is just for actually seeing/applying updates.
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, TextView};
|
|
||||||
|
|
||||||
use crate::ui::widgets as w;
|
|
||||||
use crate::ui::widgets::stream_command_then;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct FwDevice {
|
|
||||||
name: String,
|
|
||||||
version: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list_updatable() -> Vec<FwDevice> {
|
|
||||||
let Ok(output) = std::process::Command::new("fwupdmgr")
|
|
||||||
.args(["get-devices", "--json"])
|
|
||||||
.output()
|
|
||||||
else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
let Ok(root) = serde_json::from_slice::<serde_json::Value>(&output.stdout) else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
let Some(devices) = root.get("Devices").and_then(|d| d.as_array()) else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
devices
|
|
||||||
.iter()
|
|
||||||
.filter(|d| {
|
|
||||||
d.get("Flags")
|
|
||||||
.and_then(|f| f.as_array())
|
|
||||||
.is_some_and(|flags| flags.iter().any(|f| f.as_str() == Some("updatable")))
|
|
||||||
})
|
|
||||||
.filter_map(|d| {
|
|
||||||
Some(FwDevice {
|
|
||||||
name: d.get("Name")?.as_str()?.to_string(),
|
|
||||||
version: d.get("Version").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn populate(list: &ListBox) {
|
|
||||||
while let Some(child) = list.first_child() {
|
|
||||||
list.remove(&child);
|
|
||||||
}
|
|
||||||
let devices = list_updatable();
|
|
||||||
if devices.is_empty() {
|
|
||||||
let row = ListBoxRow::new();
|
|
||||||
row.set_selectable(false);
|
|
||||||
row.set_child(Some(&w::empty_state(
|
|
||||||
"software-update-available-symbolic",
|
|
||||||
"No updatable firmware devices found",
|
|
||||||
"Not every device supports firmware updates through fwupd.",
|
|
||||||
)));
|
|
||||||
list.append(&row);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for dev in devices {
|
|
||||||
let row = ListBoxRow::new();
|
|
||||||
row.set_selectable(false);
|
|
||||||
let hbox = GBox::new(Orientation::Horizontal, 16);
|
|
||||||
hbox.add_css_class("card");
|
|
||||||
hbox.set_margin_top(3);
|
|
||||||
hbox.set_margin_bottom(3);
|
|
||||||
|
|
||||||
let name_lbl = Label::new(Some(&dev.name));
|
|
||||||
name_lbl.set_hexpand(true);
|
|
||||||
name_lbl.set_xalign(0.0);
|
|
||||||
let ver_lbl = Label::new(Some(&dev.version));
|
|
||||||
ver_lbl.add_css_class("dim-label");
|
|
||||||
ver_lbl.set_xalign(1.0);
|
|
||||||
|
|
||||||
hbox.append(&name_lbl);
|
|
||||||
hbox.append(&ver_lbl);
|
|
||||||
row.set_child(Some(&hbox));
|
|
||||||
list.append(&row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
|
||||||
let (outer, content) = w::view_scaffold("Firmware");
|
|
||||||
content.append(&w::hint(
|
|
||||||
"Firmware updates for hardware that supports them (UEFI, some \
|
|
||||||
peripherals). fwupd-refresh.timer already keeps update metadata \
|
|
||||||
current in the background.",
|
|
||||||
));
|
|
||||||
|
|
||||||
let list = ListBox::new();
|
|
||||||
list.set_selection_mode(gtk4::SelectionMode::None);
|
|
||||||
populate(&list);
|
|
||||||
let scroll = ScrolledWindow::new();
|
|
||||||
scroll.set_vexpand(true);
|
|
||||||
scroll.set_child(Some(&list));
|
|
||||||
content.append(&scroll);
|
|
||||||
|
|
||||||
let log_buf = gtk4::TextBuffer::new(None);
|
|
||||||
let log_view = TextView::with_buffer(&log_buf);
|
|
||||||
log_view.set_editable(false);
|
|
||||||
log_view.set_monospace(true);
|
|
||||||
log_view.set_height_request(140);
|
|
||||||
log_view.set_margin_top(8);
|
|
||||||
log_view.set_visible(false);
|
|
||||||
|
|
||||||
let btn_row = GBox::new(Orientation::Horizontal, 8);
|
|
||||||
btn_row.set_margin_top(12);
|
|
||||||
|
|
||||||
let check_btn = Button::with_label("Check for updates");
|
|
||||||
{
|
|
||||||
let log_buf = log_buf.clone();
|
|
||||||
let log_view = log_view.clone();
|
|
||||||
let list = list.clone();
|
|
||||||
check_btn.connect_clicked(move |_| {
|
|
||||||
log_buf.set_text("");
|
|
||||||
log_view.set_visible(true);
|
|
||||||
let list2 = list.clone();
|
|
||||||
stream_command_then(&["fwupdmgr", "refresh"], log_buf.clone(), move || {
|
|
||||||
populate(&list2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let update_btn = Button::with_label("Update all");
|
|
||||||
update_btn.add_css_class("suggested-action");
|
|
||||||
{
|
|
||||||
let log_buf = log_buf.clone();
|
|
||||||
let log_view = log_view.clone();
|
|
||||||
let list = list.clone();
|
|
||||||
update_btn.connect_clicked(move |_| {
|
|
||||||
log_buf.set_text("");
|
|
||||||
log_view.set_visible(true);
|
|
||||||
let list2 = list.clone();
|
|
||||||
stream_command_then(&["fwupdmgr", "update", "-y"], log_buf.clone(), move || {
|
|
||||||
populate(&list2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let refresh_btn = Button::with_label("Refresh list");
|
|
||||||
{
|
|
||||||
let list = list.clone();
|
|
||||||
refresh_btn.connect_clicked(move |_| {
|
|
||||||
populate(&list);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
btn_row.append(&check_btn);
|
|
||||||
btn_row.append(&update_btn);
|
|
||||||
btn_row.append(&refresh_btn);
|
|
||||||
content.append(&btn_row);
|
|
||||||
content.append(&log_view);
|
|
||||||
|
|
||||||
outer
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{Box as GBox, Button};
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
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();
|
|
||||||
};
|
|
||||||
let text = String::from_utf8_lossy(&output.stdout);
|
|
||||||
let Ok(monitors) = serde_json::from_str::<Vec<serde_json::Value>>(&text) else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
monitors
|
|
||||||
.iter()
|
|
||||||
.filter_map(|m| {
|
|
||||||
let name = m.get("name")?.as_str()?;
|
|
||||||
let w = m.get("width")?.as_u64()?;
|
|
||||||
let h = m.get("height")?.as_u64()?;
|
|
||||||
let refresh = m.get("refreshRate")?.as_f64()?;
|
|
||||||
Some((name.to_string(), format!("{w}x{h} @ {refresh:.0}Hz")))
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hypr_path(name: &str) -> std::path::PathBuf {
|
|
||||||
crate::config::config_dir().join("hypr").join(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open `path` in $EDITOR (nano if unset) inside a terminal window. Spawning
|
|
||||||
/// an editor directly (no terminal) is a silent no-op for any TUI editor —
|
|
||||||
/// there's nothing for it to attach to — so it always needs a terminal
|
|
||||||
/// wrapper. Uses kitty, which is what BOS actually ships (not foot).
|
|
||||||
fn open_in_terminal(path: &std::path::Path) {
|
|
||||||
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string());
|
|
||||||
if let Ok(mut child) = Command::new("kitty").args(["-e", &editor]).arg(path).spawn() {
|
|
||||||
std::thread::spawn(move || { let _ = child.wait(); });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
|
||||||
let (outer, content) = w::view_scaffold("Display");
|
|
||||||
|
|
||||||
content.append(&w::section("Connected monitors"));
|
|
||||||
let monitors = get_monitors();
|
|
||||||
if monitors.is_empty() {
|
|
||||||
content.append(&w::hint("No monitors detected (is Hyprland running?)"));
|
|
||||||
} else {
|
|
||||||
for (name, mode) in &monitors {
|
|
||||||
content.append(&w::info_row(name, mode));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
content.append(&w::section("Configuration"));
|
|
||||||
content.append(&w::hint(
|
|
||||||
"Monitor layout, keyboard/input, and workspace rules are configured \
|
|
||||||
directly in hyprland.lua — there's no live editor for them here yet.",
|
|
||||||
));
|
|
||||||
|
|
||||||
// BOS's Hyprland config is Lua-native (hyprland.lua), not the classic
|
|
||||||
// hyprland.conf/keybinds.conf pair — those names only ever matched a
|
|
||||||
// stale, unshipped dotfiles/ directory, so this button opened (or
|
|
||||||
// silently created) the wrong file entirely.
|
|
||||||
let open_btn = Button::with_label("Open hyprland.lua in editor");
|
|
||||||
open_btn.set_halign(gtk4::Align::Start);
|
|
||||||
{
|
|
||||||
let conf_path = hypr_path("hyprland.lua");
|
|
||||||
open_btn.connect_clicked(move |_| open_in_terminal(&conf_path));
|
|
||||||
}
|
|
||||||
content.append(&open_btn);
|
|
||||||
|
|
||||||
// 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 = std::path::PathBuf::from("/usr/share/bos/keybinds.txt");
|
|
||||||
keybinds_btn.connect_clicked(move |_| open_in_terminal(&kb_path));
|
|
||||||
}
|
|
||||||
content.append(&keybinds_btn);
|
|
||||||
|
|
||||||
outer
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,328 +0,0 @@
|
||||||
//! Wi-Fi + Ethernet over `nmcli`. NetworkManager lets the active session user
|
|
||||||
//! manage connections via polkit already, so the common paths (scan, connect,
|
|
||||||
//! toggle radio) need no `pkexec`. VPN import, 802.1x, and other edge cases
|
|
||||||
//! are punted to `nm-connection-editor` via the Advanced button rather than
|
|
||||||
//! reimplemented here.
|
|
||||||
//!
|
|
||||||
//! The scan is deliberately NOT run in `build()` — every view is constructed
|
|
||||||
//! eagerly at app launch (see `window.rs`), and a Wi-Fi scan takes seconds;
|
|
||||||
//! doing it here would add that latency to every bos-settings launch. It
|
|
||||||
//! only runs when the user clicks Scan.
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch};
|
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::process::Command;
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use crate::ui::widgets as w;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct WifiNetwork {
|
|
||||||
ssid: String,
|
|
||||||
signal: i32,
|
|
||||||
secured: bool,
|
|
||||||
active: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn radio_enabled() -> bool {
|
|
||||||
Command::new("nmcli")
|
|
||||||
.args(["radio", "wifi"])
|
|
||||||
.output()
|
|
||||||
.ok()
|
|
||||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "enabled")
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ethernet_status() -> Option<String> {
|
|
||||||
let out = Command::new("nmcli").args(["-t", "-f", "DEVICE,TYPE,STATE"]).arg("dev").output().ok()?;
|
|
||||||
let text = String::from_utf8_lossy(&out.stdout);
|
|
||||||
text.lines().find_map(|l| {
|
|
||||||
let mut cols = l.splitn(3, ':');
|
|
||||||
let (dev, ty, state) = (cols.next()?, cols.next()?, cols.next()?);
|
|
||||||
(ty == "ethernet").then(|| format!("{dev}: {state}"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn known_connection_names() -> HashSet<String> {
|
|
||||||
let Ok(out) = Command::new("nmcli").args(["-t", "-f", "NAME"]).arg("con").arg("show").output() else {
|
|
||||||
return HashSet::new();
|
|
||||||
};
|
|
||||||
String::from_utf8_lossy(&out.stdout).lines().map(str::to_string).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scan + list Wi-Fi networks, deduplicated by SSID (keeping the strongest
|
|
||||||
/// signal — the same AP shows once per band/BSSID otherwise).
|
|
||||||
fn scan_wifi() -> Vec<WifiNetwork> {
|
|
||||||
let _ = Command::new("nmcli").args(["dev", "wifi", "rescan"]).output();
|
|
||||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
|
||||||
let Ok(out) = Command::new("nmcli")
|
|
||||||
.args(["-t", "-f", "SSID,SIGNAL,SECURITY,IN-USE", "dev", "wifi", "list"])
|
|
||||||
.output()
|
|
||||||
else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
let text = String::from_utf8_lossy(&out.stdout);
|
|
||||||
let mut by_ssid: std::collections::HashMap<String, WifiNetwork> = std::collections::HashMap::new();
|
|
||||||
for line in text.lines() {
|
|
||||||
let mut cols = line.splitn(4, ':');
|
|
||||||
let (ssid, signal, security, in_use) = (cols.next(), cols.next(), cols.next(), cols.next());
|
|
||||||
let Some(ssid) = ssid.filter(|s| !s.is_empty()) else { continue };
|
|
||||||
let signal: i32 = signal.and_then(|s| s.parse().ok()).unwrap_or(0);
|
|
||||||
let net = WifiNetwork {
|
|
||||||
ssid: ssid.to_string(),
|
|
||||||
signal,
|
|
||||||
secured: security.map(|s| !s.is_empty()).unwrap_or(false),
|
|
||||||
active: in_use == Some("*"),
|
|
||||||
};
|
|
||||||
by_ssid
|
|
||||||
.entry(ssid.to_string())
|
|
||||||
.and_modify(|existing| if net.signal > existing.signal { *existing = net.clone() })
|
|
||||||
.or_insert(net);
|
|
||||||
}
|
|
||||||
let mut list: Vec<_> = by_ssid.into_values().collect();
|
|
||||||
list.sort_by(|a, b| b.signal.cmp(&a.signal));
|
|
||||||
list
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
|
||||||
let (outer, content) = w::view_scaffold("Network");
|
|
||||||
|
|
||||||
content.append(&w::section("Wi-Fi"));
|
|
||||||
let radio_sw = Switch::new();
|
|
||||||
radio_sw.set_active(radio_enabled());
|
|
||||||
radio_sw.connect_active_notify(|s| {
|
|
||||||
let val = if s.is_active() { "on" } else { "off" };
|
|
||||||
let _ = Command::new("nmcli").args(["radio", "wifi", val]).spawn();
|
|
||||||
});
|
|
||||||
content.append(&w::row("Wi-Fi radio", &radio_sw));
|
|
||||||
|
|
||||||
let status = Label::new(None);
|
|
||||||
status.add_css_class("dim-label");
|
|
||||||
status.set_xalign(0.0);
|
|
||||||
|
|
||||||
let list = ListBox::new();
|
|
||||||
list.set_selection_mode(gtk4::SelectionMode::None);
|
|
||||||
// Starts with an empty_state row ("Not scanned yet") rather than being
|
|
||||||
// hidden — the "no results yet" and "scanned, found nothing" states are
|
|
||||||
// the same idea and should look the same, not one a hint label and the
|
|
||||||
// other an empty_state card.
|
|
||||||
{
|
|
||||||
let row = ListBoxRow::new();
|
|
||||||
row.set_selectable(false);
|
|
||||||
row.set_child(Some(&w::empty_state(
|
|
||||||
"network-wireless-symbolic",
|
|
||||||
"Not scanned yet",
|
|
||||||
"Press Scan to see nearby networks.",
|
|
||||||
)));
|
|
||||||
list.append(&row);
|
|
||||||
}
|
|
||||||
let scroll = ScrolledWindow::new();
|
|
||||||
scroll.set_min_content_height(220);
|
|
||||||
scroll.set_child(Some(&list));
|
|
||||||
content.append(&scroll);
|
|
||||||
|
|
||||||
// Password entry, shown only while connecting to a secured, unknown
|
|
||||||
// network — set visible/hidden rather than a modal dialog, to keep this
|
|
||||||
// panel's async flow in one place instead of a nested dialog callback.
|
|
||||||
let pw_row = GBox::new(Orientation::Horizontal, 8);
|
|
||||||
let pw_entry = Entry::new();
|
|
||||||
pw_entry.set_visibility(false);
|
|
||||||
pw_entry.set_hexpand(true);
|
|
||||||
pw_entry.set_placeholder_text(Some("Password"));
|
|
||||||
let pw_connect_btn = Button::with_label("Connect");
|
|
||||||
pw_row.append(&pw_entry);
|
|
||||||
pw_row.append(&pw_connect_btn);
|
|
||||||
pw_row.set_visible(false);
|
|
||||||
content.append(&pw_row);
|
|
||||||
content.append(&status);
|
|
||||||
|
|
||||||
let pending_ssid: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
|
|
||||||
|
|
||||||
let connect_open_or_known = {
|
|
||||||
let status = status.clone();
|
|
||||||
move |ssid: String| {
|
|
||||||
status.set_text(&format!("Connecting to {ssid}…"));
|
|
||||||
let log_buf = gtk4::TextBuffer::new(None);
|
|
||||||
let status2 = status.clone();
|
|
||||||
let ssid2 = ssid.clone();
|
|
||||||
let known = known_connection_names();
|
|
||||||
let args: Vec<String> = if known.contains(&ssid) {
|
|
||||||
vec!["nmcli".into(), "con".into(), "up".into(), ssid.clone()]
|
|
||||||
} else {
|
|
||||||
vec!["nmcli".into(), "dev".into(), "wifi".into(), "connect".into(), ssid.clone()]
|
|
||||||
};
|
|
||||||
let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
|
|
||||||
w::stream_command_then(&args_ref, log_buf.clone(), move || {
|
|
||||||
let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false);
|
|
||||||
if text.to_lowercase().contains("error") {
|
|
||||||
status2.set_text(&format!("Failed to connect to {ssid2}: {}", text.trim()));
|
|
||||||
} else {
|
|
||||||
status2.set_text(&format!("Connected to {ssid2}"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
{
|
|
||||||
let pw_row = pw_row.clone();
|
|
||||||
let pw_entry = pw_entry.clone();
|
|
||||||
let pending_ssid = pending_ssid.clone();
|
|
||||||
let status = status.clone();
|
|
||||||
pw_connect_btn.connect_clicked(move |_| {
|
|
||||||
let Some(ssid) = pending_ssid.borrow_mut().take() else { return };
|
|
||||||
let password = pw_entry.text().to_string();
|
|
||||||
pw_row.set_visible(false);
|
|
||||||
pw_entry.set_text("");
|
|
||||||
status.set_text(&format!("Connecting to {ssid}…"));
|
|
||||||
let log_buf = gtk4::TextBuffer::new(None);
|
|
||||||
let status2 = status.clone();
|
|
||||||
let ssid2 = ssid.clone();
|
|
||||||
w::stream_command_then(
|
|
||||||
&["nmcli", "dev", "wifi", "connect", &ssid, "password", &password],
|
|
||||||
log_buf.clone(),
|
|
||||||
move || {
|
|
||||||
let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false);
|
|
||||||
if text.to_lowercase().contains("error") {
|
|
||||||
status2.set_text(&format!("Failed to connect to {ssid2}: wrong password?"));
|
|
||||||
} else {
|
|
||||||
status2.set_text(&format!("Connected to {ssid2}"));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let populate_list = {
|
|
||||||
let list = list.clone();
|
|
||||||
let pw_row = pw_row.clone();
|
|
||||||
let pending_ssid = pending_ssid.clone();
|
|
||||||
let connect_open_or_known = connect_open_or_known.clone();
|
|
||||||
move |networks: Vec<WifiNetwork>| {
|
|
||||||
while let Some(child) = list.first_child() {
|
|
||||||
list.remove(&child);
|
|
||||||
}
|
|
||||||
if networks.is_empty() {
|
|
||||||
let row = ListBoxRow::new();
|
|
||||||
row.set_selectable(false);
|
|
||||||
row.set_child(Some(&w::empty_state(
|
|
||||||
"network-wireless-offline-symbolic",
|
|
||||||
"No networks found",
|
|
||||||
"Try Scan again, or check Wi-Fi radio is on.",
|
|
||||||
)));
|
|
||||||
list.append(&row);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let known = known_connection_names();
|
|
||||||
for net in networks {
|
|
||||||
let row = ListBoxRow::new();
|
|
||||||
let hbox = GBox::new(Orientation::Horizontal, 12);
|
|
||||||
hbox.set_margin_top(4);
|
|
||||||
hbox.set_margin_bottom(4);
|
|
||||||
hbox.set_margin_start(8);
|
|
||||||
hbox.set_margin_end(8);
|
|
||||||
|
|
||||||
let name = if net.active {
|
|
||||||
format!("{} (connected)", net.ssid)
|
|
||||||
} else {
|
|
||||||
net.ssid.clone()
|
|
||||||
};
|
|
||||||
let name_lbl = Label::new(Some(&name));
|
|
||||||
name_lbl.set_hexpand(true);
|
|
||||||
name_lbl.set_xalign(0.0);
|
|
||||||
if net.active {
|
|
||||||
name_lbl.add_css_class("heading");
|
|
||||||
}
|
|
||||||
|
|
||||||
let signal_icon_name = match net.signal.clamp(0, 100) {
|
|
||||||
0..=24 => "network-wireless-signal-weak-symbolic",
|
|
||||||
25..=49 => "network-wireless-signal-ok-symbolic",
|
|
||||||
50..=74 => "network-wireless-signal-good-symbolic",
|
|
||||||
_ => "network-wireless-signal-excellent-symbolic",
|
|
||||||
};
|
|
||||||
let signal_icon = gtk4::Image::from_icon_name(signal_icon_name);
|
|
||||||
signal_icon.add_css_class("dim-label");
|
|
||||||
|
|
||||||
let meta_lbl = Label::new(Some(&format!("{}%", net.signal.clamp(0, 100))));
|
|
||||||
meta_lbl.add_css_class("dim-label");
|
|
||||||
|
|
||||||
hbox.append(&name_lbl);
|
|
||||||
if net.secured {
|
|
||||||
let lock_icon = gtk4::Image::from_icon_name("channel-secure-symbolic");
|
|
||||||
lock_icon.add_css_class("dim-label");
|
|
||||||
hbox.append(&lock_icon);
|
|
||||||
}
|
|
||||||
hbox.append(&signal_icon);
|
|
||||||
hbox.append(&meta_lbl);
|
|
||||||
|
|
||||||
if !net.active {
|
|
||||||
let connect_btn = Button::with_label("Connect");
|
|
||||||
let ssid = net.ssid.clone();
|
|
||||||
let secured = net.secured;
|
|
||||||
let already_known = known.contains(&net.ssid);
|
|
||||||
let pw_row = pw_row.clone();
|
|
||||||
let pending_ssid = pending_ssid.clone();
|
|
||||||
let connect_open_or_known = connect_open_or_known.clone();
|
|
||||||
connect_btn.connect_clicked(move |_| {
|
|
||||||
if secured && !already_known {
|
|
||||||
*pending_ssid.borrow_mut() = Some(ssid.clone());
|
|
||||||
pw_row.set_visible(true);
|
|
||||||
} else {
|
|
||||||
pw_row.set_visible(false);
|
|
||||||
connect_open_or_known(ssid.clone());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
hbox.append(&connect_btn);
|
|
||||||
}
|
|
||||||
|
|
||||||
row.set_child(Some(&hbox));
|
|
||||||
list.append(&row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let scan_btn = Button::with_label("Scan");
|
|
||||||
scan_btn.set_halign(gtk4::Align::Start);
|
|
||||||
{
|
|
||||||
let status = status.clone();
|
|
||||||
let populate_list = populate_list.clone();
|
|
||||||
scan_btn.connect_clicked(move |btn| {
|
|
||||||
btn.set_sensitive(false);
|
|
||||||
status.set_text("Scanning…");
|
|
||||||
let (tx, rx) = async_channel::bounded::<Vec<WifiNetwork>>(1);
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let nets = scan_wifi();
|
|
||||||
let _ = tx.send_blocking(nets);
|
|
||||||
});
|
|
||||||
let btn = btn.clone();
|
|
||||||
let status = status.clone();
|
|
||||||
let populate_list = populate_list.clone();
|
|
||||||
glib::spawn_future_local(async move {
|
|
||||||
if let Ok(nets) = rx.recv().await {
|
|
||||||
let count = nets.len();
|
|
||||||
populate_list(nets);
|
|
||||||
status.set_text(&format!("Found {count} network(s)"));
|
|
||||||
}
|
|
||||||
btn.set_sensitive(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
content.append(&scan_btn);
|
|
||||||
|
|
||||||
if let Some(eth) = ethernet_status() {
|
|
||||||
content.append(&w::section("Ethernet"));
|
|
||||||
content.append(&w::info_row("Status", ð));
|
|
||||||
}
|
|
||||||
|
|
||||||
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,205 +0,0 @@
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{
|
|
||||||
Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, TextView,
|
|
||||||
};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
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());
|
|
||||||
let path = std::path::Path::new(&home)
|
|
||||||
.join(".local/state/bakery/installed.json");
|
|
||||||
|
|
||||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
|
||||||
return HashMap::new();
|
|
||||||
};
|
|
||||||
let Ok(mut parsed) = serde_json::from_str::<serde_json::Value>(&text) else {
|
|
||||||
return HashMap::new();
|
|
||||||
};
|
|
||||||
// installed.json is {"packages": {name: {version, binaries, services}}},
|
|
||||||
// not a flat map of package name to metadata — without unwrapping this,
|
|
||||||
// every install shows a single bogus row named "packages".
|
|
||||||
let Some(packages) = parsed.get_mut("packages").map(std::mem::take) else {
|
|
||||||
return HashMap::new();
|
|
||||||
};
|
|
||||||
let Ok(packages) = serde_json::from_value::<HashMap<String, serde_json::Value>>(packages) else {
|
|
||||||
return HashMap::new();
|
|
||||||
};
|
|
||||||
|
|
||||||
packages
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|(name, val)| {
|
|
||||||
let version = val
|
|
||||||
.get("version")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("unknown")
|
|
||||||
.to_string();
|
|
||||||
Some((name, version))
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer, log_view: &TextView) {
|
|
||||||
while let Some(child) = list.first_child() {
|
|
||||||
list.remove(&child);
|
|
||||||
}
|
|
||||||
|
|
||||||
let packages = read_installed();
|
|
||||||
if packages.is_empty() {
|
|
||||||
let row = ListBoxRow::new();
|
|
||||||
row.set_selectable(false);
|
|
||||||
row.set_child(Some(&w::empty_state(
|
|
||||||
"package-x-generic-symbolic",
|
|
||||||
"No bakery packages found",
|
|
||||||
"~/.local/state/bakery/installed.json is missing or empty.",
|
|
||||||
)));
|
|
||||||
list.append(&row);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut names: Vec<_> = packages.iter().collect();
|
|
||||||
names.sort_by_key(|(k, _)| k.as_str());
|
|
||||||
|
|
||||||
for (name, version) in names {
|
|
||||||
let row = ListBoxRow::new();
|
|
||||||
row.set_selectable(false);
|
|
||||||
let hbox = GBox::new(Orientation::Horizontal, 16);
|
|
||||||
hbox.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);
|
|
||||||
name_lbl.set_xalign(0.0);
|
|
||||||
|
|
||||||
let ver_lbl = Label::new(Some(version));
|
|
||||||
ver_lbl.set_xalign(1.0);
|
|
||||||
|
|
||||||
let pkg_name = name.clone();
|
|
||||||
let update_btn = Button::with_label("Update");
|
|
||||||
{
|
|
||||||
let log_buf = log_buf.clone();
|
|
||||||
let log_view = log_view.clone();
|
|
||||||
let list = list.clone();
|
|
||||||
update_btn.connect_clicked(move |_| {
|
|
||||||
log_buf.set_text("");
|
|
||||||
log_view.set_visible(true);
|
|
||||||
let list2 = list.clone();
|
|
||||||
let log_buf2 = log_buf.clone();
|
|
||||||
let log_view2 = log_view.clone();
|
|
||||||
// Route through stream_command (like the other buttons) so
|
|
||||||
// output is visible and the row refreshes with the new
|
|
||||||
// version once the update actually finishes — previously
|
|
||||||
// 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);
|
|
||||||
hbox.append(&update_btn);
|
|
||||||
row.set_child(Some(&hbox));
|
|
||||||
list.append(&row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
content.append(&btn_row);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
// System packages (pacman) — the other update channel. bakery only
|
|
||||||
// covers the userspace bread apps; base system/kernel/bos-settings/AUR
|
|
||||||
// republished packages come from pacman + the [breadway] repo, and
|
|
||||||
// bos-update (the CLI) already updates both — this panel previously
|
|
||||||
// only exposed the bakery half, so a user relying on it alone would
|
|
||||||
// never get base-system updates through the GUI.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,193 +0,0 @@
|
||||||
//! Battery/power status (upower), brightness (brightnessctl), and TLP's
|
|
||||||
//! current profile. Deliberately no AC/Battery/Performance *switcher* —
|
|
||||||
//! TLP's whole model on BOS is automatic profile selection by power source
|
|
||||||
//! (see packages.x86_64: power-profiles-daemon is intentionally not
|
|
||||||
//! installed, it conflicts with tlp), so this panel is read-only for TLP
|
|
||||||
//! and only exposes controls for things that are actually user choices:
|
|
||||||
//! brightness, and charge thresholds where the hardware supports them.
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{Box as GBox, Scale};
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
use crate::ui::widgets as w;
|
|
||||||
|
|
||||||
fn upower_device(kind: &str) -> Option<String> {
|
|
||||||
let out = Command::new("upower").arg("-e").output().ok()?;
|
|
||||||
String::from_utf8_lossy(&out.stdout)
|
|
||||||
.lines()
|
|
||||||
.find(|l| l.to_lowercase().contains(kind))
|
|
||||||
.map(str::to_string)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn upower_field(device: &str, field: &str) -> Option<String> {
|
|
||||||
let out = Command::new("upower").args(["-i", device]).output().ok()?;
|
|
||||||
let text = String::from_utf8_lossy(&out.stdout);
|
|
||||||
text.lines()
|
|
||||||
.find(|l| l.trim_start().starts_with(field))
|
|
||||||
.and_then(|l| l.split(':').nth(1))
|
|
||||||
.map(|v| v.trim().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn battery_summary() -> Vec<(String, String)> {
|
|
||||||
let Some(bat) = upower_device("bat") else {
|
|
||||||
return vec![("Battery".to_string(), "No battery detected".to_string())];
|
|
||||||
};
|
|
||||||
let mut rows = Vec::new();
|
|
||||||
if let Some(state) = upower_field(&bat, "state") {
|
|
||||||
rows.push(("Status".to_string(), state));
|
|
||||||
}
|
|
||||||
if let Some(pct) = upower_field(&bat, "percentage") {
|
|
||||||
rows.push(("Charge".to_string(), pct));
|
|
||||||
}
|
|
||||||
if let Some(t) = upower_field(&bat, "time to empty").or_else(|| upower_field(&bat, "time to full")) {
|
|
||||||
rows.push(("Time remaining".to_string(), t));
|
|
||||||
}
|
|
||||||
let full: Option<f64> = upower_field(&bat, "energy-full").and_then(|v| {
|
|
||||||
v.split_whitespace().next()?.parse().ok()
|
|
||||||
});
|
|
||||||
let design: Option<f64> = upower_field(&bat, "energy-full-design").and_then(|v| {
|
|
||||||
v.split_whitespace().next()?.parse().ok()
|
|
||||||
});
|
|
||||||
if let (Some(full), Some(design)) = (full, design) {
|
|
||||||
if design > 0.0 {
|
|
||||||
rows.push(("Battery health".to_string(), format!("{:.0}% of design capacity", full / design * 100.0)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows
|
|
||||||
}
|
|
||||||
|
|
||||||
fn power_source() -> String {
|
|
||||||
match upower_device("ac").and_then(|ac| upower_field(&ac, "online")) {
|
|
||||||
Some(v) if v == "yes" => "AC power".to_string(),
|
|
||||||
Some(_) => "Battery".to_string(),
|
|
||||||
None => "Unknown".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tlp_profile() -> Option<String> {
|
|
||||||
let out = Command::new("tlp-stat").arg("-s").output().ok()?;
|
|
||||||
let text = String::from_utf8_lossy(&out.stdout);
|
|
||||||
text.lines()
|
|
||||||
.find(|l| l.trim_start().starts_with("TLP profile"))
|
|
||||||
.and_then(|l| l.split('=').nth(1))
|
|
||||||
.map(|v| v.trim().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn brightness_device() -> Option<String> {
|
|
||||||
let out = Command::new("brightnessctl").output().ok()?;
|
|
||||||
String::from_utf8_lossy(&out.stdout)
|
|
||||||
.lines()
|
|
||||||
.find(|l| l.starts_with("Device"))
|
|
||||||
.and_then(|l| l.split('\'').nth(1))
|
|
||||||
.map(str::to_string)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn brightness_pct() -> Option<u32> {
|
|
||||||
let out = Command::new("brightnessctl").output().ok()?;
|
|
||||||
let text = String::from_utf8_lossy(&out.stdout);
|
|
||||||
text.lines()
|
|
||||||
.find(|l| l.contains("Current brightness"))
|
|
||||||
.and_then(|l| l.split('(').nth(1))
|
|
||||||
.and_then(|v| v.trim_end_matches("%)").parse().ok())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Charge-threshold sysfs paths, only Some when the running kernel driver
|
|
||||||
/// actually exposes them (ideapad_laptop/thinkpad_acpi on some models;
|
|
||||||
/// nothing on this dev laptop's Yoga Slim 7 — this is genuinely
|
|
||||||
/// hardware-dependent, not something every install will have).
|
|
||||||
fn charge_threshold_paths() -> Option<(std::path::PathBuf, std::path::PathBuf)> {
|
|
||||||
let base = std::path::Path::new("/sys/class/power_supply");
|
|
||||||
let entries = std::fs::read_dir(base).ok()?;
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let start = entry.path().join("charge_control_start_threshold");
|
|
||||||
let end = entry.path().join("charge_control_end_threshold");
|
|
||||||
if start.exists() && end.exists() {
|
|
||||||
return Some((start, end));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_threshold(path: &std::path::Path) -> i64 {
|
|
||||||
std::fs::read_to_string(path)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| s.trim().parse().ok())
|
|
||||||
.unwrap_or(100)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
|
||||||
let (outer, c) = w::view_scaffold("Power");
|
|
||||||
|
|
||||||
c.append(&w::section("Battery"));
|
|
||||||
for (label, value) in battery_summary() {
|
|
||||||
c.append(&w::info_row(&label, &value));
|
|
||||||
}
|
|
||||||
c.append(&w::info_row("Power source", &power_source()));
|
|
||||||
|
|
||||||
c.append(&w::section("Brightness"));
|
|
||||||
if let Some(pct) = brightness_pct() {
|
|
||||||
let scale = Scale::with_range(gtk4::Orientation::Horizontal, 1.0, 100.0, 1.0);
|
|
||||||
scale.set_value(pct as f64);
|
|
||||||
scale.set_hexpand(true);
|
|
||||||
scale.set_draw_value(true);
|
|
||||||
if let Some(device) = brightness_device() {
|
|
||||||
scale.connect_value_changed(move |s| {
|
|
||||||
let pct = format!("{}%", s.value() as i64);
|
|
||||||
let _ = Command::new("brightnessctl")
|
|
||||||
.args(["--device", &device, "set", &pct])
|
|
||||||
.spawn();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
c.append(&w::row("Screen brightness", &scale));
|
|
||||||
} else {
|
|
||||||
c.append(&w::hint("No controllable backlight found."));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((start_path, end_path)) = charge_threshold_paths() {
|
|
||||||
c.append(&w::section("Charge limits"));
|
|
||||||
c.append(&w::hint(
|
|
||||||
"Some laptops let you cap charging below 100% to slow battery \
|
|
||||||
wear on a machine that's mostly plugged in. Not every model \
|
|
||||||
supports this — shown here because yours reported it does.",
|
|
||||||
));
|
|
||||||
let start_adj = gtk4::Adjustment::new(read_threshold(&start_path) as f64, 0.0, 100.0, 1.0, 5.0, 0.0);
|
|
||||||
let start_spin = gtk4::SpinButton::new(Some(&start_adj), 1.0, 0);
|
|
||||||
{
|
|
||||||
let path = start_path.clone();
|
|
||||||
start_spin.connect_value_changed(move |s| {
|
|
||||||
let val = (s.value() as i64).to_string();
|
|
||||||
let _ = Command::new("pkexec")
|
|
||||||
.args(["tee", &path.display().to_string()])
|
|
||||||
.arg(&val)
|
|
||||||
.output();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
c.append(&w::row("Start charging below (%)", &start_spin));
|
|
||||||
|
|
||||||
let end_adj = gtk4::Adjustment::new(read_threshold(&end_path) as f64, 1.0, 100.0, 1.0, 5.0, 0.0);
|
|
||||||
let end_spin = gtk4::SpinButton::new(Some(&end_adj), 1.0, 0);
|
|
||||||
{
|
|
||||||
let path = end_path.clone();
|
|
||||||
end_spin.connect_value_changed(move |s| {
|
|
||||||
let val = (s.value() as i64).to_string();
|
|
||||||
let _ = Command::new("pkexec")
|
|
||||||
.args(["tee", &path.display().to_string()])
|
|
||||||
.arg(&val)
|
|
||||||
.output();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
c.append(&w::row("Stop charging at (%)", &end_spin));
|
|
||||||
}
|
|
||||||
|
|
||||||
c.append(&w::section("TLP"));
|
|
||||||
c.append(&w::hint(
|
|
||||||
"TLP automatically applies a power-saving profile on battery and a \
|
|
||||||
performance profile on AC — there's no manual profile switch here \
|
|
||||||
by design (power-profiles-daemon isn't installed; it conflicts \
|
|
||||||
with tlp).",
|
|
||||||
));
|
|
||||||
c.append(&w::info_row("Current profile", tlp_profile().as_deref().unwrap_or("unknown")));
|
|
||||||
|
|
||||||
outer
|
|
||||||
}
|
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{
|
|
||||||
AlertDialog, Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow,
|
|
||||||
};
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
use crate::ui::widgets as w;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct SnapshotRow {
|
|
||||||
number: String,
|
|
||||||
date: String,
|
|
||||||
description: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list_snapshots() -> Vec<SnapshotRow> {
|
|
||||||
// NOTE: the real flag is --columns, not --output-cols (which snapper
|
|
||||||
// rejects outright with "Unknown option") — confirmed against snapper
|
|
||||||
// 0.13's own --help. With the wrong flag this always failed and the
|
|
||||||
// panel silently showed "No snapshots found" on every install.
|
|
||||||
let Ok(output) = Command::new("snapper")
|
|
||||||
.args(["list", "--columns", "number,date,description"])
|
|
||||||
.output()
|
|
||||||
else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
if !output.status.success() {
|
|
||||||
eprintln!(
|
|
||||||
"bos-settings: snapper list failed: {}",
|
|
||||||
String::from_utf8_lossy(&output.stderr).trim()
|
|
||||||
);
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
|
|
||||||
let text = String::from_utf8_lossy(&output.stdout);
|
|
||||||
text.lines()
|
|
||||||
.skip(2) // header + separator
|
|
||||||
.filter_map(|line| {
|
|
||||||
let mut cols = line.splitn(3, '|');
|
|
||||||
let number = cols.next()?.trim().to_string();
|
|
||||||
// Snapshot 0 ("current") always exists, can't be rolled back to
|
|
||||||
// or deleted, and isn't a real snapshot — filter it out.
|
|
||||||
if number == "0" {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(SnapshotRow {
|
|
||||||
number,
|
|
||||||
date: cols.next()?.trim().to_string(),
|
|
||||||
description: cols.next()?.trim().to_string(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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);
|
|
||||||
}
|
|
||||||
let snapshots = list_snapshots();
|
|
||||||
if snapshots.is_empty() {
|
|
||||||
let row = ListBoxRow::new();
|
|
||||||
row.set_selectable(false);
|
|
||||||
row.set_child(Some(&w::empty_state(
|
|
||||||
"document-open-recent-symbolic",
|
|
||||||
"No snapshots yet",
|
|
||||||
"Snapshots are created automatically on every pacman transaction \
|
|
||||||
(snapper may not be configured yet).",
|
|
||||||
)));
|
|
||||||
list.append(&row);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
for snap in &snapshots {
|
|
||||||
let row = ListBoxRow::new();
|
|
||||||
row.set_widget_name(&snap.number);
|
|
||||||
|
|
||||||
let hbox = GBox::new(Orientation::Horizontal, 16);
|
|
||||||
hbox.set_margin_top(6);
|
|
||||||
hbox.set_margin_bottom(6);
|
|
||||||
hbox.set_margin_start(8);
|
|
||||||
hbox.set_margin_end(8);
|
|
||||||
|
|
||||||
let num_lbl = Label::new(Some(&snap.number));
|
|
||||||
num_lbl.set_width_chars(4);
|
|
||||||
num_lbl.set_xalign(0.0);
|
|
||||||
|
|
||||||
let date_lbl = Label::new(Some(&snap.date));
|
|
||||||
date_lbl.set_width_chars(22);
|
|
||||||
date_lbl.set_xalign(0.0);
|
|
||||||
|
|
||||||
let desc_lbl = Label::new(Some(&snap.description));
|
|
||||||
desc_lbl.set_hexpand(true);
|
|
||||||
desc_lbl.set_xalign(0.0);
|
|
||||||
|
|
||||||
hbox.append(&num_lbl);
|
|
||||||
hbox.append(&date_lbl);
|
|
||||||
hbox.append(&desc_lbl);
|
|
||||||
row.set_child(Some(&hbox));
|
|
||||||
list.append(&row);
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
|
||||||
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.",
|
|
||||||
));
|
|
||||||
|
|
||||||
let list = ListBox::new();
|
|
||||||
list.set_selection_mode(gtk4::SelectionMode::Single);
|
|
||||||
let empty = populate_list(&list);
|
|
||||||
|
|
||||||
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 refresh_btn = Button::with_label("Refresh");
|
|
||||||
let rollback_btn = Button::with_label("Boot into selected...");
|
|
||||||
let delete_btn = Button::with_label("Delete selected");
|
|
||||||
delete_btn.add_css_class("destructive-action");
|
|
||||||
rollback_btn.set_sensitive(!empty);
|
|
||||||
delete_btn.set_sensitive(!empty);
|
|
||||||
|
|
||||||
{
|
|
||||||
let list = list.clone();
|
|
||||||
let rollback_btn = rollback_btn.clone();
|
|
||||||
let delete_btn = delete_btn.clone();
|
|
||||||
refresh_btn.connect_clicked(move |_| {
|
|
||||||
let empty = populate_list(&list);
|
|
||||||
rollback_btn.set_sensitive(!empty);
|
|
||||||
delete_btn.set_sensitive(!empty);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
let list = list.clone();
|
|
||||||
rollback_btn.connect_clicked(move |btn| {
|
|
||||||
let Some(row) = list.selected_row() else { return };
|
|
||||||
let number = row.widget_name().to_string();
|
|
||||||
if number.is_empty() { return }
|
|
||||||
|
|
||||||
let window = btn
|
|
||||||
.root()
|
|
||||||
.and_then(|r| r.downcast::<gtk4::Window>().ok());
|
|
||||||
|
|
||||||
// BOS boots with root pinned to a named subvolume (grub emits
|
|
||||||
// rootflags=subvol=@), so `snapper rollback`'s usual mechanism —
|
|
||||||
// switching the btrfs *default* subvolume — has no effect here;
|
|
||||||
// grub never consults it. The real, working way to get back to a
|
|
||||||
// snapshot on this layout is grub-btrfs (already installed +
|
|
||||||
// running via grub-btrfsd.service): it generates a GRUB submenu
|
|
||||||
// entry per snapshot, bootable directly. So this button doesn't
|
|
||||||
// touch the filesystem at all — it just points you at that menu.
|
|
||||||
let dialog = AlertDialog::builder()
|
|
||||||
.message(&format!("Boot into snapshot #{number}?"))
|
|
||||||
.detail("Snapshots on BOS are booted directly from the GRUB \
|
|
||||||
menu (under \"BOS snapshots\"), not rolled back in \
|
|
||||||
place. Reboot now and pick this snapshot there, or \
|
|
||||||
later if you'd rather keep working — the menu entry \
|
|
||||||
will still be there.")
|
|
||||||
.buttons(["Later", "Reboot now"])
|
|
||||||
.cancel_button(0)
|
|
||||||
.default_button(0)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
|
|
||||||
if result == Ok(1) {
|
|
||||||
let _ = Command::new("systemctl").args(["reboot"]).spawn();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
let list = list.clone();
|
|
||||||
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 dialog = AlertDialog::builder()
|
|
||||||
.message(&format!("Delete snapshot #{number}?"))
|
|
||||||
.detail("This cannot be undone.")
|
|
||||||
.buttons(["Cancel", "Delete"])
|
|
||||||
.cancel_button(0)
|
|
||||||
.default_button(0)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let window2 = window.clone();
|
|
||||||
let list2 = list.clone();
|
|
||||||
let rollback_btn2 = rollback_btn.clone();
|
|
||||||
let delete_btn2 = delete_btn.clone();
|
|
||||||
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
|
|
||||||
if result != Ok(1) { return }
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
outer.append(&btn_row);
|
|
||||||
|
|
||||||
outer
|
|
||||||
}
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
//! Output/input volume and device selection over PipeWire's pulse
|
|
||||||
//! compatibility layer (`pactl`) — the same surface `hyprland.lua`'s media
|
|
||||||
//! keys already use via `wpctl`. `pactl` is used here instead of `wpctl`
|
|
||||||
//! because it can enumerate devices with human-readable descriptions and
|
|
||||||
//! switch the default in one command; `wpctl` cannot easily do either.
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{Box as GBox, Button, DropDown, Expression, Orientation, Scale, StringList, Switch};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
use crate::ui::widgets as w;
|
|
||||||
|
|
||||||
#[derive(Deserialize, Clone)]
|
|
||||||
struct Device {
|
|
||||||
name: String,
|
|
||||||
description: String,
|
|
||||||
mute: bool,
|
|
||||||
volume: std::collections::HashMap<String, VolumeChannel>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Clone)]
|
|
||||||
struct VolumeChannel {
|
|
||||||
value_percent: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Device {
|
|
||||||
fn percent(&self) -> f64 {
|
|
||||||
self.volume
|
|
||||||
.values()
|
|
||||||
.next()
|
|
||||||
.and_then(|v| v.value_percent.trim_end_matches('%').trim().parse::<f64>().ok())
|
|
||||||
.unwrap_or(0.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list_devices(kind: &str) -> Vec<Device> {
|
|
||||||
let Ok(output) = Command::new("pactl").args(["-f", "json", "list", kind]).output() else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
serde_json::from_slice(&output.stdout).unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_device_name(kind: &str) -> Option<String> {
|
|
||||||
let flag = if kind == "sinks" { "get-default-sink" } else { "get-default-source" };
|
|
||||||
Command::new("pactl")
|
|
||||||
.arg(flag)
|
|
||||||
.output()
|
|
||||||
.ok()
|
|
||||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build one section (Output or Input): device dropdown + volume slider + mute
|
|
||||||
/// switch. `kind` is "sinks" or "sources"; `set_default_flag` is the pactl
|
|
||||||
/// verb ("set-default-sink"/"set-default-source"); `vol_flag`/`mute_flag`
|
|
||||||
/// likewise.
|
|
||||||
fn build_section(
|
|
||||||
title: &str,
|
|
||||||
kind: &'static str,
|
|
||||||
set_default_flag: &'static str,
|
|
||||||
vol_flag: &'static str,
|
|
||||||
mute_flag: &'static str,
|
|
||||||
) -> GBox {
|
|
||||||
let section = GBox::new(Orientation::Vertical, 4);
|
|
||||||
section.append(&w::section(title));
|
|
||||||
|
|
||||||
let devices = list_devices(kind);
|
|
||||||
if devices.is_empty() {
|
|
||||||
section.append(&w::hint("No devices found."));
|
|
||||||
return section;
|
|
||||||
}
|
|
||||||
|
|
||||||
let current_name = default_device_name(kind);
|
|
||||||
let selected_idx = current_name
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|n| devices.iter().position(|d| &d.name == n))
|
|
||||||
.unwrap_or(0);
|
|
||||||
let current = devices[selected_idx].clone();
|
|
||||||
|
|
||||||
let labels: Vec<&str> = devices.iter().map(|d| d.description.as_str()).collect();
|
|
||||||
let model = StringList::new(&labels);
|
|
||||||
let dd = DropDown::new(Some(model), Expression::NONE);
|
|
||||||
dd.set_selected(selected_idx as u32);
|
|
||||||
section.append(&w::row("Device", &dd));
|
|
||||||
|
|
||||||
let scale = Scale::with_range(Orientation::Horizontal, 0.0, 150.0, 1.0);
|
|
||||||
scale.set_value(current.percent());
|
|
||||||
scale.set_size_request(180, -1);
|
|
||||||
scale.set_draw_value(true);
|
|
||||||
scale.set_value_pos(gtk4::PositionType::Right);
|
|
||||||
section.append(&w::row("Volume", &scale));
|
|
||||||
|
|
||||||
let mute_sw = Switch::new();
|
|
||||||
mute_sw.set_active(current.mute);
|
|
||||||
section.append(&w::row("Mute", &mute_sw));
|
|
||||||
|
|
||||||
// Device switch: fire-and-forget pactl call, then resync the slider/mute
|
|
||||||
// switch to whatever the newly-default device's actual state is.
|
|
||||||
{
|
|
||||||
let devices = devices.clone();
|
|
||||||
let scale = scale.clone();
|
|
||||||
let mute_sw = mute_sw.clone();
|
|
||||||
dd.connect_selected_notify(move |dd| {
|
|
||||||
let Some(d) = devices.get(dd.selected() as usize) else { return };
|
|
||||||
let _ = Command::new("pactl").args([set_default_flag, &d.name]).spawn();
|
|
||||||
scale.set_value(d.percent());
|
|
||||||
mute_sw.set_active(d.mute);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Volume: target whichever device is currently selected in the dropdown,
|
|
||||||
// not necessarily the system default at the time this closure was built.
|
|
||||||
{
|
|
||||||
let devices = devices.clone();
|
|
||||||
let dd = dd.clone();
|
|
||||||
scale.connect_value_changed(move |s| {
|
|
||||||
let Some(d) = devices.get(dd.selected() as usize) else { return };
|
|
||||||
let pct = format!("{}%", s.value() as i64);
|
|
||||||
let _ = Command::new("pactl").args([vol_flag, &d.name, &pct]).spawn();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
let devices = devices.clone();
|
|
||||||
let dd = dd.clone();
|
|
||||||
mute_sw.connect_active_notify(move |s| {
|
|
||||||
let Some(d) = devices.get(dd.selected() as usize) else { return };
|
|
||||||
let val = if s.is_active() { "1" } else { "0" };
|
|
||||||
let _ = Command::new("pactl").args([mute_flag, &d.name, val]).spawn();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
section
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
|
||||||
let (outer, content) = w::view_scaffold("Sound");
|
|
||||||
|
|
||||||
content.append(&build_section(
|
|
||||||
"Output",
|
|
||||||
"sinks",
|
|
||||||
"set-default-sink",
|
|
||||||
"set-sink-volume",
|
|
||||||
"set-sink-mute",
|
|
||||||
));
|
|
||||||
content.append(&build_section(
|
|
||||||
"Input",
|
|
||||||
"sources",
|
|
||||||
"set-default-source",
|
|
||||||
"set-source-volume",
|
|
||||||
"set-source-mute",
|
|
||||||
));
|
|
||||||
|
|
||||||
content.append(&w::section("Advanced"));
|
|
||||||
content.append(&w::hint(
|
|
||||||
"Per-app volume, port selection, and profile switching aren't covered here.",
|
|
||||||
));
|
|
||||||
let mixer_btn = Button::with_label("Open advanced mixer (pavucontrol)");
|
|
||||||
mixer_btn.set_halign(gtk4::Align::Start);
|
|
||||||
mixer_btn.connect_clicked(|_| {
|
|
||||||
let _ = Command::new("pavucontrol").spawn();
|
|
||||||
});
|
|
||||||
content.append(&mixer_btn);
|
|
||||||
|
|
||||||
outer
|
|
||||||
}
|
|
||||||
|
|
@ -1,293 +0,0 @@
|
||||||
//! User account management — add/remove users, change passwords. Everything
|
|
||||||
//! here needs root (useradd/userdel/chpasswd), so every action goes through
|
|
||||||
//! `pkexec` on a background thread (GTK widgets aren't Send), matching the
|
|
||||||
//! async_channel + glib::spawn_future_local handoff used elsewhere for
|
|
||||||
//! destructive/root actions (see snapshots.rs).
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{AlertDialog, Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow};
|
|
||||||
use std::io::Write;
|
|
||||||
use std::process::{Command, Stdio};
|
|
||||||
|
|
||||||
use crate::ui::widgets as w;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct Account {
|
|
||||||
username: String,
|
|
||||||
full_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list_accounts() -> Vec<Account> {
|
|
||||||
let Ok(text) = std::fs::read_to_string("/etc/passwd") else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
text.lines()
|
|
||||||
.filter_map(|line| {
|
|
||||||
let f: Vec<&str> = line.split(':').collect();
|
|
||||||
if f.len() < 7 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let uid: u32 = f[2].parse().ok()?;
|
|
||||||
let shell = f[6];
|
|
||||||
// Real human accounts: normal UID range, a real login shell (not
|
|
||||||
// nologin/false — excludes system/service accounts like
|
|
||||||
// greeter, avahi, etc).
|
|
||||||
if !(1000..60000).contains(&uid) || shell.ends_with("nologin") || shell.ends_with("/false") {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(Account {
|
|
||||||
username: f[0].to_string(),
|
|
||||||
full_name: f[4].split(',').next().unwrap_or("").to_string(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_user() -> String {
|
|
||||||
std::env::var("USER").unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run a root command that needs a line of input on stdin (chpasswd's own
|
|
||||||
/// "user:password" format) on a background thread, reporting success back
|
|
||||||
/// via a channel. `pkexec` inherits the spawning process's stdin only when
|
|
||||||
/// explicitly piped, so this pipes it through rather than relying on that.
|
|
||||||
fn run_with_stdin(args: &[&str], input: String, on_done: impl FnOnce(bool) + 'static) {
|
|
||||||
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
|
|
||||||
let (tx, rx) = async_channel::bounded::<bool>(1);
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let ok = (|| -> std::io::Result<bool> {
|
|
||||||
let mut child = Command::new(&args[0])
|
|
||||||
.args(&args[1..])
|
|
||||||
.stdin(Stdio::piped())
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.spawn()?;
|
|
||||||
if let Some(mut stdin) = child.stdin.take() {
|
|
||||||
stdin.write_all(input.as_bytes())?;
|
|
||||||
}
|
|
||||||
Ok(child.wait()?.success())
|
|
||||||
})()
|
|
||||||
.unwrap_or(false);
|
|
||||||
let _ = tx.send_blocking(ok);
|
|
||||||
});
|
|
||||||
glib::spawn_future_local(async move {
|
|
||||||
let ok = rx.recv().await.unwrap_or(false);
|
|
||||||
on_done(ok);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn populate(list: &ListBox) {
|
|
||||||
while let Some(child) = list.first_child() {
|
|
||||||
list.remove(&child);
|
|
||||||
}
|
|
||||||
let me = current_user();
|
|
||||||
for acc in list_accounts() {
|
|
||||||
let row = ListBoxRow::new();
|
|
||||||
row.set_selectable(false);
|
|
||||||
let card = GBox::new(Orientation::Vertical, 6);
|
|
||||||
card.add_css_class("card");
|
|
||||||
card.set_margin_top(3);
|
|
||||||
card.set_margin_bottom(3);
|
|
||||||
|
|
||||||
let top = GBox::new(Orientation::Horizontal, 12);
|
|
||||||
let label_text = if acc.full_name.is_empty() {
|
|
||||||
acc.username.clone()
|
|
||||||
} else {
|
|
||||||
format!("{} ({})", acc.username, acc.full_name)
|
|
||||||
};
|
|
||||||
let name_lbl = Label::new(Some(&label_text));
|
|
||||||
name_lbl.set_hexpand(true);
|
|
||||||
name_lbl.set_xalign(0.0);
|
|
||||||
|
|
||||||
let change_pw_btn = Button::with_label("Change password");
|
|
||||||
let remove_btn = Button::with_label("Remove");
|
|
||||||
remove_btn.add_css_class("destructive-action");
|
|
||||||
// Don't let the panel remove the account it's currently running as
|
|
||||||
// — that's a self-lockout, not a normal account-management action.
|
|
||||||
remove_btn.set_sensitive(acc.username != me);
|
|
||||||
|
|
||||||
top.append(&name_lbl);
|
|
||||||
top.append(&change_pw_btn);
|
|
||||||
top.append(&remove_btn);
|
|
||||||
card.append(&top);
|
|
||||||
|
|
||||||
// Inline password row, hidden until "Change password" is clicked —
|
|
||||||
// same pattern as Network's secured-connection password prompt.
|
|
||||||
let pw_row = GBox::new(Orientation::Horizontal, 8);
|
|
||||||
let pw_entry = Entry::new();
|
|
||||||
pw_entry.set_visibility(false);
|
|
||||||
pw_entry.set_hexpand(true);
|
|
||||||
pw_entry.set_placeholder_text(Some("New password"));
|
|
||||||
let pw_apply_btn = Button::with_label("Apply");
|
|
||||||
pw_row.append(&pw_entry);
|
|
||||||
pw_row.append(&pw_apply_btn);
|
|
||||||
pw_row.set_visible(false);
|
|
||||||
card.append(&pw_row);
|
|
||||||
|
|
||||||
let status = Label::new(None);
|
|
||||||
status.add_css_class("dim-label");
|
|
||||||
status.set_xalign(0.0);
|
|
||||||
card.append(&status);
|
|
||||||
|
|
||||||
{
|
|
||||||
let pw_row = pw_row.clone();
|
|
||||||
change_pw_btn.connect_clicked(move |_| {
|
|
||||||
pw_row.set_visible(!pw_row.is_visible());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let username = acc.username.clone();
|
|
||||||
let pw_entry = pw_entry.clone();
|
|
||||||
let pw_row = pw_row.clone();
|
|
||||||
let status = status.clone();
|
|
||||||
pw_apply_btn.connect_clicked(move |_| {
|
|
||||||
let password = pw_entry.text().to_string();
|
|
||||||
if password.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let input = format!("{username}:{password}\n");
|
|
||||||
let status2 = status.clone();
|
|
||||||
let pw_entry2 = pw_entry.clone();
|
|
||||||
let pw_row2 = pw_row.clone();
|
|
||||||
status.set_text("Applying…");
|
|
||||||
run_with_stdin(&["pkexec", "chpasswd"], input, move |ok| {
|
|
||||||
if ok {
|
|
||||||
status2.set_text("Password changed");
|
|
||||||
pw_entry2.set_text("");
|
|
||||||
pw_row2.set_visible(false);
|
|
||||||
} else {
|
|
||||||
status2.set_text("Failed to change password");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let list = list.clone();
|
|
||||||
let username = acc.username.clone();
|
|
||||||
remove_btn.connect_clicked(move |btn| {
|
|
||||||
let window = btn.root().and_then(|r| r.downcast::<gtk4::Window>().ok());
|
|
||||||
let dialog = AlertDialog::builder()
|
|
||||||
.message(format!("Remove user {username}?"))
|
|
||||||
.detail("Deletes the account and its home directory. This cannot be undone.")
|
|
||||||
.buttons(["Cancel", "Remove"])
|
|
||||||
.cancel_button(0)
|
|
||||||
.default_button(0)
|
|
||||||
.build();
|
|
||||||
let list2 = list.clone();
|
|
||||||
let username2 = username.clone();
|
|
||||||
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
|
|
||||||
if result != Ok(1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let log_buf = gtk4::TextBuffer::new(None);
|
|
||||||
let list3 = list2.clone();
|
|
||||||
w::stream_command_then(
|
|
||||||
&["pkexec", "userdel", "-r", &username2],
|
|
||||||
log_buf,
|
|
||||||
move || populate(&list3),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
row.set_child(Some(&card));
|
|
||||||
list.append(&row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
|
||||||
let (outer, content) = w::view_scaffold("Users");
|
|
||||||
content.append(&w::hint(
|
|
||||||
"Real login accounts on this machine (system/service accounts \
|
|
||||||
aren't shown). Your own account can't be removed from here.",
|
|
||||||
));
|
|
||||||
|
|
||||||
let list = ListBox::new();
|
|
||||||
list.set_selection_mode(gtk4::SelectionMode::None);
|
|
||||||
populate(&list);
|
|
||||||
let scroll = ScrolledWindow::new();
|
|
||||||
scroll.set_vexpand(true);
|
|
||||||
scroll.set_min_content_height(260);
|
|
||||||
scroll.set_child(Some(&list));
|
|
||||||
content.append(&scroll);
|
|
||||||
|
|
||||||
content.append(&w::section("Add user"));
|
|
||||||
let username_entry = Entry::new();
|
|
||||||
username_entry.set_placeholder_text(Some("username"));
|
|
||||||
content.append(&w::row("Username", &username_entry));
|
|
||||||
|
|
||||||
let fullname_entry = Entry::new();
|
|
||||||
fullname_entry.set_placeholder_text(Some("Full name (optional)"));
|
|
||||||
content.append(&w::row("Full name", &fullname_entry));
|
|
||||||
|
|
||||||
let password_entry = Entry::new();
|
|
||||||
password_entry.set_visibility(false);
|
|
||||||
password_entry.set_placeholder_text(Some("password"));
|
|
||||||
content.append(&w::row("Password", &password_entry));
|
|
||||||
|
|
||||||
let add_status = Label::new(None);
|
|
||||||
add_status.add_css_class("dim-label");
|
|
||||||
add_status.set_xalign(0.0);
|
|
||||||
|
|
||||||
let add_btn = Button::with_label("Add user");
|
|
||||||
add_btn.add_css_class("suggested-action");
|
|
||||||
add_btn.set_halign(gtk4::Align::Start);
|
|
||||||
add_btn.set_margin_top(8);
|
|
||||||
{
|
|
||||||
let list = list.clone();
|
|
||||||
let username_entry = username_entry.clone();
|
|
||||||
let fullname_entry = fullname_entry.clone();
|
|
||||||
let password_entry = password_entry.clone();
|
|
||||||
let add_status = add_status.clone();
|
|
||||||
add_btn.connect_clicked(move |_| {
|
|
||||||
let username = username_entry.text().to_string();
|
|
||||||
let full_name = fullname_entry.text().to_string();
|
|
||||||
let password = password_entry.text().to_string();
|
|
||||||
if username.trim().is_empty() || password.is_empty() {
|
|
||||||
add_status.set_text("Username and password are required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
add_status.set_text("Adding…");
|
|
||||||
|
|
||||||
let mut useradd_args = vec!["pkexec", "useradd", "-m", "-s", "/bin/bash"];
|
|
||||||
if !full_name.trim().is_empty() {
|
|
||||||
useradd_args.push("-c");
|
|
||||||
useradd_args.push(full_name.trim());
|
|
||||||
}
|
|
||||||
useradd_args.push(username.trim());
|
|
||||||
|
|
||||||
let list2 = list.clone();
|
|
||||||
let username2 = username.trim().to_string();
|
|
||||||
let password2 = password.clone();
|
|
||||||
let add_status2 = add_status.clone();
|
|
||||||
let username_entry2 = username_entry.clone();
|
|
||||||
let fullname_entry2 = fullname_entry.clone();
|
|
||||||
let password_entry2 = password_entry.clone();
|
|
||||||
let log_buf = gtk4::TextBuffer::new(None);
|
|
||||||
w::stream_command_then(&useradd_args, log_buf, move || {
|
|
||||||
let input = format!("{username2}:{password2}\n");
|
|
||||||
let list3 = list2.clone();
|
|
||||||
let add_status3 = add_status2.clone();
|
|
||||||
let username_entry3 = username_entry2.clone();
|
|
||||||
let fullname_entry3 = fullname_entry2.clone();
|
|
||||||
let password_entry3 = password_entry2.clone();
|
|
||||||
run_with_stdin(&["pkexec", "chpasswd"], input, move |ok| {
|
|
||||||
if ok {
|
|
||||||
add_status3.set_text("User added.");
|
|
||||||
username_entry3.set_text("");
|
|
||||||
fullname_entry3.set_text("");
|
|
||||||
password_entry3.set_text("");
|
|
||||||
populate(&list3);
|
|
||||||
} else {
|
|
||||||
add_status3.set_text("User created, but setting the password failed.");
|
|
||||||
populate(&list3);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
content.append(&add_btn);
|
|
||||||
content.append(&add_status);
|
|
||||||
|
|
||||||
outer
|
|
||||||
}
|
|
||||||
|
|
@ -1,506 +0,0 @@
|
||||||
//! Reusable settings rows bound to a shared `toml_edit` document.
|
|
||||||
//!
|
|
||||||
//! Every row reads its current value from the document on build and writes the
|
|
||||||
//! single key it owns back into the document on change. A view collects rows,
|
|
||||||
//! then a [`save_button`] persists the whole document to disk in one shot — so
|
|
||||||
//! unmodelled keys and comments are always preserved (see `crate::config`).
|
|
||||||
|
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{
|
|
||||||
Adjustment, AlertDialog, Box as GBox, Button, DropDown, Entry, Expression, Label, Orientation,
|
|
||||||
SpinButton, StringList, Switch,
|
|
||||||
};
|
|
||||||
use toml_edit::DocumentMut;
|
|
||||||
|
|
||||||
use crate::config;
|
|
||||||
|
|
||||||
/// Shared, mutable config document handed to every row in a view.
|
|
||||||
pub type Doc = Rc<RefCell<DocumentMut>>;
|
|
||||||
|
|
||||||
/// A fixed key path into the document, e.g. `&["adapters", "power", "enabled"]`.
|
|
||||||
type Path = &'static [&'static str];
|
|
||||||
|
|
||||||
fn field_label(text: &str) -> Label {
|
|
||||||
let lbl = Label::new(Some(text));
|
|
||||||
lbl.set_hexpand(true);
|
|
||||||
lbl.set_xalign(0.0);
|
|
||||||
lbl
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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);
|
|
||||||
row.append(control);
|
|
||||||
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));
|
|
||||||
lbl.add_css_class("heading");
|
|
||||||
lbl.set_xalign(0.0);
|
|
||||||
lbl.set_margin_top(12);
|
|
||||||
lbl.set_margin_bottom(2);
|
|
||||||
lbl
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Small dimmed helper text under a section or row.
|
|
||||||
pub fn hint(text: &str) -> Label {
|
|
||||||
let lbl = Label::new(Some(text));
|
|
||||||
lbl.add_css_class("dim-label");
|
|
||||||
lbl.set_xalign(0.0);
|
|
||||||
lbl.set_wrap(true);
|
|
||||||
// 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)`.
|
|
||||||
pub fn view_scaffold(title: &str) -> (GBox, GBox) {
|
|
||||||
let outer = GBox::new(Orientation::Vertical, 8);
|
|
||||||
outer.add_css_class("view-content");
|
|
||||||
|
|
||||||
let title_lbl = Label::new(Some(title));
|
|
||||||
title_lbl.add_css_class("title");
|
|
||||||
title_lbl.set_xalign(0.0);
|
|
||||||
// Same width+center treatment as `content` below, so the title's left
|
|
||||||
// edge lines up with the column it's heading instead of sitting flush
|
|
||||||
// against the panel edge while the column centers ~440px to its right.
|
|
||||||
title_lbl.set_halign(gtk4::Align::Center);
|
|
||||||
title_lbl.set_size_request(CONTENT_MAX_WIDTH, -1);
|
|
||||||
outer.append(&title_lbl);
|
|
||||||
|
|
||||||
let content = GBox::new(Orientation::Vertical, 4);
|
|
||||||
// Explicit hexpand(false) overrides the auto-computed value GTK would
|
|
||||||
// otherwise derive from the hexpand-ing labels inside every row, and
|
|
||||||
// Center distributes the dead space evenly either side on a wide window
|
|
||||||
// instead of stranding it all on the right of a left-hugging column.
|
|
||||||
content.set_hexpand(false);
|
|
||||||
content.set_halign(gtk4::Align::Center);
|
|
||||||
content.set_size_request(CONTENT_MAX_WIDTH, -1);
|
|
||||||
|
|
||||||
let scroll = gtk4::ScrolledWindow::new();
|
|
||||||
scroll.set_vexpand(true);
|
|
||||||
scroll.set_hscrollbar_policy(gtk4::PolicyType::Never);
|
|
||||||
scroll.set_child(Some(&content));
|
|
||||||
outer.append(&scroll);
|
|
||||||
|
|
||||||
(outer, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn switch_row(label: &str, doc: &Doc, path: Path, default: bool) -> GBox {
|
|
||||||
let cur = config::get_bool(&doc.borrow(), path).unwrap_or(default);
|
|
||||||
let sw = Switch::new();
|
|
||||||
sw.set_active(cur);
|
|
||||||
let doc = doc.clone();
|
|
||||||
sw.connect_active_notify(move |s| {
|
|
||||||
config::set_bool(&mut doc.borrow_mut(), path, s.is_active());
|
|
||||||
});
|
|
||||||
row(label, &sw)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn entry_row(label: &str, doc: &Doc, path: Path, placeholder: &str, default: &str) -> GBox {
|
|
||||||
let cur = config::get_str(&doc.borrow(), path).unwrap_or_else(|| default.to_string());
|
|
||||||
let entry = Entry::new();
|
|
||||||
entry.set_text(&cur);
|
|
||||||
entry.set_hexpand(true);
|
|
||||||
entry.set_width_chars(28);
|
|
||||||
if !placeholder.is_empty() {
|
|
||||||
entry.set_placeholder_text(Some(placeholder));
|
|
||||||
}
|
|
||||||
let doc = doc.clone();
|
|
||||||
entry.connect_changed(move |e| {
|
|
||||||
config::set_str_or_remove(&mut doc.borrow_mut(), path, e.text().as_str());
|
|
||||||
});
|
|
||||||
row(label, &entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn password_row(label: &str, doc: &Doc, path: Path) -> GBox {
|
|
||||||
let cur = config::get_str(&doc.borrow(), path).unwrap_or_default();
|
|
||||||
let entry = Entry::new();
|
|
||||||
entry.set_text(&cur);
|
|
||||||
entry.set_visibility(false);
|
|
||||||
entry.set_hexpand(true);
|
|
||||||
entry.set_width_chars(28);
|
|
||||||
entry.set_input_purpose(gtk4::InputPurpose::Password);
|
|
||||||
let doc = doc.clone();
|
|
||||||
entry.connect_changed(move |e| {
|
|
||||||
config::set_str_or_remove(&mut doc.borrow_mut(), path, e.text().as_str());
|
|
||||||
});
|
|
||||||
row(label, &entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A dropdown that stores the selected option string at `path`.
|
|
||||||
pub fn dropdown_row(label: &str, doc: &Doc, path: Path, options: &[&str], default: &str) -> GBox {
|
|
||||||
let cur = config::get_str(&doc.borrow(), path).unwrap_or_else(|| default.to_string());
|
|
||||||
let model = StringList::new(options);
|
|
||||||
let dd = DropDown::new(Some(model), Expression::NONE);
|
|
||||||
let sel = options.iter().position(|o| *o == cur).unwrap_or(0) as u32;
|
|
||||||
dd.set_selected(sel);
|
|
||||||
let owned: Vec<String> = options.iter().map(|s| s.to_string()).collect();
|
|
||||||
let doc = doc.clone();
|
|
||||||
dd.connect_selected_notify(move |dd| {
|
|
||||||
if let Some(opt) = owned.get(dd.selected() as usize) {
|
|
||||||
config::set_str(&mut doc.borrow_mut(), path, opt);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
row(label, &dd)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An integer spin button storing its value at `path`.
|
|
||||||
pub fn spin_row(
|
|
||||||
label: &str,
|
|
||||||
doc: &Doc,
|
|
||||||
path: Path,
|
|
||||||
min: f64,
|
|
||||||
max: f64,
|
|
||||||
step: f64,
|
|
||||||
default: i64,
|
|
||||||
) -> GBox {
|
|
||||||
let cur = config::get_i64(&doc.borrow(), path).unwrap_or(default);
|
|
||||||
let adj = Adjustment::new(cur as f64, min, max, step, step, 0.0);
|
|
||||||
let spin = SpinButton::new(Some(&adj), step, 0);
|
|
||||||
let doc = doc.clone();
|
|
||||||
spin.connect_value_changed(move |s| {
|
|
||||||
config::set_i64(&mut doc.borrow_mut(), path, s.value() as i64);
|
|
||||||
});
|
|
||||||
row(label, &spin)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A fractional spin button (e.g. 0.0–1.0 confidence) storing a float.
|
|
||||||
pub fn spin_f64_row(
|
|
||||||
label: &str,
|
|
||||||
doc: &Doc,
|
|
||||||
path: Path,
|
|
||||||
min: f64,
|
|
||||||
max: f64,
|
|
||||||
step: f64,
|
|
||||||
digits: u32,
|
|
||||||
default: f64,
|
|
||||||
) -> GBox {
|
|
||||||
let cur = config::get_f64(&doc.borrow(), path).unwrap_or(default);
|
|
||||||
let adj = Adjustment::new(cur, min, max, step, step, 0.0);
|
|
||||||
let spin = SpinButton::new(Some(&adj), step, digits);
|
|
||||||
let doc = doc.clone();
|
|
||||||
spin.connect_value_changed(move |s| {
|
|
||||||
config::set_f64(&mut doc.borrow_mut(), path, s.value());
|
|
||||||
});
|
|
||||||
row(label, &spin)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A comma-separated list editor storing an array of strings at `path`.
|
|
||||||
pub fn csv_row(label: &str, doc: &Doc, path: Path, placeholder: &str) -> GBox {
|
|
||||||
let cur = config::get_str_list(&doc.borrow(), path).join(", ");
|
|
||||||
let entry = Entry::new();
|
|
||||||
entry.set_text(&cur);
|
|
||||||
entry.set_hexpand(true);
|
|
||||||
entry.set_width_chars(28);
|
|
||||||
if !placeholder.is_empty() {
|
|
||||||
entry.set_placeholder_text(Some(placeholder));
|
|
||||||
}
|
|
||||||
let doc = doc.clone();
|
|
||||||
entry.connect_changed(move |e| {
|
|
||||||
let items: Vec<String> = e
|
|
||||||
.text()
|
|
||||||
.split(',')
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.collect();
|
|
||||||
config::set_str_list(&mut doc.borrow_mut(), path, &items);
|
|
||||||
});
|
|
||||||
row(label, &entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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);
|
|
||||||
btn_row.set_margin_top(16);
|
|
||||||
|
|
||||||
let save_btn = Button::with_label("Save");
|
|
||||||
save_btn.add_css_class("suggested-action");
|
|
||||||
let status = Label::new(None);
|
|
||||||
status.add_css_class("dim-label");
|
|
||||||
|
|
||||||
let doc = doc.clone();
|
|
||||||
let status_c = status.clone();
|
|
||||||
save_btn.connect_clicked(move |_| match config::save_doc(&path, &doc.borrow()) {
|
|
||||||
Ok(()) => {
|
|
||||||
status_c.set_text("Saved");
|
|
||||||
let lbl = status_c.clone();
|
|
||||||
glib::timeout_add_seconds_local(3, move || {
|
|
||||||
lbl.set_text("");
|
|
||||||
glib::ControlFlow::Break
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e) => status_c.set_text(&format!("Error: {e}")),
|
|
||||||
});
|
|
||||||
|
|
||||||
btn_row.append(&save_btn);
|
|
||||||
btn_row.append(&status);
|
|
||||||
btn_row
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
use gtk4::prelude::*;
|
|
||||||
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)
|
|
||||||
.title("BOS Settings")
|
|
||||||
.default_width(960)
|
|
||||||
.default_height(640)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
crate::theme::load(&WidgetExt::display(&window));
|
|
||||||
|
|
||||||
let hpaned = Paned::new(Orientation::Horizontal);
|
|
||||||
hpaned.set_position(210);
|
|
||||||
hpaned.set_shrink_start_child(false);
|
|
||||||
hpaned.set_resize_start_child(false);
|
|
||||||
|
|
||||||
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"));
|
|
||||||
|
|
||||||
stack.set_visible_child_name(DEFAULT_PAGE);
|
|
||||||
|
|
||||||
{
|
|
||||||
let stack = stack.clone();
|
|
||||||
list.connect_row_selected(move |_, row| {
|
|
||||||
if let Some(row) = row {
|
|
||||||
let name = row.widget_name();
|
|
||||||
if !name.is_empty() {
|
|
||||||
stack.set_visible_child_name(&name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
hpaned.set_start_child(Some(&sidebar_box));
|
|
||||||
hpaned.set_end_child(Some(&stack));
|
|
||||||
|
|
||||||
window.set_child(Some(&hpaned));
|
|
||||||
window.present();
|
|
||||||
}
|
|
||||||
|
|
@ -9,10 +9,10 @@ strings:
|
||||||
versionedName: "BOS (rolling)"
|
versionedName: "BOS (rolling)"
|
||||||
shortVersionedName: "BOS"
|
shortVersionedName: "BOS"
|
||||||
bootloaderEntryName: "BOS"
|
bootloaderEntryName: "BOS"
|
||||||
productUrl: "https://github.com/Breadway/bos"
|
productUrl: "https://git.breadway.dev/Breadway/bos"
|
||||||
supportUrl: "https://github.com/Breadway/bos/issues"
|
supportUrl: "https://git.breadway.dev/Breadway/bos/issues"
|
||||||
knownIssuesUrl: "https://github.com/Breadway/bos/issues"
|
knownIssuesUrl: "https://git.breadway.dev/Breadway/bos/issues"
|
||||||
releaseNotesUrl: "https://github.com/Breadway/bos/releases"
|
releaseNotesUrl: "https://git.breadway.dev/Breadway/bos/releases"
|
||||||
|
|
||||||
images:
|
images:
|
||||||
productLogo: "logo.png"
|
productLogo: "logo.png"
|
||||||
|
|
|
||||||
|
|
@ -272,11 +272,20 @@ fi
|
||||||
# transiently fail inside the Calamares chroot (the same mount unmounts
|
# transiently fail inside the Calamares chroot (the same mount unmounts
|
||||||
# cleanly moments later once booted normally — a chroot-specific busy-mount
|
# cleanly moments later once booted normally — a chroot-specific busy-mount
|
||||||
# race, not a logic error), which cascades into snapper create-config
|
# race, not a logic error), which cascades into snapper create-config
|
||||||
# refusing because .snapshots "already exists". Retry a few times, then
|
# refusing because .snapshots "already exists". A single pass — retry the
|
||||||
# fall back to a lazy unmount (detaches the mountpoint immediately even if
|
# umount a few times, then fall back to a lazy unmount — was NOT enough on
|
||||||
# something still transiently references it) rather than give up.
|
# real hardware: also confirmed is a run where every umount attempt in that
|
||||||
|
# single pass failed (including the lazy fallback settling too slowly for
|
||||||
|
# the immediately-following rmdir/create-config), leaving
|
||||||
|
# /etc/snapper/configs/ completely empty and BOS's advertised snapshot/
|
||||||
|
# rollback feature silently non-functional on that install. So retry the
|
||||||
|
# WHOLE dance, not just the umount substep, and verify at the end that the
|
||||||
|
# config file actually exists before declaring success.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
if command -v snapper &>/dev/null; then
|
if command -v snapper &>/dev/null; then
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
[[ -f /etc/snapper/configs/root ]] && break
|
||||||
|
|
||||||
unmounted=0
|
unmounted=0
|
||||||
for _ in 1 2 3 4 5; do
|
for _ in 1 2 3 4 5; do
|
||||||
if umount /.snapshots 2>/dev/null; then
|
if umount /.snapshots 2>/dev/null; then
|
||||||
|
|
@ -286,24 +295,41 @@ if command -v snapper &>/dev/null; then
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
if [[ "$unmounted" != "1" ]]; then
|
if [[ "$unmounted" != "1" ]]; then
|
||||||
echo "WARN: umount /.snapshots failed after retries, forcing lazy unmount"
|
echo "WARN: umount /.snapshots failed after retries (attempt $attempt), forcing lazy unmount"
|
||||||
umount -l /.snapshots || echo "WARN: lazy umount /.snapshots also failed"
|
umount -l /.snapshots || echo "WARN: lazy umount /.snapshots also failed (attempt $attempt)"
|
||||||
|
# Lazy unmount detaches the mountpoint from the namespace right
|
||||||
|
# away, but whatever was holding it busy may take a moment
|
||||||
|
# longer to actually let go — rmdir/create-config right after
|
||||||
|
# this both fail if anything still references /.snapshots.
|
||||||
|
sleep 2
|
||||||
fi
|
fi
|
||||||
rmdir /.snapshots || echo "WARN: rmdir /.snapshots failed"
|
rmdir /.snapshots 2>/dev/null || echo "WARN: rmdir /.snapshots failed (attempt $attempt)"
|
||||||
snapper -c root create-config / || echo "WARN: snapper create-config failed"
|
snapper -c root create-config / || echo "WARN: snapper create-config failed (attempt $attempt)"
|
||||||
if [[ -d /.snapshots ]]; then
|
if [[ -d /.snapshots ]]; then
|
||||||
btrfs subvolume delete /.snapshots || echo "WARN: deleting snapper's own .snapshots subvolume failed"
|
btrfs subvolume delete /.snapshots || echo "WARN: deleting snapper's own .snapshots subvolume failed (attempt $attempt)"
|
||||||
fi
|
fi
|
||||||
mkdir -p /.snapshots
|
mkdir -p /.snapshots
|
||||||
mount /.snapshots || echo "WARN: remounting the real @snapshots subvolume failed"
|
mount /.snapshots || echo "WARN: remounting the real @snapshots subvolume failed (attempt $attempt)"
|
||||||
|
|
||||||
|
[[ -f /etc/snapper/configs/root ]] || sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
if [[ -f /etc/snapper/configs/root ]]; then
|
if [[ -f /etc/snapper/configs/root ]]; then
|
||||||
sed -i 's/TIMELINE_CREATE="yes"/TIMELINE_CREATE="no"/' /etc/snapper/configs/root
|
sed -i 's/TIMELINE_CREATE="yes"/TIMELINE_CREATE="no"/' /etc/snapper/configs/root
|
||||||
sed -i 's/NUMBER_CLEANUP="no"/NUMBER_CLEANUP="yes"/' /etc/snapper/configs/root
|
sed -i 's/NUMBER_CLEANUP="no"/NUMBER_CLEANUP="yes"/' /etc/snapper/configs/root
|
||||||
sed -i 's/NUMBER_MIN_AGE="[^"]*"/NUMBER_MIN_AGE="1800"/' /etc/snapper/configs/root
|
sed -i 's/NUMBER_MIN_AGE="[^"]*"/NUMBER_MIN_AGE="1800"/' /etc/snapper/configs/root
|
||||||
sed -i 's/NUMBER_LIMIT="[^"]*"/NUMBER_LIMIT="10"/' /etc/snapper/configs/root
|
sed -i 's/NUMBER_LIMIT="[^"]*"/NUMBER_LIMIT="10"/' /etc/snapper/configs/root
|
||||||
sed -i 's/NUMBER_LIMIT_IMPORTANT="[^"]*"/NUMBER_LIMIT_IMPORTANT="5"/' /etc/snapper/configs/root
|
sed -i 's/NUMBER_LIMIT_IMPORTANT="[^"]*"/NUMBER_LIMIT_IMPORTANT="5"/' /etc/snapper/configs/root
|
||||||
|
# set-config (not sed) — snapper's own template text for this line has
|
||||||
|
# drifted across versions before, and a sed that doesn't match just
|
||||||
|
# silently no-ops, leaving ALLOW_USERS empty and every non-root
|
||||||
|
# `snapper` call (bos-settings' Snapshots page included) failing with
|
||||||
|
# "No permissions." forever. set-config is the stable API regardless
|
||||||
|
# of template wording.
|
||||||
[[ -n "$MAIN_USER" ]] && \
|
[[ -n "$MAIN_USER" ]] && \
|
||||||
sed -i "s/ALLOW_USERS=\"\"/ALLOW_USERS=\"$MAIN_USER\"/" /etc/snapper/configs/root
|
snapper -c root set-config "ALLOW_USERS=$MAIN_USER"
|
||||||
|
else
|
||||||
|
echo "ERROR: snapper config for root still missing after 3 attempts — snapshots/rollback will not work on this install"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -349,7 +375,8 @@ fi
|
||||||
# is bakery-managed, not pacman: the binaries and bakery manifest live in
|
# is bakery-managed, not pacman: the binaries and bakery manifest live in
|
||||||
# /etc/skel/.local (baked in at ISO build time) and are copied into the user's
|
# /etc/skel/.local (baked in at ISO build time) and are copied into the user's
|
||||||
# home below, so the install works fully offline with no DNS for bakery/GitHub.
|
# home below, so the install works fully offline with no DNS for bakery/GitHub.
|
||||||
# bos-settings is the only pacman bread package and was installed by unpackfs.
|
# bos-settings and breadhelp are the only pacman bread packages and were
|
||||||
|
# installed by unpackfs.
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Deploy dotfiles + the bakery bread ecosystem into the user's home (Calamares
|
# Deploy dotfiles + the bakery bread ecosystem into the user's home (Calamares
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
-- breadhelp-suggest — nudge breadhelp's Home tab banner when a bread event
|
||||||
|
-- suggests a relevant guide (e.g. a newly connected monitor -> breadmon
|
||||||
|
-- setup). Auto-discovered by breadd. If breadhelp isn't already running,
|
||||||
|
-- `--suggest <id>` launches it straight to Home with the banner focused; if
|
||||||
|
-- it's already running, the banner just updates silently (see
|
||||||
|
-- breadhelp's services/breadd.rs) rather than stealing focus on every event.
|
||||||
|
|
||||||
|
local M = bread.module({ name = "breadhelp-suggest", version = "1.0.0" })
|
||||||
|
|
||||||
|
function M.on_load()
|
||||||
|
bread.on("bread.monitor.connected", function(event)
|
||||||
|
bread.exec("breadhelp --suggest monitor-setup")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
-- breadhelp-tour — forwards bread/Hyprland events the live guided tour
|
||||||
|
-- (breadhelp's ui::tour) uses to detect "the user actually did the thing"
|
||||||
|
-- and auto-advance a step. Always forwards; breadhelp's `--tour-event`
|
||||||
|
-- handler is a hard no-op unless a tour is currently waiting for that exact
|
||||||
|
-- id, so this module doesn't need to know whether a tour is even running.
|
||||||
|
--
|
||||||
|
-- `bread.window.opened`/`bread.workspace.changed` are already normalized by
|
||||||
|
-- breadd. Layer-surface opens/closes (breadbox, breadclip, breadsearch) are
|
||||||
|
-- NOT normalized — `openlayer`/`closelayer` fall through to the generic
|
||||||
|
-- `bread.hyprland.event` topic, which `on_raw` filters by raw kind.
|
||||||
|
--
|
||||||
|
-- Fullscreen click-catcher launchers (breadbox et al.) report their surface
|
||||||
|
-- as covering the whole monitor while open, so the tour can't safely show a
|
||||||
|
-- clickable callout at the same time — a step targeting one of these should
|
||||||
|
-- key its success on the *close* event (implying the user picked something
|
||||||
|
-- and it dismissed), not the open event, so the callout only ever reappears
|
||||||
|
-- once the launcher's surface is already gone and input contention is moot.
|
||||||
|
|
||||||
|
local M = bread.module({ name = "breadhelp-tour", version = "1.0.0" })
|
||||||
|
|
||||||
|
-- `bread.exec` only takes a single shell command string — it always runs it
|
||||||
|
-- as `sh -lc <cmd>` (see breadd's Lua runtime), there's no array-exec form
|
||||||
|
-- that bypasses the shell. `event.data.class` (a Wayland window class) and
|
||||||
|
-- `event.data.data` (a layer-shell namespace) are both arbitrary strings a
|
||||||
|
-- client fully controls — a window/surface can name itself
|
||||||
|
-- `x; rm -rf ~ #` and have that land in a real shell command otherwise.
|
||||||
|
-- POSIX single-quoting neutralizes that: wrap the value in single quotes,
|
||||||
|
-- and turn any single quote *inside* it into `'\''` (close the quote, an
|
||||||
|
-- escaped literal quote, reopen the quote) — the one escaping rule `sh`
|
||||||
|
-- needs to treat the whole thing as inert data, never command syntax.
|
||||||
|
local function shell_quote(s)
|
||||||
|
return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.on_load()
|
||||||
|
bread.on("bread.window.opened", function(event)
|
||||||
|
bread.exec("breadhelp --tour-event " .. shell_quote("window:" .. event.data.class))
|
||||||
|
end)
|
||||||
|
|
||||||
|
bread.on("bread.workspace.changed", function(event)
|
||||||
|
bread.exec("breadhelp --tour-event workspace-changed")
|
||||||
|
end)
|
||||||
|
|
||||||
|
bread.hyprland.on_raw("openlayer", function(event)
|
||||||
|
bread.exec("breadhelp --tour-event " .. shell_quote("layer:" .. event.data.data))
|
||||||
|
end)
|
||||||
|
|
||||||
|
bread.hyprland.on_raw("closelayer", function(event)
|
||||||
|
bread.exec("breadhelp --tour-event " .. shell_quote("layer-closed:" .. event.data.data))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
8
iso/airootfs/etc/skel/.config/hypr/autostart.json
Normal file
8
iso/airootfs/etc/skel/.config/hypr/autostart.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"extra": [
|
||||||
|
{ "command": "breadbar", "label": "Bar (breadbar)", "enabled": true },
|
||||||
|
{ "command": "hypridle", "label": "Idle / lock daemon (hypridle)", "enabled": true },
|
||||||
|
{ "command": "bos-netcheck", "label": "Network connectivity check", "enabled": true },
|
||||||
|
{ "command": "breadhelp --autostart", "label": "BOS Help (first-run onboarding)", "enabled": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
88
iso/airootfs/etc/skel/.config/hypr/binds.json
Normal file
88
iso/airootfs/etc/skel/.config/hypr/binds.json
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
{
|
||||||
|
"default_mods": ["SUPER"],
|
||||||
|
"bindings": [
|
||||||
|
{ "action": "exec", "command": "kitty", "key": "RETURN", "label": "Open a terminal", "category": "apps" },
|
||||||
|
{ "action": "close", "key": "BACKSPACE", "label": "Close the focused window", "category": "windows" },
|
||||||
|
{ "action": "exec", "command": "breadbox", "key": "SPACE", "label": "Open the app launcher (breadbox)", "category": "apps", "demo_cmd": "breadbox" },
|
||||||
|
{ "action": "exec", "command": "nautilus", "key": "E", "label": "Open files (nautilus)", "category": "apps" },
|
||||||
|
{ "action": "exec", "command": "zen-browser", "key": "B", "label": "Open the browser (zen)", "category": "apps" },
|
||||||
|
{ "action": "exec", "command": "breadpad", "key": "U", "label": "Notes / reminders (breadpad)", "category": "apps", "demo_cmd": "breadpad" },
|
||||||
|
{ "action": "exec", "command": "breadman", "key": "M", "label": "Notes / task manager (breadman)", "category": "apps" },
|
||||||
|
{ "action": "exec", "command": "bos-settings", "key": "comma", "label": "Open BOS Settings", "category": "apps", "demo_cmd": "bos-settings" },
|
||||||
|
{ "action": "exec", "command": "breadhelp", "key": "slash", "label": "Show this keybind cheatsheet", "category": "apps" },
|
||||||
|
{ "action": "exec", "command": "loginctl lock-session", "key": "L", "label": "Lock screen", "category": "apps" },
|
||||||
|
{ "action": "fullscreen", "key": "F", "label": "Toggle fullscreen", "category": "windows" },
|
||||||
|
{ "action": "float", "key": "I", "label": "Toggle floating", "category": "windows" },
|
||||||
|
{ "action": "pseudo", "key": "P", "label": "Toggle pseudotile", "category": "windows" },
|
||||||
|
{ "action": "resize", "key": "R", "label": "Resize mode", "category": "windows" },
|
||||||
|
|
||||||
|
{ "action": "exec", "command": "breadclip", "key": "V", "label": "Clipboard history (breadclip)", "category": "apps", "demo_cmd": "breadclip" },
|
||||||
|
{ "action": "exec", "command": "breadclip", "key": "V", "mods": ["SUPER", "SHIFT"], "label": "Clipboard history (breadclip) — same as SUPER + V", "category": "apps" },
|
||||||
|
|
||||||
|
{ "action": "layout", "layout": "togglesplit", "key": "T", "label": "Toggle split direction", "category": "windows" },
|
||||||
|
{ "action": "focus_last", "key": "Tab", "label": "Focus last window", "category": "windows" },
|
||||||
|
{ "action": "exit", "key": "N", "label": "Exit Hyprland (log out)", "category": "windows" },
|
||||||
|
|
||||||
|
{ "action": "exec", "command": "breadshot region -o ~/Pictures/Screenshots", "key": "S", "mods": ["SUPER", "SHIFT"], "label": "Screenshot: select region -> file", "category": "screenshots" },
|
||||||
|
{ "action": "exec", "command": "breadshot region --clipboard-only", "key": "C", "mods": ["SUPER", "SHIFT"], "label": "Screenshot: select region -> clipboard", "category": "screenshots" },
|
||||||
|
{ "action": "exec", "command": "breadshot active-output -o ~/Pictures/Screenshots", "key": "P", "mods": ["SUPER", "SHIFT"], "label": "Screenshot: whole active screen -> file", "category": "screenshots" },
|
||||||
|
|
||||||
|
{ "action": "focus", "direction": "left", "key": "left", "label": "Move focus left", "category": "focus" },
|
||||||
|
{ "action": "focus", "direction": "right", "key": "right", "label": "Move focus right", "category": "focus" },
|
||||||
|
{ "action": "focus", "direction": "up", "key": "up", "label": "Move focus up", "category": "focus" },
|
||||||
|
{ "action": "focus", "direction": "down", "key": "down", "label": "Move focus down", "category": "focus" },
|
||||||
|
|
||||||
|
{ "action": "move_dir", "direction": "left", "key": "h", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window left", "category": "focus" },
|
||||||
|
{ "action": "move_dir", "direction": "down", "key": "j", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window down", "category": "focus" },
|
||||||
|
{ "action": "move_dir", "direction": "up", "key": "k", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window up", "category": "focus" },
|
||||||
|
{ "action": "move_dir", "direction": "right", "key": "l", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window right", "category": "focus" },
|
||||||
|
|
||||||
|
{ "action": "resize_dir", "x": 30, "y": 0, "key": "right", "mods": ["SUPER", "SHIFT"], "options": { "repeating": true }, "label": "Resize the focused window (grow right)", "category": "focus" },
|
||||||
|
{ "action": "resize_dir", "x": -30, "y": 0, "key": "left", "mods": ["SUPER", "SHIFT"], "options": { "repeating": true }, "label": "Resize the focused window (grow left)", "category": "focus" },
|
||||||
|
{ "action": "resize_dir", "x": 0, "y": -30, "key": "up", "mods": ["SUPER", "SHIFT"], "options": { "repeating": true }, "label": "Resize the focused window (grow up)", "category": "focus" },
|
||||||
|
{ "action": "resize_dir", "x": 0, "y": 30, "key": "down", "mods": ["SUPER", "SHIFT"], "options": { "repeating": true }, "label": "Resize the focused window (grow down)", "category": "focus" },
|
||||||
|
|
||||||
|
{ "action": "focus", "workspace": 1, "key": "1", "label": "Switch to workspace 1", "category": "workspaces" },
|
||||||
|
{ "action": "focus", "workspace": 2, "key": "2", "label": "Switch to workspace 2", "category": "workspaces" },
|
||||||
|
{ "action": "focus", "workspace": 3, "key": "3", "label": "Switch to workspace 3", "category": "workspaces" },
|
||||||
|
{ "action": "focus", "workspace": 4, "key": "4", "label": "Switch to workspace 4", "category": "workspaces" },
|
||||||
|
{ "action": "focus", "workspace": 5, "key": "5", "label": "Switch to workspace 5", "category": "workspaces" },
|
||||||
|
{ "action": "focus", "workspace": 6, "key": "6", "label": "Switch to workspace 6", "category": "workspaces" },
|
||||||
|
{ "action": "focus", "workspace": 7, "key": "7", "label": "Switch to workspace 7", "category": "workspaces" },
|
||||||
|
{ "action": "focus", "workspace": 8, "key": "8", "label": "Switch to workspace 8", "category": "workspaces" },
|
||||||
|
{ "action": "focus", "workspace": 9, "key": "9", "label": "Switch to workspace 9", "category": "workspaces" },
|
||||||
|
{ "action": "focus", "workspace": 10, "key": "0", "label": "Switch to workspace 10", "category": "workspaces" },
|
||||||
|
|
||||||
|
{ "action": "move", "workspace": 1, "key": "1", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 1", "category": "workspaces" },
|
||||||
|
{ "action": "move", "workspace": 2, "key": "2", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 2", "category": "workspaces" },
|
||||||
|
{ "action": "move", "workspace": 3, "key": "3", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 3", "category": "workspaces" },
|
||||||
|
{ "action": "move", "workspace": 4, "key": "4", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 4", "category": "workspaces" },
|
||||||
|
{ "action": "move", "workspace": 5, "key": "5", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 5", "category": "workspaces" },
|
||||||
|
{ "action": "move", "workspace": 6, "key": "6", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 6", "category": "workspaces" },
|
||||||
|
{ "action": "move", "workspace": 7, "key": "7", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 7", "category": "workspaces" },
|
||||||
|
{ "action": "move", "workspace": 8, "key": "8", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 8", "category": "workspaces" },
|
||||||
|
{ "action": "move", "workspace": 9, "key": "9", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 9", "category": "workspaces" },
|
||||||
|
{ "action": "move", "workspace": 10, "key": "0", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 10", "category": "workspaces" },
|
||||||
|
|
||||||
|
{ "action": "focus", "workspace": "e+1", "key": "bracketright", "label": "Next workspace", "category": "workspaces" },
|
||||||
|
{ "action": "focus", "workspace": "e-1", "key": "bracketleft", "label": "Previous workspace", "category": "workspaces" },
|
||||||
|
{ "action": "move", "workspace": "e+1", "key": "bracketright", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to the next workspace", "category": "workspaces" },
|
||||||
|
{ "action": "move", "workspace": "e-1", "key": "bracketleft", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to the previous workspace", "category": "workspaces" },
|
||||||
|
|
||||||
|
{ "action": "focus", "workspace": "e+1", "key": "mouse_down", "label": "Cycle to the next workspace (scroll)", "category": "mouse" },
|
||||||
|
{ "action": "focus", "workspace": "e-1", "key": "mouse_up", "label": "Cycle to the previous workspace (scroll)", "category": "mouse" },
|
||||||
|
{ "action": "drag", "key": "mouse:272", "options": { "mouse": true }, "label": "Move a window (drag)", "category": "mouse" },
|
||||||
|
{ "action": "resize", "key": "mouse:273", "options": { "mouse": true }, "label": "Resize a window (drag)", "category": "mouse" },
|
||||||
|
|
||||||
|
{ "action": "exec", "command": "wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+", "key": "XF86AudioRaiseVolume", "mods": [], "options": { "locked": true, "repeating": true }, "label": "Volume up", "category": "media" },
|
||||||
|
{ "action": "exec", "command": "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-", "key": "XF86AudioLowerVolume", "mods": [], "options": { "locked": true, "repeating": true }, "label": "Volume down", "category": "media" },
|
||||||
|
{ "action": "exec", "command": "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle", "key": "XF86AudioMute", "mods": [], "options": { "locked": true }, "label": "Mute", "category": "media" },
|
||||||
|
{ "action": "exec", "command": "wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle", "key": "XF86AudioMicMute", "mods": [], "options": { "locked": true }, "label": "Mic mute", "category": "media" },
|
||||||
|
{ "action": "exec", "command": "brightnessctl -e4 -n2 set 5%+", "key": "XF86MonBrightnessUp", "mods": [], "options": { "locked": true, "repeating": true }, "label": "Brightness up", "category": "media" },
|
||||||
|
{ "action": "exec", "command": "brightnessctl -e4 -n2 set 5%-", "key": "XF86MonBrightnessDown", "mods": [], "options": { "locked": true, "repeating": true }, "label": "Brightness down", "category": "media" },
|
||||||
|
{ "action": "exec", "command": "playerctl next", "key": "XF86AudioNext", "mods": [], "options": { "locked": true }, "label": "Next track", "category": "media" },
|
||||||
|
{ "action": "exec", "command": "playerctl previous", "key": "XF86AudioPrev", "mods": [], "options": { "locked": true }, "label": "Previous track", "category": "media" },
|
||||||
|
{ "action": "exec", "command": "playerctl play-pause", "key": "XF86AudioPlay", "mods": [], "options": { "locked": true }, "label": "Play / pause", "category": "media" },
|
||||||
|
{ "action": "exec", "command": "gnome-calculator", "key": "XF86Calculator", "mods": [], "label": "Open the calculator", "category": "media" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,61 +1,30 @@
|
||||||
-- BOS Hyprland configuration — native Lua config (Hyprland 0.55+).
|
-- BOS Hyprland configuration — native Lua config (Hyprland 0.55+).
|
||||||
-- hyprlang (.conf) is deprecated; this uses the built-in `hl` API.
|
-- hyprlang (.conf) is deprecated; this uses the built-in `hl` API.
|
||||||
-- Single-file and non-modular by design. Reference: https://wiki.hypr.land/
|
-- Mostly single-file by design (reference: https://wiki.hypr.land/) — the
|
||||||
|
-- exceptions are keybinds, appearance settings, monitor layout, and the
|
||||||
local mod = "SUPER"
|
-- extra autostart list, each loaded from a JSON file (binds.json,
|
||||||
|
-- settings.json, monitors.json, autostart.json) so bread* apps can read/edit
|
||||||
|
-- them as structured data instead of parsing this Lua file.
|
||||||
|
--
|
||||||
|
-- Every loader below is wrapped in `pcall`: this file is loaded as a single
|
||||||
|
-- Lua chunk, so an uncaught error partway through would abort everything
|
||||||
|
-- *after* it too (no keybinds, no window rules, no autostart) — a bad or
|
||||||
|
-- hand-edited JSON file must degrade to that one section's hardcoded
|
||||||
|
-- defaults, never take down the rest of the session.
|
||||||
|
local script_dir = os.getenv("HOME") .. "/.config/hypr/scripts/"
|
||||||
|
local config_home = os.getenv("HOME") .. "/.config/hypr/"
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- Monitors — generic default that works on any hardware.
|
-- Monitors — from monitors.json, always falls back to a generic
|
||||||
|
-- any-hardware default (see scripts/display/monitors.lua).
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" })
|
pcall(dofile, script_dir .. "display/monitors.lua")
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- Core settings.
|
-- Core appearance/input settings — from settings.json (see
|
||||||
|
-- scripts/ui/settings.lua).
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
hl.config({
|
pcall(dofile, script_dir .. "ui/settings.lua")
|
||||||
general = {
|
|
||||||
gaps_in = 5,
|
|
||||||
gaps_out = 10,
|
|
||||||
border_size = 2,
|
|
||||||
col = {
|
|
||||||
active_border = "rgba(88c0d0ff)",
|
|
||||||
inactive_border = "rgba(4c566aff)",
|
|
||||||
},
|
|
||||||
layout = "dwindle",
|
|
||||||
resize_on_border = true,
|
|
||||||
},
|
|
||||||
decoration = {
|
|
||||||
rounding = 8,
|
|
||||||
active_opacity = 1.0,
|
|
||||||
inactive_opacity = 1.0,
|
|
||||||
blur = {
|
|
||||||
enabled = true,
|
|
||||||
size = 6,
|
|
||||||
passes = 2,
|
|
||||||
new_optimizations = true,
|
|
||||||
},
|
|
||||||
shadow = {
|
|
||||||
enabled = true,
|
|
||||||
range = 12,
|
|
||||||
render_power = 3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
input = {
|
|
||||||
kb_layout = "us",
|
|
||||||
follow_mouse = 1,
|
|
||||||
touchpad = { natural_scroll = true },
|
|
||||||
},
|
|
||||||
dwindle = {
|
|
||||||
preserve_split = true,
|
|
||||||
},
|
|
||||||
animations = {
|
|
||||||
enabled = true,
|
|
||||||
},
|
|
||||||
misc = {
|
|
||||||
disable_hyprland_logo = true,
|
|
||||||
disable_splash_rendering = true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- Animations — snappy curves + per-leaf speeds (matches the reference laptop;
|
-- Animations — snappy curves + per-leaf speeds (matches the reference laptop;
|
||||||
|
|
@ -86,10 +55,12 @@ for _, animation in ipairs(animations) do
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- Window rules — float + centre the onboarding popups (kitty --class …).
|
-- Window rules — float + centre the onboarding/help popups.
|
||||||
|
-- breadhelp replaces the old bos-welcome/bos-keybinds kitty+less popups.
|
||||||
|
-- bos-netsetup (nmtui, from bos-netcheck) is unrelated to breadhelp and
|
||||||
|
-- still floats the same way.
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
hl.window_rule({ name = "bos-keybinds", match = { class = "^(bos-keybinds)$" }, float = true, size = { 760, 720 } })
|
hl.window_rule({ name = "breadhelp", match = { class = "^(com\\.breadway\\.breadhelp)$" }, float = true, size = { 880, 600 } })
|
||||||
hl.window_rule({ name = "bos-welcome", match = { class = "^(bos-welcome)$" }, float = true, size = { 700, 560 } })
|
|
||||||
hl.window_rule({ name = "bos-netsetup", match = { class = "^(bos-netsetup)$" }, float = true, size = { 700, 560 } })
|
hl.window_rule({ name = "bos-netsetup", match = { class = "^(bos-netsetup)$" }, float = true, size = { 700, 560 } })
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
@ -110,94 +81,31 @@ hl.env("_JAVA_AWT_WM_NONREPARENTING", "1")
|
||||||
-- above blurs behind the terminal while keeping text fully opaque.
|
-- above blurs behind the terminal while keeping text fully opaque.
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- Standard BOS keybinds (SUPER = mod).
|
-- Standard BOS keybinds — data-driven from binds.json (apps, windows,
|
||||||
-- ---------------------------------------------------------------------------
|
-- screenshots, focus/move/resize, workspaces, mouse, media keys), loaded via
|
||||||
-- Apps / window management
|
-- scripts/input/{binds,keybinds}.lua. breadhelp's keybind viewer reads
|
||||||
hl.bind(mod .. " + RETURN", hl.dsp.exec_cmd("kitty"))
|
-- binds.json directly as its source of truth (no separately-maintained
|
||||||
hl.bind(mod .. " + BACKSPACE", hl.dsp.window.close())
|
-- cheatsheet to keep in sync), and a future bos-settings editor can
|
||||||
hl.bind(mod .. " + SPACE", hl.dsp.exec_cmd("breadbox"))
|
-- read/write the same file.
|
||||||
hl.bind(mod .. " + E", hl.dsp.exec_cmd("nautilus"))
|
pcall(function()
|
||||||
hl.bind(mod .. " + B", hl.dsp.exec_cmd("zen-browser"))
|
local binds = dofile(script_dir .. "input/binds.lua")(config_home .. "binds.json")
|
||||||
hl.bind(mod .. " + U", hl.dsp.exec_cmd("breadpad"))
|
dofile(script_dir .. "input/keybinds.lua")({
|
||||||
hl.bind(mod .. " + M", hl.dsp.exec_cmd("breadman"))
|
default_mods = binds.default_mods,
|
||||||
hl.bind(mod .. " + comma", hl.dsp.exec_cmd("bos-settings"))
|
bindings = binds.bindings,
|
||||||
hl.bind(mod .. " + slash", hl.dsp.exec_cmd("bos-keybinds"))
|
})
|
||||||
hl.bind(mod .. " + L", hl.dsp.exec_cmd("loginctl lock-session"))
|
end)
|
||||||
hl.bind(mod .. " + F", hl.dsp.window.fullscreen({ action = "toggle" }))
|
|
||||||
hl.bind(mod .. " + I", hl.dsp.window.float({ action = "toggle" }))
|
|
||||||
hl.bind(mod .. " + P", hl.dsp.window.pseudo({ action = "toggle" }))
|
|
||||||
hl.bind(mod .. " + R", hl.dsp.window.resize())
|
|
||||||
-- breadclip (its own gtk4-layer-shell popup — not a TUI, so no terminal
|
|
||||||
-- needed). Previously piped cliphist through fzf directly from the
|
|
||||||
-- compositor with no terminal attached, which was a silent no-op.
|
|
||||||
-- Bound on both V (breadclip's own suggested default, freed up now that
|
|
||||||
-- float toggle moved to I) and SHIFT+V (kept for muscle memory).
|
|
||||||
hl.bind(mod .. " + V", hl.dsp.exec_cmd("breadclip"))
|
|
||||||
hl.bind(mod .. " + SHIFT + V", hl.dsp.exec_cmd("breadclip"))
|
|
||||||
hl.bind(mod .. " + T", hl.dsp.layout("togglesplit"))
|
|
||||||
hl.bind(mod .. " + Tab", hl.dsp.focus({ urgent_or_last = true }))
|
|
||||||
hl.bind(mod .. " + N", hl.dsp.exit())
|
|
||||||
|
|
||||||
-- Screenshots (grim + slurp + wl-clipboard)
|
|
||||||
hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd([[bash -c 'mkdir -p ~/Pictures/Screenshots && grim -g "$(slurp)" ~/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png']]))
|
|
||||||
hl.bind(mod .. " + SHIFT + C", hl.dsp.exec_cmd([[bash -c 'grim -g "$(slurp)" - | wl-copy']]))
|
|
||||||
hl.bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd([[bash -c 'mkdir -p ~/Pictures/Screenshots && grim ~/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png']]))
|
|
||||||
|
|
||||||
-- Focus (directional)
|
|
||||||
hl.bind(mod .. " + left", hl.dsp.focus({ direction = "left" }))
|
|
||||||
hl.bind(mod .. " + right", hl.dsp.focus({ direction = "right" }))
|
|
||||||
hl.bind(mod .. " + up", hl.dsp.focus({ direction = "up" }))
|
|
||||||
hl.bind(mod .. " + down", hl.dsp.focus({ direction = "down" }))
|
|
||||||
|
|
||||||
-- Move window (directional, vim keys)
|
|
||||||
hl.bind(mod .. " + SHIFT + h", hl.dsp.window.move({ direction = "left" }))
|
|
||||||
hl.bind(mod .. " + SHIFT + j", hl.dsp.window.move({ direction = "down" }))
|
|
||||||
hl.bind(mod .. " + SHIFT + k", hl.dsp.window.move({ direction = "up" }))
|
|
||||||
hl.bind(mod .. " + SHIFT + l", hl.dsp.window.move({ direction = "right" }))
|
|
||||||
|
|
||||||
-- Resize active window (arrows)
|
|
||||||
hl.bind(mod .. " + SHIFT + right", hl.dsp.window.resize({ x = 30, y = 0, relative = true }), { repeating = true })
|
|
||||||
hl.bind(mod .. " + SHIFT + left", hl.dsp.window.resize({ x = -30, y = 0, relative = true }), { repeating = true })
|
|
||||||
hl.bind(mod .. " + SHIFT + up", hl.dsp.window.resize({ x = 0, y = -30, relative = true }), { repeating = true })
|
|
||||||
hl.bind(mod .. " + SHIFT + down", hl.dsp.window.resize({ x = 0, y = 30, relative = true }), { repeating = true })
|
|
||||||
|
|
||||||
-- Workspaces 1–10 (0 = workspace 10)
|
|
||||||
for i = 1, 10 do
|
|
||||||
local key = tostring(i % 10)
|
|
||||||
hl.bind(mod .. " + " .. key, hl.dsp.focus({ workspace = i }))
|
|
||||||
hl.bind(mod .. " + SHIFT + " .. key, hl.dsp.window.move({ workspace = i }))
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Workspace cycling
|
|
||||||
hl.bind(mod .. " + bracketright", hl.dsp.focus({ workspace = "e+1" }))
|
|
||||||
hl.bind(mod .. " + bracketleft", hl.dsp.focus({ workspace = "e-1" }))
|
|
||||||
hl.bind(mod .. " + SHIFT + bracketright", hl.dsp.window.move({ workspace = "e+1" }))
|
|
||||||
hl.bind(mod .. " + SHIFT + bracketleft", hl.dsp.window.move({ workspace = "e-1" }))
|
|
||||||
|
|
||||||
-- Mouse
|
|
||||||
hl.bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "e+1" }))
|
|
||||||
hl.bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "e-1" }))
|
|
||||||
hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true })
|
|
||||||
hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true })
|
|
||||||
|
|
||||||
-- Media / hardware keys (work locked, i.e. on the lock screen too)
|
|
||||||
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+"), { locked = true, repeating = true })
|
|
||||||
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"), { locked = true, repeating = true })
|
|
||||||
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true })
|
|
||||||
hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true })
|
|
||||||
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { locked = true, repeating = true })
|
|
||||||
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"), { locked = true, repeating = true })
|
|
||||||
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true })
|
|
||||||
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true })
|
|
||||||
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true })
|
|
||||||
hl.bind("XF86Calculator", hl.dsp.exec_cmd("gnome-calculator"))
|
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- Autostart. polkit agent + the bread ecosystem + idle daemon + wallpaper.
|
-- Autostart. Core bootstrap sequence (polkit agent, dark theme, wallpaper
|
||||||
|
-- daemon, breadd's Wayland-env fix, breadclipd) stays hardcoded here — it's
|
||||||
|
-- timing/order-sensitive infrastructure, not something a settings UI should
|
||||||
|
-- expose for a user to disable or reorder. The extra, genuinely toggleable
|
||||||
|
-- apps (breadbar, hypridle, bos-netcheck, breadhelp) come from
|
||||||
|
-- autostart.json via scripts/system/autostart.lua, appended after.
|
||||||
-- (bos-live-setup appends the live-installer launch below this on the ISO.)
|
-- (bos-live-setup appends the live-installer launch below this on the ISO.)
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
hl.on("hyprland.start", function()
|
hl.on("hyprland.start", function()
|
||||||
local startup = {
|
local core_startup = {
|
||||||
-- Generate the shared bread GUI stylesheet first, so breadbar/breadbox/
|
-- Generate the shared bread GUI stylesheet first, so breadbar/breadbox/
|
||||||
-- bos-settings load it on start (they also live-reload if it changes).
|
-- bos-settings load it on start (they also live-reload if it changes).
|
||||||
"bread-theme generate",
|
"bread-theme generate",
|
||||||
|
|
@ -239,16 +147,24 @@ hl.on("hyprland.start", function()
|
||||||
-- Start it directly instead. If more graphical-session.target
|
-- Start it directly instead. If more graphical-session.target
|
||||||
-- services show up later, add them here too.
|
-- services show up later, add them here too.
|
||||||
"systemctl --user start breadclipd.service",
|
"systemctl --user start breadclipd.service",
|
||||||
"breadbar",
|
|
||||||
-- breadbox-sync is a Type=oneshot systemd --user service
|
|
||||||
-- (WantedBy=default.target, no Hyprland IPC dependency) — it
|
|
||||||
-- already runs on login via the unit baked into skel; exec'ing it
|
|
||||||
-- again here would just start it twice.
|
|
||||||
"hypridle",
|
|
||||||
-- first-boot onboarding (self-gates after the first run)
|
|
||||||
"bos-welcome",
|
|
||||||
}
|
}
|
||||||
for _, cmd in ipairs(startup) do
|
for _, cmd in ipairs(core_startup) do
|
||||||
|
hl.dispatch(hl.dsp.exec_cmd(cmd))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- breadbox-sync is a Type=oneshot systemd --user service
|
||||||
|
-- (WantedBy=default.target, no Hyprland IPC dependency) — it already
|
||||||
|
-- runs on login via the unit baked into skel, independent of this list.
|
||||||
|
local ok, extra = pcall(function()
|
||||||
|
return dofile(script_dir .. "system/autostart.lua")()
|
||||||
|
end)
|
||||||
|
if not ok or type(extra) ~= "table" then
|
||||||
|
-- autostart.json/its loader broke — fall back to the same apps BOS
|
||||||
|
-- has always started, so a bad JSON edit degrades to "normal
|
||||||
|
-- desktop" rather than "no bar, no idle lock, no onboarding".
|
||||||
|
extra = { "breadbar", "hypridle", "bos-netcheck", "breadhelp --autostart" }
|
||||||
|
end
|
||||||
|
for _, cmd in ipairs(extra) do
|
||||||
hl.dispatch(hl.dsp.exec_cmd(cmd))
|
hl.dispatch(hl.dsp.exec_cmd(cmd))
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
5
iso/airootfs/etc/skel/.config/hypr/monitors.json
Normal file
5
iso/airootfs/etc/skel/.config/hypr/monitors.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"monitors": [
|
||||||
|
{ "output": "", "mode": "preferred", "position": "auto", "scale": "auto" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
-- scripts/display/monitors.lua — loads monitors.json (an array of monitor
|
||||||
|
-- rules) and applies each via hl.monitor(). Failsafe: an empty or entirely
|
||||||
|
-- invalid monitors.json is treated the same as a missing one — falling back
|
||||||
|
-- to the single generic wildcard rule — because applying *zero* monitor
|
||||||
|
-- rules risks an unconfigured/black-screen session, unlike settings.json or
|
||||||
|
-- autostart.json where "apply nothing extra" is a legitimate user choice.
|
||||||
|
local json = dofile(os.getenv("HOME") .. "/.config/hypr/scripts/lib/json.lua")
|
||||||
|
|
||||||
|
local DEFAULT_MONITORS = {
|
||||||
|
{ output = "", mode = "preferred", position = "auto", scale = "auto" },
|
||||||
|
}
|
||||||
|
|
||||||
|
local function valid_entry(e)
|
||||||
|
return type(e) == "table" and type(e.output) == "string"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function load_monitors()
|
||||||
|
local path = os.getenv("HOME") .. "/.config/hypr/monitors.json"
|
||||||
|
local parsed = json.load(path)
|
||||||
|
if type(parsed) ~= "table" or type(parsed.monitors) ~= "table" then
|
||||||
|
return DEFAULT_MONITORS
|
||||||
|
end
|
||||||
|
|
||||||
|
local valid = {}
|
||||||
|
for _, entry in ipairs(parsed.monitors) do
|
||||||
|
if valid_entry(entry) then
|
||||||
|
valid[#valid + 1] = {
|
||||||
|
output = entry.output,
|
||||||
|
mode = (type(entry.mode) == "string" and entry.mode) or "preferred",
|
||||||
|
position = (type(entry.position) == "string" and entry.position) or "auto",
|
||||||
|
scale = entry.scale ~= nil and entry.scale or "auto",
|
||||||
|
mirror = type(entry.mirror) == "string" and entry.mirror or nil,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #valid == 0 then
|
||||||
|
return DEFAULT_MONITORS
|
||||||
|
end
|
||||||
|
return valid
|
||||||
|
end
|
||||||
|
|
||||||
|
local monitors = load_monitors()
|
||||||
|
local applied_any = false
|
||||||
|
for _, m in ipairs(monitors) do
|
||||||
|
if pcall(hl.monitor, m) then
|
||||||
|
applied_any = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Every entry failed to apply (e.g. Hyprland rejected values that still
|
||||||
|
-- passed our type checks) — guarantee a usable session rather than leaving
|
||||||
|
-- every monitor unconfigured.
|
||||||
|
if not applied_any then
|
||||||
|
pcall(hl.monitor, DEFAULT_MONITORS[1])
|
||||||
|
end
|
||||||
35
iso/airootfs/etc/skel/.config/hypr/scripts/input/binds.lua
Normal file
35
iso/airootfs/etc/skel/.config/hypr/scripts/input/binds.lua
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
-- scripts/input/binds.lua — loads binds.json into { default_mods, bindings }.
|
||||||
|
-- Simpler than the multi-keyboard-layout version some personal configs use
|
||||||
|
-- (BOS ships one fixed layout, no per-layout bind switching needed): just
|
||||||
|
-- reads `default_mods` + the flat `bindings` array.
|
||||||
|
|
||||||
|
local json = dofile(os.getenv("HOME") .. "/.config/hypr/scripts/lib/json.lua")
|
||||||
|
|
||||||
|
local function normalize_mods(value, fallback)
|
||||||
|
local mods = {}
|
||||||
|
if type(value) == "table" then
|
||||||
|
for _, item in ipairs(value) do
|
||||||
|
if type(item) == "string" and item ~= "" then
|
||||||
|
mods[#mods + 1] = item
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #mods == 0 then
|
||||||
|
for _, mod in ipairs(fallback or {}) do
|
||||||
|
mods[#mods + 1] = mod
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return mods
|
||||||
|
end
|
||||||
|
|
||||||
|
return function(configPath)
|
||||||
|
local parsed = json.load(configPath)
|
||||||
|
if type(parsed) ~= "table" then
|
||||||
|
return { default_mods = { "SUPER" }, bindings = {} }
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
default_mods = normalize_mods(parsed.default_mods, { "SUPER" }),
|
||||||
|
bindings = type(parsed.bindings) == "table" and parsed.bindings or {},
|
||||||
|
}
|
||||||
|
end
|
||||||
124
iso/airootfs/etc/skel/.config/hypr/scripts/input/keybinds.lua
Normal file
124
iso/airootfs/etc/skel/.config/hypr/scripts/input/keybinds.lua
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
-- scripts/input/keybinds.lua — turns binds.json entries into real hl.bind()
|
||||||
|
-- calls. Ported from a personal modular Hyprland config using the same
|
||||||
|
-- `hl` API; action_builders covers every dispatcher shape BOS's keybinds
|
||||||
|
-- actually use (exec, window management, focus/move/resize, workspaces,
|
||||||
|
-- mouse, media keys).
|
||||||
|
return function(ctx)
|
||||||
|
local function split_mods(value)
|
||||||
|
local mods = {}
|
||||||
|
for raw_mod in tostring(value):gmatch("[^+]+") do
|
||||||
|
local mod = raw_mod:gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
|
if mod ~= "" then
|
||||||
|
mods[#mods + 1] = mod
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return mods
|
||||||
|
end
|
||||||
|
|
||||||
|
-- `allow_empty`: an explicit `"mods": []` in binds.json (media keys,
|
||||||
|
-- which must bind with no modifier at all) must NOT fall back to
|
||||||
|
-- default_mods — only an *omitted* `mods` field should.
|
||||||
|
local function normalize_mods(value, fallback, allow_empty)
|
||||||
|
if value == nil then
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
if type(value) ~= "table" then
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
if #value == 0 then
|
||||||
|
return allow_empty and {} or fallback
|
||||||
|
end
|
||||||
|
local mods = {}
|
||||||
|
for _, item in ipairs(value) do
|
||||||
|
if type(item) == "string" then
|
||||||
|
for _, mod in ipairs(split_mods(item)) do
|
||||||
|
mods[#mods + 1] = mod
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #mods == 0 then
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
return mods
|
||||||
|
end
|
||||||
|
|
||||||
|
local defaultMods = normalize_mods(ctx.default_mods, { "SUPER" }, false)
|
||||||
|
local bindings = ctx.bindings or {}
|
||||||
|
|
||||||
|
local function bind_string(mods, key)
|
||||||
|
if type(key) ~= "string" or key == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if #mods == 0 then
|
||||||
|
return key
|
||||||
|
end
|
||||||
|
return table.concat(mods, " + ") .. " + " .. key
|
||||||
|
end
|
||||||
|
|
||||||
|
local action_builders = {
|
||||||
|
exec = function(entry)
|
||||||
|
return hl.dsp.exec_cmd(entry.command)
|
||||||
|
end,
|
||||||
|
close = function()
|
||||||
|
return hl.dsp.window.close()
|
||||||
|
end,
|
||||||
|
exit = function()
|
||||||
|
return hl.dsp.exit()
|
||||||
|
end,
|
||||||
|
float = function()
|
||||||
|
return hl.dsp.window.float({ action = "toggle" })
|
||||||
|
end,
|
||||||
|
fullscreen = function()
|
||||||
|
return hl.dsp.window.fullscreen({ action = "toggle" })
|
||||||
|
end,
|
||||||
|
pseudo = function()
|
||||||
|
return hl.dsp.window.pseudo({ action = "toggle" })
|
||||||
|
end,
|
||||||
|
layout = function(entry)
|
||||||
|
return hl.dsp.layout(entry.layout)
|
||||||
|
end,
|
||||||
|
focus = function(entry)
|
||||||
|
return hl.dsp.focus({ direction = entry.direction, workspace = entry.workspace })
|
||||||
|
end,
|
||||||
|
focus_last = function()
|
||||||
|
return hl.dsp.focus({ urgent_or_last = true })
|
||||||
|
end,
|
||||||
|
move = function(entry)
|
||||||
|
return hl.dsp.window.move({ workspace = entry.workspace })
|
||||||
|
end,
|
||||||
|
move_dir = function(entry)
|
||||||
|
return hl.dsp.window.move({ direction = entry.direction })
|
||||||
|
end,
|
||||||
|
resize = function()
|
||||||
|
return hl.dsp.window.resize()
|
||||||
|
end,
|
||||||
|
resize_dir = function(entry)
|
||||||
|
return hl.dsp.window.resize({ x = entry.x or 0, y = entry.y or 0, relative = true })
|
||||||
|
end,
|
||||||
|
drag = function()
|
||||||
|
return hl.dsp.window.drag()
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- One bad entry (unknown action, missing field a builder needs) must
|
||||||
|
-- never take down every other bind — skip it and keep going instead of
|
||||||
|
-- asserting/erroring, which would abort binds.json loading entirely.
|
||||||
|
for _, entry in ipairs(bindings) do
|
||||||
|
local builder = action_builders[entry.action]
|
||||||
|
if builder then
|
||||||
|
local ok, result = pcall(function()
|
||||||
|
local mods = normalize_mods(entry.mods, defaultMods, true)
|
||||||
|
local bind = bind_string(mods, entry.key)
|
||||||
|
local action = builder(entry)
|
||||||
|
if bind and action then
|
||||||
|
hl.bind(bind, action, entry.options)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
print("breadhelp/hyprland: skipping bad bind entry (" .. tostring(entry.action) .. "/" .. tostring(entry.key) .. "): " .. tostring(result))
|
||||||
|
end
|
||||||
|
else
|
||||||
|
print("breadhelp/hyprland: skipping bind entry with unknown action: " .. tostring(entry.action))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
180
iso/airootfs/etc/skel/.config/hypr/scripts/lib/json.lua
Normal file
180
iso/airootfs/etc/skel/.config/hypr/scripts/lib/json.lua
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
-- scripts/lib/json.lua
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.parse(str)
|
||||||
|
local s = str
|
||||||
|
local pos = 1
|
||||||
|
local len = #s
|
||||||
|
|
||||||
|
local function skipws()
|
||||||
|
while pos <= len and s:sub(pos,pos):match('%s') do pos = pos + 1 end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parse_string()
|
||||||
|
if s:sub(pos,pos) ~= '"' then error('expected string') end
|
||||||
|
pos = pos + 1
|
||||||
|
local out = {}
|
||||||
|
while pos <= len do
|
||||||
|
local c = s:sub(pos,pos)
|
||||||
|
if c == '"' then pos = pos + 1; return table.concat(out) end
|
||||||
|
if c == '\\' then
|
||||||
|
local n = s:sub(pos+1,pos+1)
|
||||||
|
if n == '"' then out[#out+1] = '"'; pos = pos + 2
|
||||||
|
elseif n == '\\' then out[#out+1] = '\\'; pos = pos + 2
|
||||||
|
elseif n == '/' then out[#out+1] = '/'; pos = pos + 2
|
||||||
|
elseif n == 'b' then out[#out+1] = '\b'; pos = pos + 2
|
||||||
|
elseif n == 'f' then out[#out+1] = '\f'; pos = pos + 2
|
||||||
|
elseif n == 'n' then out[#out+1] = '\n'; pos = pos + 2
|
||||||
|
elseif n == 'r' then out[#out+1] = '\r'; pos = pos + 2
|
||||||
|
elseif n == 't' then out[#out+1] = '\t'; pos = pos + 2
|
||||||
|
elseif n == 'u' then
|
||||||
|
local hex = s:sub(pos+2, pos+5)
|
||||||
|
local code = tonumber(hex, 16)
|
||||||
|
if code then out[#out+1] = utf8.char(code) end
|
||||||
|
pos = pos + 6
|
||||||
|
else
|
||||||
|
pos = pos + 2
|
||||||
|
end
|
||||||
|
else
|
||||||
|
out[#out+1] = c
|
||||||
|
pos = pos + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
error('unclosed string')
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parse_value()
|
||||||
|
skipws()
|
||||||
|
local c = s:sub(pos,pos)
|
||||||
|
if c == '"' then return parse_string()
|
||||||
|
elseif c == '{' then
|
||||||
|
return parse_object()
|
||||||
|
elseif c == '[' then
|
||||||
|
return parse_array()
|
||||||
|
elseif c:match('[%d%-]') then
|
||||||
|
local start = pos
|
||||||
|
while s:sub(pos,pos):match('[%d+%-.eE]') do pos = pos + 1 end
|
||||||
|
local num = tonumber(s:sub(start, pos-1))
|
||||||
|
return num
|
||||||
|
elseif s:sub(pos,pos+3) == 'null' then pos = pos + 4; return nil
|
||||||
|
elseif s:sub(pos,pos+3) == 'true' then pos = pos + 4; return true
|
||||||
|
elseif s:sub(pos,pos+4) == 'false' then pos = pos + 5; return false
|
||||||
|
else
|
||||||
|
error('unexpected value at ' .. pos)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function parse_array()
|
||||||
|
if s:sub(pos,pos) ~= '[' then error('expected [') end
|
||||||
|
pos = pos + 1
|
||||||
|
skipws()
|
||||||
|
local arr = {}
|
||||||
|
if s:sub(pos,pos) == ']' then pos = pos + 1; return arr end
|
||||||
|
while true do
|
||||||
|
skipws()
|
||||||
|
local val = parse_value()
|
||||||
|
table.insert(arr, val)
|
||||||
|
skipws()
|
||||||
|
local c = s:sub(pos,pos)
|
||||||
|
if c == ']' then pos = pos + 1; break
|
||||||
|
elseif c == ',' then pos = pos + 1; skipws()
|
||||||
|
else error('expected , or ]') end
|
||||||
|
end
|
||||||
|
return arr
|
||||||
|
end
|
||||||
|
|
||||||
|
function parse_object()
|
||||||
|
if s:sub(pos,pos) ~= '{' then error('expected {') end
|
||||||
|
pos = pos + 1
|
||||||
|
skipws()
|
||||||
|
local obj = {}
|
||||||
|
if s:sub(pos,pos) == '}' then pos = pos + 1; return obj end
|
||||||
|
while true do
|
||||||
|
skipws()
|
||||||
|
local key = parse_string()
|
||||||
|
skipws()
|
||||||
|
if s:sub(pos,pos) ~= ':' then error('expected :') end
|
||||||
|
pos = pos + 1
|
||||||
|
skipws()
|
||||||
|
local val = parse_value()
|
||||||
|
obj[key] = val
|
||||||
|
skipws()
|
||||||
|
local c = s:sub(pos,pos)
|
||||||
|
if c == '}' then pos = pos + 1; break
|
||||||
|
elseif c == ',' then pos = pos + 1; skipws()
|
||||||
|
else error('expected , or }') end
|
||||||
|
end
|
||||||
|
return obj
|
||||||
|
end
|
||||||
|
|
||||||
|
skipws()
|
||||||
|
return parse_value()
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.load(path)
|
||||||
|
local ok, fh = pcall(io.open, path, "r")
|
||||||
|
if not ok or not fh then
|
||||||
|
return nil, "unable to open file"
|
||||||
|
end
|
||||||
|
|
||||||
|
local content = fh:read("*a")
|
||||||
|
fh:close()
|
||||||
|
|
||||||
|
local success, parsed = pcall(M.parse, content)
|
||||||
|
if not success then
|
||||||
|
return nil, parsed
|
||||||
|
end
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.encode(value, indent)
|
||||||
|
indent = indent or 0
|
||||||
|
local indent_str = string.rep(" ", indent)
|
||||||
|
local next_indent_str = string.rep(" ", indent + 1)
|
||||||
|
|
||||||
|
if value == nil then
|
||||||
|
return "null"
|
||||||
|
elseif type(value) == "boolean" then
|
||||||
|
return value and "true" or "false"
|
||||||
|
elseif type(value) == "number" then
|
||||||
|
return tostring(value)
|
||||||
|
elseif type(value) == "string" then
|
||||||
|
return '"' .. value:gsub('\\', '\\\\'):gsub('"', '\\"'):gsub('\n', '\\n'):gsub('\r', '\\r'):gsub('\t', '\\t') .. '"'
|
||||||
|
elseif type(value) == "table" then
|
||||||
|
local is_array = true
|
||||||
|
local max_idx = 0
|
||||||
|
for k in pairs(value) do
|
||||||
|
if type(k) ~= "number" then
|
||||||
|
is_array = false
|
||||||
|
break
|
||||||
|
end
|
||||||
|
max_idx = math.max(max_idx, k)
|
||||||
|
end
|
||||||
|
|
||||||
|
if is_array and max_idx == #value then
|
||||||
|
if max_idx == 0 then
|
||||||
|
return "[]"
|
||||||
|
end
|
||||||
|
local items = {}
|
||||||
|
for i = 1, max_idx do
|
||||||
|
table.insert(items, next_indent_str .. M.encode(value[i], indent + 1))
|
||||||
|
end
|
||||||
|
return "[\n" .. table.concat(items, ",\n") .. "\n" .. indent_str .. "]"
|
||||||
|
else
|
||||||
|
local items = {}
|
||||||
|
for k, v in pairs(value) do
|
||||||
|
table.insert(items, next_indent_str .. M.encode(tostring(k), 0) .. ": " .. M.encode(v, indent + 1))
|
||||||
|
end
|
||||||
|
if #items == 0 then
|
||||||
|
return "{}"
|
||||||
|
end
|
||||||
|
table.sort(items)
|
||||||
|
return "{\n" .. table.concat(items, ",\n") .. "\n" .. indent_str .. "}"
|
||||||
|
end
|
||||||
|
else
|
||||||
|
return "null"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
-- scripts/system/autostart.lua — loads the *extra* (user-toggleable)
|
||||||
|
-- autostart list from autostart.json. Returns just the enabled commands, in
|
||||||
|
-- order. The core bootstrap sequence (theme generation, dark-mode gsettings,
|
||||||
|
-- polkit agent, wallpaper daemon, breadd's Wayland-env fix, breadclipd) is
|
||||||
|
-- deliberately NOT exposed here — it's timing/order-sensitive infrastructure,
|
||||||
|
-- not something a settings UI should let a user disable, so it stays
|
||||||
|
-- hardcoded in hyprland.lua itself.
|
||||||
|
--
|
||||||
|
-- Failsafe: unlike monitors.json, an empty or all-disabled result here is a
|
||||||
|
-- legitimate user choice (they don't want breadbar/hypridle/etc), so this
|
||||||
|
-- only falls back to defaults on a missing/malformed file — never just
|
||||||
|
-- because the valid result happens to be empty.
|
||||||
|
local json = dofile(os.getenv("HOME") .. "/.config/hypr/scripts/lib/json.lua")
|
||||||
|
|
||||||
|
local DEFAULT_EXTRA = {
|
||||||
|
{ command = "breadbar", enabled = true },
|
||||||
|
{ command = "hypridle", enabled = true },
|
||||||
|
{ command = "bos-netcheck", enabled = true },
|
||||||
|
{ command = "breadhelp --autostart", enabled = true },
|
||||||
|
}
|
||||||
|
|
||||||
|
return function()
|
||||||
|
local path = os.getenv("HOME") .. "/.config/hypr/autostart.json"
|
||||||
|
local parsed = json.load(path)
|
||||||
|
local entries
|
||||||
|
if type(parsed) ~= "table" or type(parsed.extra) ~= "table" then
|
||||||
|
entries = DEFAULT_EXTRA
|
||||||
|
else
|
||||||
|
entries = parsed.extra
|
||||||
|
end
|
||||||
|
|
||||||
|
local commands = {}
|
||||||
|
for _, entry in ipairs(entries) do
|
||||||
|
if type(entry) == "table" and type(entry.command) == "string" and entry.command ~= "" then
|
||||||
|
local enabled = entry.enabled
|
||||||
|
if type(enabled) ~= "boolean" then
|
||||||
|
enabled = true
|
||||||
|
end
|
||||||
|
if enabled then
|
||||||
|
commands[#commands + 1] = entry.command
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return commands
|
||||||
|
end
|
||||||
134
iso/airootfs/etc/skel/.config/hypr/scripts/ui/settings.lua
Normal file
134
iso/airootfs/etc/skel/.config/hypr/scripts/ui/settings.lua
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
-- scripts/ui/settings.lua — loads settings.json over hardcoded defaults and
|
||||||
|
-- applies via hl.config(). Failsafe in two layers:
|
||||||
|
-- 1. every leaf value is type-checked against its default individually, so
|
||||||
|
-- one bad field (wrong type, typo) falls back to just that field, not
|
||||||
|
-- the whole file;
|
||||||
|
-- 2. the actual hl.config() application is pcall'd — if Hyprland itself
|
||||||
|
-- rejects a well-typed-but-semantically-bad value, we fall back to
|
||||||
|
-- re-applying pure hardcoded defaults, so the session always comes up
|
||||||
|
-- with a normal, usable layout instead of erroring out mid-config-load.
|
||||||
|
local json = dofile(os.getenv("HOME") .. "/.config/hypr/scripts/lib/json.lua")
|
||||||
|
|
||||||
|
local DEFAULTS = {
|
||||||
|
gaps_in = 5,
|
||||||
|
gaps_out = 10,
|
||||||
|
border_size = 2,
|
||||||
|
active_border = "rgba(88c0d0ff)",
|
||||||
|
inactive_border = "rgba(4c566aff)",
|
||||||
|
layout = "dwindle",
|
||||||
|
resize_on_border = true,
|
||||||
|
rounding = 8,
|
||||||
|
blur_enabled = true,
|
||||||
|
blur_size = 6,
|
||||||
|
blur_passes = 2,
|
||||||
|
shadow_enabled = true,
|
||||||
|
shadow_range = 12,
|
||||||
|
shadow_render_power = 3,
|
||||||
|
kb_layout = "us",
|
||||||
|
follow_mouse = 1,
|
||||||
|
natural_scroll = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function num(v, fallback)
|
||||||
|
if type(v) == "number" then return v end
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
|
||||||
|
local function bool(v, fallback)
|
||||||
|
if type(v) == "boolean" then return v end
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
|
||||||
|
local function str(v, fallback)
|
||||||
|
if type(v) == "string" and v ~= "" then return v end
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
|
||||||
|
local function load_overrides()
|
||||||
|
local path = os.getenv("HOME") .. "/.config/hypr/settings.json"
|
||||||
|
local parsed = json.load(path)
|
||||||
|
if type(parsed) ~= "table" then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
return parsed
|
||||||
|
end
|
||||||
|
|
||||||
|
local o = load_overrides()
|
||||||
|
|
||||||
|
local merged = {
|
||||||
|
gaps_in = num(o.gaps_in, DEFAULTS.gaps_in),
|
||||||
|
gaps_out = num(o.gaps_out, DEFAULTS.gaps_out),
|
||||||
|
border_size = num(o.border_size, DEFAULTS.border_size),
|
||||||
|
active_border = str(o.active_border, DEFAULTS.active_border),
|
||||||
|
inactive_border = str(o.inactive_border, DEFAULTS.inactive_border),
|
||||||
|
layout = str(o.layout, DEFAULTS.layout),
|
||||||
|
resize_on_border = bool(o.resize_on_border, DEFAULTS.resize_on_border),
|
||||||
|
rounding = num(o.rounding, DEFAULTS.rounding),
|
||||||
|
blur_enabled = bool(o.blur_enabled, DEFAULTS.blur_enabled),
|
||||||
|
blur_size = num(o.blur_size, DEFAULTS.blur_size),
|
||||||
|
blur_passes = num(o.blur_passes, DEFAULTS.blur_passes),
|
||||||
|
shadow_enabled = bool(o.shadow_enabled, DEFAULTS.shadow_enabled),
|
||||||
|
shadow_range = num(o.shadow_range, DEFAULTS.shadow_range),
|
||||||
|
shadow_render_power = num(o.shadow_render_power, DEFAULTS.shadow_render_power),
|
||||||
|
kb_layout = str(o.kb_layout, DEFAULTS.kb_layout),
|
||||||
|
follow_mouse = num(o.follow_mouse, DEFAULTS.follow_mouse),
|
||||||
|
natural_scroll = bool(o.natural_scroll, DEFAULTS.natural_scroll),
|
||||||
|
}
|
||||||
|
|
||||||
|
local function build_hl_config(v)
|
||||||
|
return {
|
||||||
|
general = {
|
||||||
|
gaps_in = v.gaps_in,
|
||||||
|
gaps_out = v.gaps_out,
|
||||||
|
border_size = v.border_size,
|
||||||
|
col = {
|
||||||
|
active_border = v.active_border,
|
||||||
|
inactive_border = v.inactive_border,
|
||||||
|
},
|
||||||
|
layout = v.layout,
|
||||||
|
resize_on_border = v.resize_on_border,
|
||||||
|
},
|
||||||
|
decoration = {
|
||||||
|
rounding = v.rounding,
|
||||||
|
active_opacity = 1.0,
|
||||||
|
inactive_opacity = 1.0,
|
||||||
|
blur = {
|
||||||
|
enabled = v.blur_enabled,
|
||||||
|
size = v.blur_size,
|
||||||
|
passes = v.blur_passes,
|
||||||
|
new_optimizations = true,
|
||||||
|
},
|
||||||
|
shadow = {
|
||||||
|
enabled = v.shadow_enabled,
|
||||||
|
range = v.shadow_range,
|
||||||
|
render_power = v.shadow_render_power,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
input = {
|
||||||
|
kb_layout = v.kb_layout,
|
||||||
|
follow_mouse = v.follow_mouse,
|
||||||
|
touchpad = { natural_scroll = v.natural_scroll },
|
||||||
|
},
|
||||||
|
dwindle = { preserve_split = true },
|
||||||
|
animations = { enabled = true },
|
||||||
|
misc = { disable_hyprland_logo = true, disable_splash_rendering = true },
|
||||||
|
-- 3-finger touchpad swipe switches workspaces (touchscreen/touchpad
|
||||||
|
-- gesture, native to Hyprland — no plugin needed).
|
||||||
|
gestures = {
|
||||||
|
workspace_swipe = true,
|
||||||
|
workspace_swipe_fingers = 3,
|
||||||
|
workspace_swipe_distance = 300,
|
||||||
|
workspace_swipe_invert = true,
|
||||||
|
workspace_swipe_min_speed_to_force = 30,
|
||||||
|
workspace_swipe_cancel_ratio = 0.5,
|
||||||
|
workspace_swipe_create_new = true,
|
||||||
|
workspace_swipe_direction_lock = true,
|
||||||
|
workspace_swipe_forever = false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok = pcall(hl.config, build_hl_config(merged))
|
||||||
|
if not ok then
|
||||||
|
pcall(hl.config, build_hl_config(DEFAULTS))
|
||||||
|
end
|
||||||
19
iso/airootfs/etc/skel/.config/hypr/settings.json
Normal file
19
iso/airootfs/etc/skel/.config/hypr/settings.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"gaps_in": 5,
|
||||||
|
"gaps_out": 10,
|
||||||
|
"border_size": 2,
|
||||||
|
"active_border": "rgba(88c0d0ff)",
|
||||||
|
"inactive_border": "rgba(4c566aff)",
|
||||||
|
"layout": "dwindle",
|
||||||
|
"resize_on_border": true,
|
||||||
|
"rounding": 8,
|
||||||
|
"blur_enabled": true,
|
||||||
|
"blur_size": 6,
|
||||||
|
"blur_passes": 2,
|
||||||
|
"shadow_enabled": true,
|
||||||
|
"shadow_range": 12,
|
||||||
|
"shadow_render_power": 3,
|
||||||
|
"kb_layout": "us",
|
||||||
|
"follow_mouse": 1,
|
||||||
|
"natural_scroll": true
|
||||||
|
}
|
||||||
|
|
@ -81,6 +81,14 @@ alias ip='ip --color=auto'
|
||||||
alias update='bos-update'
|
alias update='bos-update'
|
||||||
alias pacman='sudo pacman'
|
alias pacman='sudo pacman'
|
||||||
|
|
||||||
|
# Package shortcuts — official repos via pacman, AUR via yay (alt-* prefix).
|
||||||
|
alias install='sudo pacman -S'
|
||||||
|
alias uninstall='sudo pacman -R'
|
||||||
|
alias srchpkg='sudo pacman -Ss'
|
||||||
|
alias alt-install='yay -S'
|
||||||
|
alias alt-uninstall='yay -R'
|
||||||
|
alias alt-srchpkg='yay -Ss'
|
||||||
|
|
||||||
# ~/.local/bin holds the bread* binaries baked in at build time.
|
# ~/.local/bin holds the bread* binaries baked in at build time.
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# Show the BOS keybind cheatsheet in a floating terminal (bound to SUPER+/).
|
|
||||||
# The bos-keybinds window class is floated/centred by a Hyprland window rule.
|
|
||||||
exec kitty --class bos-keybinds --title "BOS Keybinds" -- less -R /usr/share/bos/keybinds.txt
|
|
||||||
35
iso/airootfs/usr/local/bin/bos-netcheck
Executable file
35
iso/airootfs/usr/local/bin/bos-netcheck
Executable file
|
|
@ -0,0 +1,35 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Every-login network connectivity check. Split out of the old bos-welcome
|
||||||
|
# script, which conflated this with one-time onboarding (now breadhelp's
|
||||||
|
# job) — this half must keep running unconditionally on every login, before
|
||||||
|
# any GUI toolkit is worth spinning up, and must never be gated by a marker.
|
||||||
|
set -u
|
||||||
|
|
||||||
|
# Never run in the live/installer session — only on an installed system.
|
||||||
|
[[ "$(id -un)" == "liveuser" ]] && exit 0
|
||||||
|
|
||||||
|
# A fresh install usually boots with no connection (Wi-Fi isn't configured
|
||||||
|
# during install), and the first `bos-update`/pacman run then fails with
|
||||||
|
# confusing DNS/"could not resolve host" errors. If NetworkManager reports
|
||||||
|
# we're not fully online, open nmtui so the user can join a network before
|
||||||
|
# anything else. Best-effort: missing nmcli/nmtui/kitty, or the user quitting
|
||||||
|
# nmtui, must never block the rest of login.
|
||||||
|
command -v nmcli &>/dev/null || exit 0
|
||||||
|
|
||||||
|
conn="$(nmcli networking connectivity check 2>/dev/null)"
|
||||||
|
# NetworkManager may still be associating right at compositor start — give it
|
||||||
|
# a few short retries before concluding we're actually offline, so a machine
|
||||||
|
# with working Wi-Fi doesn't get a spurious nmtui popup.
|
||||||
|
tries=0
|
||||||
|
while [[ "$conn" != "full" && "$tries" -lt 3 ]]; do
|
||||||
|
sleep 1
|
||||||
|
conn="$(nmcli networking connectivity check 2>/dev/null)"
|
||||||
|
tries=$((tries + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$conn" != "full" ]]; then
|
||||||
|
notify-send -u normal "BOS" "No internet yet — opening network setup so updates work." 2>/dev/null || true
|
||||||
|
if command -v nmtui &>/dev/null; then
|
||||||
|
kitty --class bos-netsetup --title "Connect to a network" -- nmtui connect 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# First-run welcome + first-run/every-login network check. Launched from the
|
|
||||||
# Hyprland autostart; the bos-welcome window class is floated/centred by a
|
|
||||||
# Hyprland window rule.
|
|
||||||
set -u
|
|
||||||
|
|
||||||
# Never run in the live/installer session — only on an installed system.
|
|
||||||
[[ "$(id -un)" == "liveuser" ]] && exit 0
|
|
||||||
|
|
||||||
welcomed_marker="${XDG_CONFIG_HOME:-$HOME/.config}/bos/.welcomed"
|
|
||||||
mkdir -p "$(dirname "$welcomed_marker")"
|
|
||||||
|
|
||||||
# Network check. A fresh install usually boots with no connection (Wi-Fi
|
|
||||||
# isn't configured during install), and the first `bos-update`/pacman run
|
|
||||||
# then fails with confusing DNS/"could not resolve host" errors. If
|
|
||||||
# NetworkManager reports we're not fully online, open nmtui so the user can
|
|
||||||
# join a network before anything else. This runs on EVERY login, not just
|
|
||||||
# the first, and isn't gated by any marker — it keeps re-prompting until the
|
|
||||||
# machine is actually online, then naturally stops (the "full" check below
|
|
||||||
# short-circuits). Best-effort: missing nmcli/nmtui/kitty, or the user
|
|
||||||
# quitting nmtui, must never block the welcome text below.
|
|
||||||
if command -v nmcli &>/dev/null; then
|
|
||||||
conn="$(nmcli networking connectivity check 2>/dev/null)"
|
|
||||||
# NetworkManager may still be associating right at compositor start —
|
|
||||||
# give it a few short retries before concluding we're actually offline,
|
|
||||||
# so a machine with working Wi-Fi doesn't get a spurious nmtui popup.
|
|
||||||
tries=0
|
|
||||||
while [[ "$conn" != "full" && "$tries" -lt 3 ]]; do
|
|
||||||
sleep 1
|
|
||||||
conn="$(nmcli networking connectivity check 2>/dev/null)"
|
|
||||||
tries=$((tries + 1))
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ "$conn" != "full" ]]; then
|
|
||||||
notify-send -u normal "BOS" "No internet yet — opening network setup so updates work." 2>/dev/null || true
|
|
||||||
if command -v nmtui &>/dev/null; then
|
|
||||||
kitty --class bos-netsetup --title "Connect to a network" -- nmtui connect 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Welcome text: shown once ever, independent of network status above (an
|
|
||||||
# offline machine still gets useful onboarding text — it just also keeps
|
|
||||||
# getting the network prompt on future logins until it connects).
|
|
||||||
[[ -f "$welcomed_marker" ]] && exit 0
|
|
||||||
touch "$welcomed_marker"
|
|
||||||
|
|
||||||
exec kitty --class bos-welcome --title "Welcome to BOS" -- less -R /usr/share/bos/welcome.txt
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
|
|
||||||
██████ ██████ ███████ keyboard shortcuts
|
|
||||||
██ ██ ██ ██ ██ SUPER is the Windows/Cmd key
|
|
||||||
██████ ██ ██ ███████
|
|
||||||
══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
APPS & WINDOWS
|
|
||||||
SUPER + Return terminal (kitty)
|
|
||||||
SUPER + Space app launcher (breadbox)
|
|
||||||
SUPER + E files (nautilus)
|
|
||||||
SUPER + B browser (zen)
|
|
||||||
SUPER + U notes / reminders (breadpad)
|
|
||||||
SUPER + M notes / task manager (breadman)
|
|
||||||
SUPER + , BOS Settings
|
|
||||||
SUPER + / this keybind cheatsheet
|
|
||||||
SUPER + L lock screen
|
|
||||||
SUPER + Backspace close window
|
|
||||||
SUPER + F fullscreen
|
|
||||||
SUPER + I toggle floating
|
|
||||||
SUPER + P toggle pseudotile
|
|
||||||
SUPER + R resize mode
|
|
||||||
SUPER + V / Shift + V clipboard history (breadclip)
|
|
||||||
SUPER + T toggle split direction
|
|
||||||
SUPER + Tab last window
|
|
||||||
SUPER + N exit Hyprland (log out)
|
|
||||||
|
|
||||||
SCREENSHOTS
|
|
||||||
SUPER + Shift + S select region -> file
|
|
||||||
SUPER + Shift + C select region -> clipboard
|
|
||||||
SUPER + Shift + P whole screen -> file
|
|
||||||
|
|
||||||
FOCUS & MOVE
|
|
||||||
SUPER + arrows move focus
|
|
||||||
SUPER + Shift + h/j/k/l move window
|
|
||||||
SUPER + Shift + arrows resize window
|
|
||||||
|
|
||||||
WORKSPACES
|
|
||||||
SUPER + 1..0 switch to workspace 1..10
|
|
||||||
SUPER + Shift + 1..0 move window to workspace
|
|
||||||
SUPER + [ / ] previous / next workspace
|
|
||||||
SUPER + Shift + [ / ] move window prev / next workspace
|
|
||||||
SUPER + scroll cycle workspaces
|
|
||||||
|
|
||||||
MOUSE
|
|
||||||
SUPER + left-drag move window
|
|
||||||
SUPER + right-drag resize window
|
|
||||||
|
|
||||||
MEDIA & HARDWARE KEYS
|
|
||||||
volume / brightness / play-pause / next / prev (work on lock screen)
|
|
||||||
calculator key opens gnome-calculator
|
|
||||||
|
|
||||||
──────────────────────────────────────────────────────────
|
|
||||||
Press q to close. Configure everything in BOS Settings (SUPER + ,).
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
|
|
||||||
Welcome to BOS — the Bread Operating System
|
|
||||||
══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
You're running a complete Hyprland desktop with the bread
|
|
||||||
ecosystem preinstalled. A few things to get you started:
|
|
||||||
|
|
||||||
• SUPER + / show the keybind cheatsheet (any time)
|
|
||||||
• SUPER + , open BOS Settings — configure bread, the
|
|
||||||
bar, launcher, Wi-Fi profiles, notes,
|
|
||||||
snapshots and package updates, all in one
|
|
||||||
place (no config files needed)
|
|
||||||
• SUPER + Space the app launcher (breadbox)
|
|
||||||
• SUPER + Return a terminal
|
|
||||||
|
|
||||||
The bar at the top (breadbar) shows workspaces, the clock,
|
|
||||||
system stats, and your tray. Notifications appear top-right.
|
|
||||||
|
|
||||||
Your system is snapshotted on every package change — if an
|
|
||||||
update breaks something, roll back from BOS Settings or pick
|
|
||||||
a snapshot from the GRUB menu at boot.
|
|
||||||
|
|
||||||
──────────────────────────────────────────────────────────
|
|
||||||
Press q to close. This message won't show again.
|
|
||||||
|
|
@ -187,9 +187,10 @@ yay-bin
|
||||||
# networkmanager, openssl, zlib, systemd-libs) — keep those even though no bread
|
# networkmanager, openssl, zlib, systemd-libs) — keep those even though no bread
|
||||||
# package depends on them.
|
# package depends on them.
|
||||||
#
|
#
|
||||||
# bos-settings is a BOS-specific pacman package (not part of the bakery index),
|
# bos-settings and breadhelp are BOS-specific pacman packages (not part of the
|
||||||
# so it stays here, served from the [breadway] repo.
|
# bakery index), so they stay here, served from the [breadway] repo.
|
||||||
bos-settings
|
bos-settings
|
||||||
|
breadhelp
|
||||||
|
|
||||||
# Input / screen utilities
|
# Input / screen utilities
|
||||||
brightnessctl
|
brightnessctl
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ file_permissions=(
|
||||||
["/usr/local/bin/bos-copy-kernel"]="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-resolve-airootfs"]="0:0:755"
|
||||||
["/usr/local/bin/bos-session"]="0:0:755"
|
["/usr/local/bin/bos-session"]="0:0:755"
|
||||||
["/usr/local/bin/bos-keybinds"]="0:0:755"
|
["/usr/local/bin/bos-netcheck"]="0:0:755"
|
||||||
["/usr/local/bin/bos-welcome"]="0:0:755"
|
|
||||||
["/usr/local/bin/bos-update"]="0:0:755"
|
["/usr/local/bin/bos-update"]="0:0:755"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
# Maintainer: Breadway <rileyhorsham@gmail.com>
|
|
||||||
|
|
||||||
pkgname=bos-settings
|
|
||||||
pkgver=0.1.0
|
|
||||||
pkgrel=1
|
|
||||||
pkgdesc="System settings app for Bread OS"
|
|
||||||
arch=('x86_64')
|
|
||||||
url="https://github.com/Breadway/bos"
|
|
||||||
license=('MIT')
|
|
||||||
# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's
|
|
||||||
# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read,
|
|
||||||
# causing undefined-symbol errors. Disable LTO.
|
|
||||||
options=(!lto !debug)
|
|
||||||
depends=('gtk4' 'glib2' 'hicolor-icon-theme')
|
|
||||||
optdepends=(
|
|
||||||
'snapper: snapshot management view'
|
|
||||||
)
|
|
||||||
makedepends=('rust' 'cargo')
|
|
||||||
source=("${pkgname}-${pkgver}.tar.gz")
|
|
||||||
sha256sums=('SKIP')
|
|
||||||
|
|
||||||
build() {
|
|
||||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
|
||||||
cargo build --release --locked -p bos-settings
|
|
||||||
}
|
|
||||||
|
|
||||||
check() {
|
|
||||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
|
||||||
cargo test --release --locked -p bos-settings
|
|
||||||
}
|
|
||||||
|
|
||||||
package() {
|
|
||||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
|
||||||
install -Dm755 target/release/bos-settings "${pkgdir}/usr/bin/bos-settings"
|
|
||||||
install -Dm644 packaging/arch/bos-settings.desktop \
|
|
||||||
"${pkgdir}/usr/share/applications/bos-settings.desktop"
|
|
||||||
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +1,32 @@
|
||||||
Arch packaging
|
Arch packaging
|
||||||
==============
|
==============
|
||||||
|
|
||||||
`PKGBUILD` builds and installs `bos-settings` from source.
|
`breadhelp/PKGBUILD` builds and installs `breadhelp` from source — BOS's
|
||||||
|
onboarding + help center, and the only first-party pacman package still built
|
||||||
|
from this repo.
|
||||||
|
|
||||||
|
`bos-settings` is also pacman-packaged and served from the same [breadway]
|
||||||
|
repo, but its source lives in its own repo now (`~/Projects/bos-settings`,
|
||||||
|
`git.breadway.dev/Breadway/bos-settings`) so a bos-settings release doesn't require
|
||||||
|
a BOS ISO release.
|
||||||
|
|
||||||
|
Everything else the bread ecosystem ships (breadbar, breadbox, breadpad, ...)
|
||||||
|
is bakery-managed, not pacman-packaged — see `build-local.sh`.
|
||||||
|
|
||||||
## Local build
|
## Local build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
makepkg -si
|
cd breadhelp && makepkg -si
|
||||||
```
|
```
|
||||||
|
|
||||||
## Before publishing to [breadway] repo
|
## Before publishing to [breadway] repo
|
||||||
|
|
||||||
1. Tag a release on GitHub.
|
CI (`.forgejo/workflows/package.yml`) handles this on tag push (`vX.Y.Z`): it
|
||||||
2. Update `pkgver` to match the tag.
|
bumps `pkgver`, archives the repo into the expected tarball name, and runs
|
||||||
|
`makepkg`. To do it by hand instead:
|
||||||
|
|
||||||
|
1. Tag a release on GitHub (`vX.Y.Z`).
|
||||||
|
2. Update `pkgver` in `breadhelp/PKGBUILD` to match the tag.
|
||||||
3. Update `source` to the release tarball URL.
|
3. Update `source` to the release tarball URL.
|
||||||
4. Run `updpkgsums` (or manually set `sha256sums`).
|
4. Run `updpkgsums` (or manually set `sha256sums`).
|
||||||
|
|
||||||
|
|
@ -22,4 +36,4 @@ makepkg -si
|
||||||
|---------|----------|-------|
|
|---------|----------|-------|
|
||||||
| `gtk4` | yes | UI toolkit |
|
| `gtk4` | yes | UI toolkit |
|
||||||
| `glib2` | yes | always |
|
| `glib2` | yes | always |
|
||||||
| `snapper` | optional | snapshot management view |
|
| `snapper` | optional | create-backup one-click fix |
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
[Desktop Entry]
|
|
||||||
Name=BOS Settings
|
|
||||||
Comment=System settings for Bread OS
|
|
||||||
Exec=bos-settings
|
|
||||||
Icon=preferences-system
|
|
||||||
Terminal=false
|
|
||||||
Type=Application
|
|
||||||
Categories=Settings;System;
|
|
||||||
StartupWMClass=bos-settings
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Maintainer: Breadway <rileyhorsham@gmail.com>
|
# Maintainer: Breadway <plasticbread849@gmail.com>
|
||||||
# In-house copy of the AUR calamares PKGBUILD (Calamares is AUR-only; not in
|
# In-house copy of the AUR calamares PKGBUILD (Calamares is AUR-only; not in
|
||||||
# Arch's official repos). Built by CI and published to the [breadway] repo.
|
# Arch's official repos). Built by CI and published to the [breadway] repo.
|
||||||
# Source of truth: https://aur.archlinux.org/packages/calamares
|
# Source of truth: https://aur.archlinux.org/packages/calamares
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,18 @@ done
|
||||||
echo "== bos-settings =="
|
echo "== bos-settings =="
|
||||||
check "bos-settings installed" "command -v bos-settings"
|
check "bos-settings installed" "command -v bos-settings"
|
||||||
|
|
||||||
|
echo "== breadhelp =="
|
||||||
|
check "breadhelp installed" "command -v breadhelp"
|
||||||
|
check "breadhelp content installed" "[ -d /usr/share/breadhelp/content ]"
|
||||||
|
check "bos-netcheck present" "command -v bos-netcheck"
|
||||||
|
|
||||||
echo "== default dotfiles =="
|
echo "== default dotfiles =="
|
||||||
check "hyprland.lua present" "[ -f \"\$HOME/.config/hypr/hyprland.lua\" ]"
|
check "hyprland.lua present" "[ -f \"\$HOME/.config/hypr/hyprland.lua\" ]"
|
||||||
|
check "binds.json present" "[ -f \"\$HOME/.config/hypr/binds.json\" ]"
|
||||||
|
check "monitors.json present" "[ -f \"\$HOME/.config/hypr/monitors.json\" ]"
|
||||||
|
check "settings.json present" "[ -f \"\$HOME/.config/hypr/settings.json\" ]"
|
||||||
|
check "autostart.json present" "[ -f \"\$HOME/.config/hypr/autostart.json\" ]"
|
||||||
|
check "hypr scripts/lib present" "[ -f \"\$HOME/.config/hypr/scripts/lib/json.lua\" ]"
|
||||||
check "mimeapps.list present" "[ -f \"\$HOME/.config/mimeapps.list\" ]"
|
check "mimeapps.list present" "[ -f \"\$HOME/.config/mimeapps.list\" ]"
|
||||||
check "kitty config present" "[ -f \"\$HOME/.config/kitty/kitty.conf\" ]"
|
check "kitty config present" "[ -f \"\$HOME/.config/kitty/kitty.conf\" ]"
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue