Compare commits

..

No commits in common. "main" and "v0.3.0" have entirely different histories.
main ... v0.3.0

19 changed files with 410 additions and 982 deletions

View File

@ -1,43 +0,0 @@
# ABOUTME: Updates pkgver in moonarch-pkgbuilds after a push to main.
# ABOUTME: Ensures paru detects new versions of this package.
name: Update PKGBUILD version
on:
push:
branches:
- main
jobs:
update-pkgver:
runs-on: moonarch
steps:
- name: Checkout source repo
run: |
git clone --bare http://gitea:3000/nevaforget/moonset.git source.git
cd source.git
PKGVER=$(git describe --long --tags | sed 's/^v//;s/-/.r/;s/-/./')
echo "New pkgver: $PKGVER"
echo "$PKGVER" > /tmp/pkgver
- name: Update PKGBUILD
run: |
PKGVER=$(cat /tmp/pkgver)
git clone http://gitea:3000/nevaforget/moonarch-pkgbuilds.git pkgbuilds
cd pkgbuilds
OLD_VER=$(grep '^pkgver=' moonset-git/PKGBUILD | cut -d= -f2)
if [ "$OLD_VER" = "$PKGVER" ]; then
echo "pkgver already up to date ($PKGVER)"
exit 0
fi
sed -i "s/^pkgver=.*/pkgver=$PKGVER/" moonset-git/PKGBUILD
sed -i "s/^\tpkgver = .*/\tpkgver = $PKGVER/" moonset-git/.SRCINFO
echo "Updated pkgver: $OLD_VER → $PKGVER"
git config user.name "pkgver-bot"
git config user.email "gitea@moonarch.de"
git add moonset-git/PKGBUILD moonset-git/.SRCINFO
git commit -m "chore(moonset-git): bump pkgver to $PKGVER"
git -c http.extraHeader="Authorization: token ${{ secrets.PKGBUILD_TOKEN }}" push

View File

@ -1,126 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
Format based on [Keep a Changelog](https://keepachangelog.com/).
## [0.8.0] - 2026-03-30
### Changed
- Replace `canonicalize()` with `symlink_metadata` + `is_file` + `!is_symlink` for avatar lookup — prevents symlink traversal to arbitrary files
- Replace `canonicalize()` with same symlink-safe check in `resolve_background_path`
- Downscale wallpaper to `MAX_BLUR_DIMENSION` (1920px) before GPU blur — prevents excessive memory use on high-res images
- Validate `background_blur` per config source — invalid user value preserves system default instead of silently falling back to 0
### Fixed
- Fix blur padding offset from `(0,0)` to `(-pad,-pad)` to prevent edge darkening on blurred wallpaper
## [0.7.3] - 2026-03-29
### Fixed
- Fix shutdown and reboot — `loginctl` does not support `poweroff`/`reboot` verbs, switched to `systemctl poweroff` and `systemctl reboot`
## [0.7.2] - 2026-03-29
### Fixed
- Fix CSS priority so app styles override GTK4 user theme (Colloid-Catppuccin) — use `STYLE_PROVIDER_PRIORITY_USER` instead of `STYLE_PROVIDER_PRIORITY_APPLICATION`
- Replace `border-radius: 50%` with `9999px` — GTK4 CSS does not reliably support percentage-based border-radius
## [0.7.1] - 2026-03-28
### Fixed
- Fix edge darkening on blurred wallpaper — GskBlurNode sampled transparent pixels outside texture bounds, now renders with 3x-sigma padding and crops back
## [0.7.0] - 2026-03-28
### Added
- Blur validation: `background_blur` must be 0.0200.0 (negative, NaN, infinite, and extreme values are rejected with a warning)
- `detect_locale_with()` testable DI function for locale detection (4 new tests)
- Path canonicalization for `~/.face` and AccountsService avatar paths (resolves symlinks, prevents passing arbitrary files to gdk-pixbuf)
### Changed
- Replace busy-loop polling (`try_wait` + `sleep(100ms)`) in `run_command` with blocking `child.wait()` + timeout thread — eliminates poll latency and thread waste
- Move config loading from `activate()` to `main()` — filesystem I/O no longer blocks the GTK main loop
- Click-to-dismiss now attached to overlay instead of background picture (works with or without wallpaper)
### Removed
- Embedded fallback wallpaper from GResource bundle — moonarch provides `/usr/share/moonarch/wallpaper.jpg` at install time, binary size dropped from ~3.2MB to ~1.3MB
- GResource fallback path in `resolve_background_path` — returns `Option<PathBuf>` now, `None` falls through to CSS background
## [0.6.0] - 2026-03-28
### Added
- Systemd journal logging (`journalctl -t moonset`) replacing env_logger stderr output
- `MOONSET_DEBUG` env var to enable debug-level journal output
- Debug logging across all modules (config resolution, wallpaper source, avatar loading, power actions, locale detection, blur cache)
- Shared blur cache for multi-monitor — GPU blur computed once, reused by all windows
### Changed
- Lock action spawns moonlock as detached process instead of blocking via run_command — moonset can quit immediately while moonlock runs independently
## [0.4.1] - 2026-03-28
### Added
- Fade-in/fade-out animation (250ms ease-in) for panel and wallpaper windows via CSS opacity transition
### Fixed
- Fix pixel format mismatch in blur path — `texture.download()` yields BGRA but was passed to `RgbaImage` without channel swap, now explicitly converts B↔R
- Logout action now calls `app.quit()` to dismiss the menu immediately (previously only Lock did)
- Log TOML parse errors to stderr instead of silently falling back to defaults
### Changed
- Replace magic string `"lock"` comparison with `quit_after` field on `ActionDef` for type-safe action dispatch
- Remove `compressed="true"` from JPEG wallpaper in GResource — JPEG is already compressed, zlib overhead hurts startup for negligible size savings
## [0.4.0] - 2026-03-28
### Added
- Optional background blur via `background_blur` config option (Gaussian blur, `image` crate)
- Disk cache for blurred wallpaper (`~/.cache/moonset/`) — avoids re-blurring on subsequent starts
## [0.1.1] - 2026-03-28
### Fixed
- Use absolute paths for all system binaries (`systemctl`, `loginctl`, `niri`, `moonlock`) to prevent PATH hijacking
- Implement `POWER_TIMEOUT` (30s) via `try_wait()` polling — previously declared but unused, leaving power actions able to block indefinitely
- Prevent panic in `load_background_texture` when GResource path contains non-UTF-8 bytes — now falls back to known wallpaper resource
- Fix fallback user UID from `0` (root) to `u32::MAX` as a safe sentinel value
- Fix CSS comment incorrectly describing circular buttons as "square card"
### Changed
- Compress wallpaper in GResource bundle (`compressed="true"`) to reduce binary size
- Merge double `idle_add_local_once` into single idle cycle for faster keyboard focus on launch
- Centralize `GRESOURCE_PREFIX` as `pub(crate) const` in `main.rs` (was duplicated in `config.rs`, `users.rs`, and literal strings in `panel.rs`)
- Translate README.md and config comments from German to English
- Remove stale `journal.md` (one-time development notes, not actively maintained)
## [0.1.0] - 2026-03-27
### Added
- Rust rewrite of the Python power menu (gtk4-rs + gtk4-layer-shell)
- 5 power actions: Lock, Logout, Hibernate, Reboot, Shutdown
- Inline confirmation for destructive actions (all except Lock)
- Multi-monitor wallpaper support via shared `gdk::Texture`
- DE/EN localization with automatic locale detection
- TOML configuration for custom wallpaper path
- GResource bundle for CSS, wallpaper, and default avatar
- Async power actions via `glib::spawn_future_local` + `gio::spawn_blocking`
- Async avatar loading (file-based avatars decoded off UI thread)
- Cached icon loading at startup
- 45 unit tests

View File

@ -1,5 +1,7 @@
# Moonset # Moonset
**Name**: Hekate (Göttin der Wegkreuzungen — passend zum Power-Menu, das den Weg der Session bestimmt)
## Projekt ## Projekt
Moonset ist ein Wayland Session Power Menu, gebaut mit Rust + gtk4-rs + gtk4-layer-shell. Moonset ist ein Wayland Session Power Menu, gebaut mit Rust + gtk4-rs + gtk4-layer-shell.
@ -15,7 +17,7 @@ Lock, Logout, Hibernate, Reboot, Shutdown.
## Projektstruktur ## Projektstruktur
- `src/` — Rust-Quellcode (main.rs, power.rs, i18n.rs, config.rs, users.rs, panel.rs) - `src/` — Rust-Quellcode (main.rs, power.rs, i18n.rs, config.rs, users.rs, panel.rs)
- `resources/` — GResource-Assets (style.css, default-avatar.svg) - `resources/` — GResource-Assets (style.css, wallpaper.jpg, default-avatar.svg)
- `config/` — Beispiel-Konfigurationsdateien - `config/` — Beispiel-Konfigurationsdateien
## Kommandos ## Kommandos
@ -33,25 +35,20 @@ LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonset
## Architektur ## Architektur
- `main.rs` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor, systemd-Journal-Logging, Debug-Level per `MOONSET_DEBUG` Env-Var, zentrale `GRESOURCE_PREFIX`-Konstante - `power.rs` — 5 Power-Action-Wrapper (lock, logout, hibernate, reboot, shutdown)
- `power.rs` — 5 Power-Action-Wrapper mit absoluten Pfaden und 30s Timeout (lock, logout, hibernate, reboot, shutdown)
- `i18n.rs` — Locale-Erkennung und String-Tabellen (DE/EN) - `i18n.rs` — Locale-Erkennung und String-Tabellen (DE/EN)
- `config.rs` — TOML-Config + Wallpaper-Fallback - `config.rs` — TOML-Config + Wallpaper-Fallback
- `panel.rs` — GTK4 UI (Action-Buttons, Inline-Confirmation, WallpaperWindow) - `panel.rs` — GTK4 UI (Action-Buttons, Inline-Confirmation, WallpaperWindow)
- `users.rs` — User-Erkennung, Avatar-Loading (AccountsService, ~/.face, GResource-Fallback) - `main.rs` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor
- `resources/style.css`GTK-Theme-Colors für Konsistenz mit dem aktiven Desktop-Theme - `resources/style.css`Catppuccin Mocha Theme (aus Python-Version übernommen)
## Design Decisions ## Design Decisions
Siehe `DECISIONS.md` für das vollständige Entscheidungsprotokoll.
Kurzfassung der wichtigsten Entscheidungen:
- **OVERLAY statt TOP Layer**: Waybar liegt auf TOP, moonset muss darüber - **OVERLAY statt TOP Layer**: Waybar liegt auf TOP, moonset muss darüber
- **Niri-spezifischer Logout** (`niri msg action quit`): Moonarch setzt fest auf Niri - **Niri-spezifischer Logout** (`niri msg action quit`): Moonarch setzt fest auf Niri
- **Einmal-Start per Keybind**: Kein Daemon, GTK `application_id` verhindert Doppelstart - **Einmal-Start per Keybind**: Kein Daemon, GTK `application_id` verhindert Doppelstart
- **System-Icons**: Adwaita/Catppuccin liefern alle benötigten symbolischen Icons - **System-Icons**: Adwaita/Catppuccin liefern alle benötigten symbolischen Icons
- **Lock ohne Confirmation**: Lock ist sofort reversibel, braucht kein Confirm - **Lock ohne Confirmation**: Lock ist sofort reversibel, braucht kein Confirm
- **Absolute Pfade für Binaries**: `/usr/bin/systemctl` etc. statt relativer Pfade (Security) - **Icon-Scaling**: 22px Theme-Variante laden, auf 64px skalieren via GdkPixbuf
- **GResource-Bundle**: CSS und Default-Avatar sind in die Binary kompiliert (Wallpaper kommt vom Dateisystem) - **GResource-Bundle**: CSS, Wallpaper und Default-Avatar sind in die Binary kompiliert
- **Async Power Actions**: `glib::spawn_future_local` + `gio::spawn_blocking` mit 30s Timeout - **Async Power Actions**: `glib::spawn_future_local` + `gio::spawn_blocking` statt raw Threads
- **Journal-Logging**: `systemd-journal-logger` statt File-Logging — `journalctl -t moonset`, Debug-Level per `MOONSET_DEBUG` Env-Var

189
Cargo.lock generated
View File

@ -2,6 +2,65 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.102" version = "1.0.102"
@ -65,6 +124,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]] [[package]]
name = "dirs" name = "dirs"
version = "6.0.0" version = "6.0.0"
@ -86,6 +151,29 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "env_filter"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@ -554,12 +642,42 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
dependencies = [
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-static"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "khronos_api" name = "khronos_api"
version = "3.1.0" version = "3.1.0"
@ -616,20 +734,19 @@ dependencies = [
[[package]] [[package]]
name = "moonset" name = "moonset"
version = "0.8.5" version = "0.1.0"
dependencies = [ dependencies = [
"dirs", "dirs",
"env_logger",
"gdk-pixbuf", "gdk-pixbuf",
"gdk4", "gdk4",
"glib", "glib",
"glib-build-tools", "glib-build-tools",
"graphene-rs",
"gtk4", "gtk4",
"gtk4-layer-shell", "gtk4-layer-shell",
"log", "log",
"nix", "nix",
"serde", "serde",
"systemd-journal-logger",
"tempfile", "tempfile",
"toml 0.8.23", "toml 0.8.23",
] ]
@ -652,6 +769,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@ -694,6 +817,21 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
dependencies = [
"portable-atomic",
]
[[package]] [[package]]
name = "prettyplease" name = "prettyplease"
version = "0.2.37" version = "0.2.37"
@ -748,6 +886,35 @@ dependencies = [
"thiserror", "thiserror",
] ]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@ -873,16 +1040,6 @@ dependencies = [
"version-compare", "version-compare",
] ]
[[package]]
name = "systemd-journal-logger"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7266304d24ca5a4b230545fc558c80e18bd3e1d2eb1be149b6bcd04398d3e79c"
dependencies = [
"log",
"rustix",
]
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.13.3" version = "0.13.3"
@ -1035,6 +1192,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"

View File

@ -1,6 +1,6 @@
[package] [package]
name = "moonset" name = "moonset"
version = "0.8.5" version = "0.1.0"
edition = "2024" edition = "2024"
description = "Wayland session power menu with GTK4 and Layer Shell" description = "Wayland session power menu with GTK4 and Layer Shell"
license = "MIT" license = "MIT"
@ -14,18 +14,12 @@ gdk-pixbuf = "0.22"
toml = "0.8" toml = "0.8"
dirs = "6" dirs = "6"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
nix = { version = "0.29", features = ["user", "signal"] } nix = { version = "0.29", features = ["user"] }
graphene-rs = { version = "0.22", package = "graphene-rs" }
log = "0.4" log = "0.4"
systemd-journal-logger = "2.2" env_logger = "0.11"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
[profile.release]
lto = "thin"
codegen-units = 1
strip = true
[build-dependencies] [build-dependencies]
glib-build-tools = "0.22" glib-build-tools = "0.22"

View File

@ -1,108 +0,0 @@
# Decisions
Architectural and design decisions for Moonset, in reverse chronological order.
## 2026-04-24 Audit LOW fixes: dead uid field, home_dir warn, clippy sweep, debug value (v0.8.5)
- **Who**: ClaudeCode, Dom
- **Why**: Five LOW findings cleared in one pass. (1) `User::uid` was populated from `getuid()` but never read — a compiler `dead_code` warning for a field on the public API. (2) Falling back to a synthetic user when `get_current_user()` returned None used `uid: u32::MAX`, an undocumented sentinel that became moot once uid was removed. (3) `dirs::home_dir().unwrap_or_default()` silently yielded `PathBuf::new()` on failure; avatars would then look for `.face` in the current working directory. (4) `cargo clippy` flagged three suggestions (two collapsible `if`, one redundant closure) that had crept in. (5) `MOONSET_DEBUG` promoted log verbosity on mere presence, leaking path information into the journal.
- **Tradeoffs**: Dropping `uid` from `User` is a minor API break for any internal caller expecting the field — none existed. The synthetic fallback now surfaces `log::warn!` when home resolution fails, which should be rare outside of pathological sandbox environments.
- **How**: (1) Remove `pub uid: u32` from `User` and the `uid: uid.as_raw()` assignment in `get_current_user`. (2) Panel fallback drops the `uid` field entirely. (3) `dirs::home_dir().unwrap_or_else(|| { log::warn!(...); PathBuf::new() })`. (4) `cargo clippy --fix` for the two collapsible ifs, manual collapse of `if-let` + `&&` chain, redundant closure replaced with the function itself. (5) `MOONSET_DEBUG` now requires the literal value `"1"` to escalate to Debug.
## 2026-04-24 Audit MEDIUM fixes: timeout guard, POSIX locale, button desensitize, wallpaper allowlist (v0.8.4)
- **Who**: ClaudeCode, Dom
- **Why**: Five MEDIUM findings: (1) `run_command`'s timeout thread leaked a 30 s gio::spawn_blocking slot if `child.wait()` errored, because `done.store(true)` ran after the `?`. (2) Timeout detection compared `status.signal() == Some(9)` — a hardcoded signal number that also misclassifies OOM-killer SIGKILL as our timeout. (3) `execute_action` never desensitized the button_box, so a double-click or accidental keyboard repeat fired the action twice. (4) `detect_locale` read only `LANG`, ignoring POSIX priority order (`LC_ALL` > `LC_MESSAGES` > `LANG`) — a common dual-language setup picked the wrong UI language. (5) The wallpaper path was passed to gdk-pixbuf without extension or size restriction, widening the image-parser attack surface and allowing unbounded decode latency.
- **Tradeoffs**: The extension allowlist (`jpg`, `jpeg`, `png`, `webp`) rejects exotic formats users might have used before. The 10 MB size cap rejects uncompressed/high-quality 4K wallpapers; acceptable for a power menu. Memory ordering on the `done` flag is now `Release`/`Acquire` instead of `Relaxed` — no runtime cost but correct across threads.
- **How**: (1) RAII `DoneGuard` struct sets `done.store(true, Release)` in its `Drop`, so the flag fires on every function exit path. A second `timed_out` AtomicBool distinguishes our SIGKILL from an external one. (2) Replace `Some(9)` with the `timed_out` flag check. (3) `execute_action` now takes `button_box: &gtk::Box`, calls `set_sensitive(false)` on entry and re-enables it on error paths; success paths that quit skip the re-enable. All call sites updated. (4) `detect_locale` reads `LC_ALL`, `LC_MESSAGES`, `LANG` in order, picking the first non-empty value before falling back to `/etc/locale.conf`. (5) `accept_wallpaper` helper applies extension allowlist + symlink rejection + `MAX_WALLPAPER_FILE_SIZE = 10 MB`, and is called for both config-path and Moonarch fallback.
## 2026-04-24 Audit fix: avoid latent stdout pipe deadlock in run_command (v0.8.3)
- **Who**: ClaudeCode, Dom
- **Why**: Audit found that `run_command` piped the child's `stdout` but never drained it, then called blocking `child.wait()`. A child writing more than one OS pipe buffer (~64 KB on Linux) would block on `write()` while the parent blocked in `wait()` — classic pipe deadlock, broken only by the 30 s SIGKILL timeout. Current callers (`systemctl`, `niri msg`, `loginctl`) do not emit that much output, but the structure was fragile and would bite on any future command or changed behaviour.
- **Tradeoffs**: stdout is now fully discarded. If a future caller needs stdout, it will have to drain it concurrently with `wait()` (separate reader thread).
- **How**: Replace `.stdout(Stdio::piped())` with `.stdout(Stdio::null())` in `run_command`. `stderr` stays piped — it is drained after `wait()`, which is safe because `wait()` already reaped the child and no further writes can occur.
## 2026-03-31 Fourth audit: release profile, GResource compression, lock stderr, sync markers
- **Who**: ClaudeCode, Dom
- **Why**: Fourth triple audit found missing release profile (LTO/strip), uncompressed GResource assets, moonlock stderr suppressed (errors invisible), and duplicated code without sync markers.
- **Tradeoffs**: moonlock stderr now inherited instead of null — errors appear in moonset's journal context. Acceptable for debugging, no security leak since moonlock logs to its own journal identifier.
- **How**: (1) `[profile.release]` with LTO, codegen-units=1, strip. (2) `compressed="true"` on GResource entries. (3) `Stdio::inherit()` for moonlock stderr in lock(). (4) SYNC comments on duplicated blur/background functions.
## 2026-03-28 Remove wallpaper from GResource bundle
- **Who**: ClaudeCode, Dom
- **Why**: All three Moon projects (moonset, moongreet, moonlock) embedded a 374kB fallback wallpaper in the binary via GResource. Moonarch already installs `/usr/share/moonarch/wallpaper.jpg` at system setup time, making the embedded fallback unnecessary dead weight (~2MB in binary size).
- **Tradeoffs**: If `/usr/share/moonarch/wallpaper.jpg` is missing and no user config exists, moonset shows a solid CSS background instead of a wallpaper. Acceptable — the power menu is functional without a wallpaper image.
- **How**: Removed `wallpaper.jpg` from GResource XML and resources directory. `resolve_background_path` returns `Option<PathBuf>`. All wallpaper-related functions handle `None` gracefully. Binary size dropped from ~3.2MB to ~1.3MB.
## 2026-03-28 Switch from env_logger to systemd-journal-logger
- **Who**: ClaudeCode, Dom
- **Why**: moonlock and moongreet already use systemd-journal-logger. moonset used env_logger which writes to stderr — not useful for a GUI app launched via keybind. Journal integration enables `journalctl -t moonset` and consistent troubleshooting across all three Moon projects.
- **Tradeoffs**: Requires systemd at runtime. Graceful fallback to eprintln if journal logger fails. Acceptable since Moonarch targets systemd-based Arch Linux.
- **How**: Replace `env_logger` dep with `systemd-journal-logger`, add `setup_logging()` with `MOONSET_DEBUG` env var for debug-level output. Same pattern as moonlock/moongreet.
## 2026-03-28 Replace action name dispatch with `quit_after` field
- **Who**: ClaudeCode, Dom
- **Why**: Post-action behavior (quit the app or not) was controlled by comparing `action_name == "lock"` — a magic string duplicated from the action definition. Renaming an action would silently break the dispatch.
- **Tradeoffs**: Adds a field to `ActionDef` that most actions set to `false`. Acceptable because it makes the contract explicit and testable.
- **How**: `ActionDef.quit_after: bool``true` for lock and logout, `false` for hibernate/reboot/shutdown.
## 2026-03-28 GPU blur via GskBlurNode replaces CPU blur
- **Who**: ClaudeCode, Dom
- **Why**: CPU-side Gaussian blur (`image` crate) blocked startup and added caching complexity. moonlock already migrated to GPU blur.
- **Tradeoffs**: GPU blur quality is slightly different (box-blur approximation vs true Gaussian), acceptable for wallpaper backgrounds. Removes `image` crate dependency entirely. No disk cache needed.
- **How**: `Snapshot::push_blur()` + `GskRenderer::render_texture()` on `connect_realize`. Blur happens once on the GPU when the widget gets its renderer. Symmetric with moonlock and moongreet.
## 2026-03-28 Use absolute paths for system binaries
- **Who**: ClaudeCode, Dom
- **Why**: Security audit flagged PATH hijacking risk — relative binary names allow a malicious `$PATH` entry to intercept `systemctl`, `loginctl`, etc.
- **Tradeoffs**: Hardcoded paths reduce portability to non-Arch distros where binaries may live elsewhere (e.g. `/sbin/`). Acceptable because Moonarch targets Arch Linux exclusively.
- **How**: All five power action wrappers now use `/usr/bin/` prefixed paths.
## 2026-03-28 Implement power action timeout via try_wait polling
- **Who**: ClaudeCode, Dom
- **Why**: `POWER_TIMEOUT` and `PowerError::Timeout` were declared but never wired up. A hanging `systemctl hibernate` (e.g. blocked NFS mount) would freeze the power menu indefinitely.
- **Tradeoffs**: Polling with `try_wait()` + 100ms sleep is slightly less efficient than a dedicated timeout crate, but avoids adding a dependency for a single use case.
- **How**: `run_command` now polls `child.try_wait()` against a 30s deadline, kills the child on timeout.
## 2026-03-28 Centralize GRESOURCE_PREFIX
- **Who**: ClaudeCode, Dom
- **Why**: The string `/dev/moonarch/moonset` was duplicated in `config.rs`, `users.rs`, and as literal strings in `panel.rs` and `main.rs`. Changing the application ID would require edits in 4+ locations.
- **Tradeoffs**: Modules now depend on `crate::GRESOURCE_PREFIX` — tighter coupling to main.rs, but acceptable for an internal constant.
- **How**: Single `pub(crate) const GRESOURCE_PREFIX` in `main.rs`, referenced everywhere else.
## 2026-03-28 Remove journal.md
- **Who**: ClaudeCode, Dom
- **Why**: One-time development notes from the Rust rewrite, never updated after initial session. Overlapped with memory system and git history.
- **Tradeoffs**: Historical context lost from the file, but the information is preserved in git history and the memory system.
- **How**: Deleted. Useful technical learnings migrated to persistent memory.
## 2026-03-27 OVERLAY layer instead of TOP
- **Who**: ClaudeCode, Dom
- **Why**: Waybar occupies the TOP layer. The power menu must appear above it.
- **Tradeoffs**: OVERLAY is the highest layer — nothing can render above moonset while it's open. This is intentional for a session power menu.
- **How**: `setup_layer_shell` uses `gtk4_layer_shell::Layer::Overlay` for the panel window.
## 2026-03-27 Lock without confirmation
- **Who**: ClaudeCode, Dom
- **Why**: Lock is immediately reversible (just unlock). All other actions (logout, hibernate, reboot, shutdown) are destructive or disruptive.
- **Tradeoffs**: One less click for the most common action. Risk of accidental lock is negligible since unlocking is trivial.
- **How**: `ActionDef.needs_confirm = false` for lock; all others require inline confirmation.
## 2026-03-27 Niri-specific logout via `niri msg action quit`
- **Who**: ClaudeCode, Dom
- **Why**: Moonarch is built exclusively for the Niri compositor. Generic Wayland logout mechanisms don't exist — each compositor has its own.
- **Tradeoffs**: Hard dependency on Niri. If the compositor changes, `power::logout()` must be updated.
- **How**: `Command::new("/usr/bin/niri").args(["msg", "action", "quit"])`.

View File

@ -1,19 +1,19 @@
# Moonset # Moonset
Wayland Session Power Menu for the Moonarch ecosystem. Wayland Session Power Menu für das Moonarch-Ökosystem.
A fullscreen overlay triggered by keybind with 5 actions: Per Keybind aufrufbares Fullscreen-Overlay mit 5 Aktionen:
**Lock** · **Logout** · **Hibernate** · **Reboot** · **Shutdown** **Lock** · **Logout** · **Hibernate** · **Reboot** · **Shutdown**
## Features ## Features
- Rust + gtk4-rs + gtk4-layer-shell (OVERLAY layer — above Waybar) - Rust + gtk4-rs + gtk4-layer-shell (OVERLAY Layer — über Waybar)
- Catppuccin Mocha theme - Catppuccin Mocha Theme
- Multi-monitor support (wallpaper on secondary monitors) - Multi-Monitor-Support (Wallpaper auf Sekundärmonitoren)
- Inline confirmation for destructive actions - Inline-Confirmation für destruktive Aktionen
- Escape or background click to dismiss - Escape oder Hintergrund-Klick zum Schließen
- DE/EN localization - DE/EN Lokalisierung
- Configurable wallpaper (TOML) - Konfigurierbare Wallpaper (TOML)
## Installation ## Installation
@ -22,48 +22,48 @@ cargo build --release
install -Dm755 target/release/moonset /usr/bin/moonset install -Dm755 target/release/moonset /usr/bin/moonset
``` ```
Or via PKGBUILD: Oder via PKGBUILD:
```bash ```bash
cd pkg && makepkg -si cd pkg && makepkg -si
``` ```
## Usage ## Verwendung
```bash ```bash
# Launch directly # Direkt starten
moonset moonset
# Via Niri keybind (in ~/.config/niri/config.kdl) # Per Niri-Keybind (in ~/.config/niri/config.kdl)
# binds { # binds {
# Mod+Escape { spawn "moonset"; } # Mod+Escape { spawn "moonset"; }
# } # }
``` ```
## Configuration ## Konfiguration
Config file: `~/.config/moonset/moonset.toml` or `/etc/moonset/moonset.toml` Konfigurationsdatei: `~/.config/moonset/moonset.toml` oder `/etc/moonset/moonset.toml`
```toml ```toml
# Path to background image (optional) # Pfad zum Hintergrundbild (optional)
background_path = "/usr/share/moonarch/wallpaper.jpg" background_path = "/usr/share/moonarch/wallpaper.jpg"
``` ```
Wallpaper fallback: config → `/usr/share/moonarch/wallpaper.jpg` → bundled package wallpaper Wallpaper-Fallback: Konfiguration → `/usr/share/moonarch/wallpaper.jpg` → eingebettetes Package-Wallpaper
## Development ## Entwicklung
```bash ```bash
# Tests # Tests
cargo test cargo test
# Release build # Release-Build
cargo build --release cargo build --release
``` ```
## Part of the Moonarch ecosystem ## Teil des Moonarch-Ökosystems
- **moonarch** — Reproducible Arch Linux setup - **moonarch** — Reproduzierbares Arch-Linux-Setup
- **moongreet** — greetd greeter for Wayland - **moongreet** — greetd Greeter für Wayland
- **moonlock** — Wayland lockscreen - **moonlock** — Wayland Lockscreen
- **moonset** — Session power menu - **moonset** — Session Power Menu

View File

@ -1,5 +1,5 @@
// ABOUTME: Build script for compiling GResource bundle. // ABOUTME: Build script for compiling GResource bundle.
// ABOUTME: Bundles style.css and default-avatar.svg into the binary. // ABOUTME: Bundles style.css, wallpaper.jpg, and default-avatar.svg into the binary.
fn main() { fn main() {
glib_build_tools::compile_resources( glib_build_tools::compile_resources(

View File

@ -1,6 +1,6 @@
# Moonset — Wayland Session Power Menu # Moonset — Wayland Session Power Menu
# Config file: ~/.config/moonset/moonset.toml or /etc/moonset/moonset.toml # Konfigurationsdatei: ~/.config/moonset/moonset.toml oder /etc/moonset/moonset.toml
# Path to background image (optional) # Pfad zum Hintergrundbild (optional)
# Fallback order: config → /usr/share/moonarch/wallpaper.jpg → bundled package wallpaper # Fallback-Reihenfolge: config → /usr/share/moonarch/wallpaper.jpg → Package-Wallpaper
# background_path = "/usr/share/moonarch/wallpaper.jpg" # background_path = "/usr/share/moonarch/wallpaper.jpg"

38
journal.md Normal file
View File

@ -0,0 +1,38 @@
# Hekate — Journal
## 2026-03-27 — Rust Rewrite
Rewrite von Python auf Rust (gtk4-rs + gtk4-layer-shell). Motivation: ~800ms Startzeit der Python-Version durch Interpreter-Overhead.
Alle Module 1:1 portiert:
- `power.rs` — Command::new statt subprocess.run, PowerError enum statt Exceptions
- `i18n.rs` — Static Strings statt Dataclass, parse_lang_prefix() separat testbar (kein env::set_var nötig)
- `config.rs` — serde::Deserialize für TOML, GResource-Pfad als letzter Fallback
- `users.rs` — nix-crate für getuid/getpwuid, GResource-Pfad für default-avatar
- `panel.rs` — Freie Funktionen statt Klassen, Rc<RefCell> für Confirm-State, glib::spawn_future_local + gio::spawn_blocking für async Power-Actions
- `main.rs` — GResource-Registration, LayerShell trait statt Gtk4LayerShell-Modul
45 Unit-Tests grün. Release-Binary: 3.1 MB.
Gelernt:
- gtk4-rs 0.11 braucht Rust ≥1.92 (system hatte 1.91 → rustup update)
- `ContentFit` und `Widget::color()` brauchen Feature-Flags (`v4_8`, `v4_10`)
- GTK-Objekte (WeakRef) sind nicht Send → glib::spawn_future_local statt std::thread für UI-Updates
- `set_from_paintable` heißt jetzt `set_paintable` in gtk4-rs 0.11
- GResource-Bundle kompiliert CSS/Wallpaper/Avatar in die Binary — kein importlib.resources mehr nötig
## 2026-03-27 — Initiale Python-Version
Erster Tag. Moonset von Null auf v0.1.0 gebracht. TDD durchgezogen — alle 54 Tests grün, bevor der erste manuelle Start passiert ist. Das Pattern aus moongreet/moonlock hat sich bewährt: power.py, i18n.py, config.py sind fast 1:1 übernommen, nur mit den 5 Aktionen erweitert.
Layer Shell brauchte `LD_PRELOAD` — selbes Thema wie bei moongreet. GI-Import allein reicht nicht, weil die Linker-Reihenfolge stimmen muss. Erster Start ohne LD_PRELOAD gab die bekannten Warnings, mit LD_PRELOAD lief alles sauber: Overlay auf allen Monitoren, Escape schließt, Buttons da.
Designentscheidung: Lock ohne Confirmation, alles andere mit Inline-Confirm. Fühlt sich richtig an — Lock ist sofort reversibel, Shutdown nicht.
v0.2.0 direkt hinterher. Viel gelernt:
- `exclusive_zone = -1` ist Pflicht, sonst respektiert man Waybars Zone
- Monitor-Detection über `is_primary()` ist unzuverlässig auf Niri — stattdessen kein `set_monitor()` und den Compositor entscheiden lassen
- Icon-Theme-Lookup: 22px-Variante laden und per GdkPixbuf auf 64px skalieren, damit die gleichen Icons wie bei moonlock erscheinen
- CSS Fade-In Animationen auf Layer Shell Surfaces wirken ruckelig (wenige FPS) — rausgenommen
- `loginctl lock-session` braucht einen D-Bus-Listener der schwer aufzusetzen ist — moonlock direkt aufrufen ist einfacher und zuverlässiger
- LD_PRELOAD über den Niri-Keybind setzen spart den Reexec und damit ~1s Startzeit

View File

@ -1,7 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<gresources> <gresources>
<gresource prefix="/dev/moonarch/moonset"> <gresource prefix="/dev/moonarch/moonset">
<file compressed="true">style.css</file> <file>style.css</file>
<file compressed="true">default-avatar.svg</file> <file>wallpaper.jpg</file>
<file>default-avatar.svg</file>
</gresource> </gresource>
</gresources> </gresources>

View File

@ -6,28 +6,16 @@ window.panel {
background-color: @theme_bg_color; background-color: @theme_bg_color;
background-size: cover; background-size: cover;
background-position: center; background-position: center;
opacity: 0;
transition: opacity 250ms ease-in;
}
window.panel.visible {
opacity: 1;
} }
/* Wallpaper-only window for secondary monitors */ /* Wallpaper-only window for secondary monitors */
window.wallpaper { window.wallpaper {
background-color: @theme_bg_color; background-color: @theme_bg_color;
opacity: 0;
transition: opacity 250ms ease-in;
}
window.wallpaper.visible {
opacity: 1;
} }
/* Round avatar image */ /* Round avatar image */
.avatar { .avatar {
border-radius: 9999px; border-radius: 50%;
min-width: 128px; min-width: 128px;
min-height: 128px; min-height: 128px;
background-color: @theme_selected_bg_color; background-color: @theme_selected_bg_color;
@ -43,12 +31,12 @@ window.wallpaper.visible {
margin-bottom: 40px; margin-bottom: 40px;
} }
/* Action button — circular card */ /* Action button — square card */
.action-button { .action-button {
min-width: 120px; min-width: 120px;
min-height: 120px; min-height: 120px;
padding: 16px; padding: 16px;
border-radius: 9999px; border-radius: 50%;
background-color: alpha(@theme_base_color, 0.55); background-color: alpha(@theme_base_color, 0.55);
color: @theme_fg_color; color: @theme_fg_color;
border: none; border: none;

BIN
resources/wallpaper.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

View File

@ -6,6 +6,7 @@ use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
const MOONARCH_WALLPAPER: &str = "/usr/share/moonarch/wallpaper.jpg"; const MOONARCH_WALLPAPER: &str = "/usr/share/moonarch/wallpaper.jpg";
const GRESOURCE_PREFIX: &str = "/dev/moonarch/moonset";
/// Default config search paths: system-wide, then user-specific. /// Default config search paths: system-wide, then user-specific.
fn default_config_paths() -> Vec<PathBuf> { fn default_config_paths() -> Vec<PathBuf> {
@ -20,7 +21,6 @@ fn default_config_paths() -> Vec<PathBuf> {
#[derive(Debug, Clone, Default, Deserialize)] #[derive(Debug, Clone, Default, Deserialize)]
pub struct Config { pub struct Config {
pub background_path: Option<String>, pub background_path: Option<String>,
pub background_blur: Option<f32>,
} }
/// Load config from TOML files. Later paths override earlier ones. /// Load config from TOML files. Later paths override earlier ones.
@ -31,22 +31,10 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
let mut merged = Config::default(); let mut merged = Config::default();
for path in paths { for path in paths {
if let Ok(content) = fs::read_to_string(path) { if let Ok(content) = fs::read_to_string(path) {
match toml::from_str::<Config>(&content) { if let Ok(parsed) = toml::from_str::<Config>(&content) {
Ok(parsed) => {
log::debug!("Config loaded: {}", path.display());
if parsed.background_path.is_some() { if parsed.background_path.is_some() {
merged.background_path = parsed.background_path; merged.background_path = parsed.background_path;
} }
// Validate blur per source — invalid values preserve the previous default
if parsed.background_blur.is_some_and(|b| b.is_finite() && (0.0..=200.0).contains(&b)) {
merged.background_blur = parsed.background_blur;
} else if parsed.background_blur.is_some() {
log::warn!("Invalid background_blur in {}, ignoring", path.display());
}
}
Err(e) => {
log::warn!("Failed to parse {}: {e}", path.display());
}
} }
} }
} }
@ -56,68 +44,28 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
/// Resolve the wallpaper path using the fallback hierarchy. /// Resolve the wallpaper path using the fallback hierarchy.
/// ///
/// Priority: config background_path > Moonarch system default. /// Priority: config background_path > Moonarch system default > gresource fallback.
/// Returns None if no wallpaper is available (CSS background shows through). pub fn resolve_background_path(config: &Config) -> PathBuf {
pub fn resolve_background_path(config: &Config) -> Option<PathBuf> {
resolve_background_path_with(config, Path::new(MOONARCH_WALLPAPER)) resolve_background_path_with(config, Path::new(MOONARCH_WALLPAPER))
} }
/// Wallpapers are passed to gdk-pixbuf's image loader; restrict to common image
/// extensions to reduce the parser-attack surface for user-controlled paths.
const ALLOWED_BG_EXT: &[&str] = &["jpg", "jpeg", "png", "webp"];
/// Bound wallpaper decode latency (10 MB covers typical 4K JPEGs at Q95).
const MAX_WALLPAPER_FILE_SIZE: u64 = 10 * 1024 * 1024;
fn is_allowed_wallpaper(path: &Path) -> bool {
match path.extension().and_then(|e| e.to_str()) {
Some(ext) => ALLOWED_BG_EXT.iter().any(|a| a.eq_ignore_ascii_case(ext)),
None => false,
}
}
fn accept_wallpaper(path: &Path) -> bool {
if !is_allowed_wallpaper(path) {
log::warn!("Wallpaper rejected (extension not in allowlist): {}", path.display());
return false;
}
match path.symlink_metadata() {
Ok(meta) if meta.file_type().is_symlink() => {
log::warn!("Wallpaper rejected (symlink): {}", path.display());
false
}
Ok(meta) if !meta.is_file() => false,
Ok(meta) if meta.len() > MAX_WALLPAPER_FILE_SIZE => {
log::warn!(
"Wallpaper rejected ({} bytes > {} limit): {}",
meta.len(), MAX_WALLPAPER_FILE_SIZE, path.display()
);
false
}
Ok(_) => true,
Err(_) => false,
}
}
/// Resolve with configurable moonarch wallpaper path (for testing). /// Resolve with configurable moonarch wallpaper path (for testing).
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> Option<PathBuf> { pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> PathBuf {
// User-configured path — reject symlinks, non-image extensions, and oversized files // User-configured path
if let Some(ref bg) = config.background_path { if let Some(ref bg) = config.background_path {
let path = PathBuf::from(bg); let path = PathBuf::from(bg);
if accept_wallpaper(&path) { if path.is_file() {
log::debug!("Wallpaper source: config ({})", path.display()); return path;
return Some(path);
} }
} }
// Moonarch ecosystem default — apply the same checks for consistency // Moonarch ecosystem default
if accept_wallpaper(moonarch_wallpaper) { if moonarch_wallpaper.is_file() {
log::debug!("Wallpaper source: moonarch default ({})", moonarch_wallpaper.display()); return moonarch_wallpaper.to_path_buf();
return Some(moonarch_wallpaper.to_path_buf());
} }
log::debug!("No wallpaper found, using CSS background"); // GResource fallback path (loaded from compiled resources at runtime)
None PathBuf::from(format!("{GRESOURCE_PREFIX}/wallpaper.jpg"))
} }
#[cfg(test)] #[cfg(test)]
@ -128,7 +76,6 @@ mod tests {
fn default_config_has_none_background() { fn default_config_has_none_background() {
let config = Config::default(); let config = Config::default();
assert!(config.background_path.is_none()); assert!(config.background_path.is_none());
assert!(config.background_blur.is_none());
} }
#[test] #[test]
@ -148,28 +95,6 @@ mod tests {
assert_eq!(config.background_path.as_deref(), Some("/custom/wallpaper.jpg")); assert_eq!(config.background_path.as_deref(), Some("/custom/wallpaper.jpg"));
} }
#[test]
fn load_config_reads_background_blur() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moonset.toml");
fs::write(&conf, "background_blur = 20.0\n").unwrap();
let paths = vec![conf];
let config = load_config(Some(&paths));
assert_eq!(config.background_blur, Some(20.0));
}
#[test]
fn load_config_blur_override() {
let dir = tempfile::tempdir().unwrap();
let conf1 = dir.path().join("first.toml");
let conf2 = dir.path().join("second.toml");
fs::write(&conf1, "background_blur = 10.0\n").unwrap();
fs::write(&conf2, "background_blur = 25.0\n").unwrap();
let paths = vec![conf1, conf2];
let config = load_config(Some(&paths));
assert_eq!(config.background_blur, Some(25.0));
}
#[test] #[test]
fn load_config_later_paths_override_earlier() { fn load_config_later_paths_override_earlier() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@ -199,11 +124,10 @@ mod tests {
fs::write(&wallpaper, "fake").unwrap(); fs::write(&wallpaper, "fake").unwrap();
let config = Config { let config = Config {
background_path: Some(wallpaper.to_str().unwrap().to_string()), background_path: Some(wallpaper.to_str().unwrap().to_string()),
..Config::default()
}; };
assert_eq!( assert_eq!(
resolve_background_path_with(&config, Path::new("/nonexistent")), resolve_background_path_with(&config, Path::new("/nonexistent")),
Some(wallpaper) wallpaper
); );
} }
@ -211,10 +135,10 @@ mod tests {
fn resolve_ignores_config_path_when_file_missing() { fn resolve_ignores_config_path_when_file_missing() {
let config = Config { let config = Config {
background_path: Some("/nonexistent/wallpaper.jpg".to_string()), background_path: Some("/nonexistent/wallpaper.jpg".to_string()),
..Config::default()
}; };
let result = resolve_background_path_with(&config, Path::new("/nonexistent")); let result = resolve_background_path_with(&config, Path::new("/nonexistent"));
assert_eq!(result, None); // Falls through to gresource fallback
assert!(result.to_str().unwrap().contains("moonset"));
} }
#[test] #[test]
@ -223,84 +147,13 @@ mod tests {
let moonarch_wp = dir.path().join("wallpaper.jpg"); let moonarch_wp = dir.path().join("wallpaper.jpg");
fs::write(&moonarch_wp, "fake").unwrap(); fs::write(&moonarch_wp, "fake").unwrap();
let config = Config::default(); let config = Config::default();
assert_eq!(resolve_background_path_with(&config, &moonarch_wp), Some(moonarch_wp)); assert_eq!(resolve_background_path_with(&config, &moonarch_wp), moonarch_wp);
} }
#[test] #[test]
fn resolve_returns_none_when_no_wallpaper_available() { fn resolve_uses_gresource_fallback_as_last_resort() {
let config = Config::default(); let config = Config::default();
let result = resolve_background_path_with(&config, Path::new("/nonexistent")); let result = resolve_background_path_with(&config, Path::new("/nonexistent"));
assert_eq!(result, None); assert!(result.to_str().unwrap().contains("wallpaper.jpg"));
}
#[test]
fn load_config_ignores_invalid_toml_syntax() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("bad.toml");
fs::write(&conf, "this is not valid [[[ toml").unwrap();
let paths = vec![conf];
let config = load_config(Some(&paths));
assert!(config.background_path.is_none());
assert!(config.background_blur.is_none());
}
#[test]
fn load_config_ignores_wrong_field_types() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("wrong_type.toml");
fs::write(&conf, "background_blur = \"not_a_number\"\n").unwrap();
let paths = vec![conf];
let config = load_config(Some(&paths));
assert!(config.background_blur.is_none());
}
#[test]
fn load_config_rejects_negative_blur() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("negative.toml");
fs::write(&conf, "background_blur = -5.0\n").unwrap();
let paths = vec![conf];
let config = load_config(Some(&paths));
assert_eq!(config.background_blur, None);
}
#[test]
fn load_config_rejects_excessive_blur() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("huge.toml");
fs::write(&conf, "background_blur = 999.0\n").unwrap();
let paths = vec![conf];
let config = load_config(Some(&paths));
assert_eq!(config.background_blur, None);
}
#[test]
fn load_config_accepts_valid_blur_range() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("valid.toml");
fs::write(&conf, "background_blur = 50.0\n").unwrap();
let paths = vec![conf];
let config = load_config(Some(&paths));
assert_eq!(config.background_blur, Some(50.0));
}
#[test]
fn load_config_accepts_zero_blur() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("zero.toml");
fs::write(&conf, "background_blur = 0.0\n").unwrap();
let paths = vec![conf];
let config = load_config(Some(&paths));
assert_eq!(config.background_blur, Some(0.0));
}
#[test]
fn load_config_accepts_max_blur() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("max.toml");
fs::write(&conf, "background_blur = 200.0\n").unwrap();
let paths = vec![conf];
let config = load_config(Some(&paths));
assert_eq!(config.background_blur, Some(200.0));
} }
} }

View File

@ -110,34 +110,17 @@ fn read_lang_from_conf(path: &Path) -> Option<String> {
None None
} }
/// Determine the system language from POSIX locale env vars or /etc/locale.conf. /// Determine the system language from LANG env var or /etc/locale.conf.
/// Checks LC_ALL, LC_MESSAGES, LANG in POSIX priority order (LC_ALL overrides
/// everything; LC_MESSAGES overrides LANG for text categories).
pub fn detect_locale() -> String { pub fn detect_locale() -> String {
let env_val = env::var("LC_ALL") let lang = env::var("LANG")
.ok() .ok()
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.or_else(|| env::var("LC_MESSAGES").ok().filter(|s| !s.is_empty())) .or_else(|| read_lang_from_conf(Path::new(DEFAULT_LOCALE_CONF)));
.or_else(|| env::var("LANG").ok().filter(|s| !s.is_empty()));
detect_locale_with(env_val.as_deref(), Path::new(DEFAULT_LOCALE_CONF))
}
/// Determine locale with configurable inputs (for testing). match lang {
pub fn detect_locale_with(env_lang: Option<&str>, locale_conf_path: &Path) -> String {
let (raw, source) = if let Some(val) = env_lang.filter(|s| !s.is_empty()) {
(Some(val.to_string()), "env")
} else if let Some(val) = read_lang_from_conf(locale_conf_path) {
(Some(val), "locale.conf")
} else {
(None, "default")
};
let result = match raw {
Some(l) => parse_lang_prefix(&l), Some(l) => parse_lang_prefix(&l),
None => "en".to_string(), None => "en".to_string(),
}; }
log::debug!("Detected locale: {result} (source: {source})");
result
} }
/// Return the string table for the given locale, defaulting to English. /// Return the string table for the given locale, defaulting to English.
@ -276,40 +259,6 @@ mod tests {
} }
} }
// -- detect_locale_with tests --
#[test]
fn detect_locale_uses_env_lang() {
let result = detect_locale_with(Some("de_DE.UTF-8"), Path::new("/nonexistent"));
assert_eq!(result, "de");
}
#[test]
fn detect_locale_falls_back_to_conf_file() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("locale.conf");
let mut f = fs::File::create(&conf).unwrap();
writeln!(f, "LANG=de_DE.UTF-8").unwrap();
let result = detect_locale_with(None, &conf);
assert_eq!(result, "de");
}
#[test]
fn detect_locale_ignores_empty_env_lang() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("locale.conf");
let mut f = fs::File::create(&conf).unwrap();
writeln!(f, "LANG=fr_FR.UTF-8").unwrap();
let result = detect_locale_with(Some(""), &conf);
assert_eq!(result, "fr");
}
#[test]
fn detect_locale_defaults_to_english() {
let result = detect_locale_with(None, Path::new("/nonexistent"));
assert_eq!(result, "en");
}
#[test] #[test]
fn error_messages_contain_failed() { fn error_messages_contain_failed() {
let s = load_strings(Some("en")); let s = load_strings(Some("en"));

View File

@ -11,17 +11,14 @@ use gdk4 as gdk;
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4::{self as gtk, gio}; use gtk4::{self as gtk, gio};
use gtk4_layer_shell::LayerShell; use gtk4_layer_shell::LayerShell;
use std::path::PathBuf;
pub(crate) const GRESOURCE_PREFIX: &str = "/dev/moonarch/moonset";
fn load_css(display: &gdk::Display) { fn load_css(display: &gdk::Display) {
let css_provider = gtk::CssProvider::new(); let css_provider = gtk::CssProvider::new();
css_provider.load_from_resource(&format!("{GRESOURCE_PREFIX}/style.css")); css_provider.load_from_resource("/dev/moonarch/moonset/style.css");
gtk::style_context_add_provider_for_display( gtk::style_context_add_provider_for_display(
display, display,
&css_provider, &css_provider,
gtk::STYLE_PROVIDER_PRIORITY_USER, gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
); );
} }
@ -43,7 +40,7 @@ fn setup_layer_shell(
window.set_anchor(gtk4_layer_shell::Edge::Right, true); window.set_anchor(gtk4_layer_shell::Edge::Right, true);
} }
fn activate(app: &gtk::Application, bg_path: &Option<PathBuf>, blur_radius: Option<f32>) { fn activate(app: &gtk::Application) {
let display = match gdk::Display::default() { let display = match gdk::Display::default() {
Some(d) => d, Some(d) => d,
None => { None => {
@ -54,14 +51,12 @@ fn activate(app: &gtk::Application, bg_path: &Option<PathBuf>, blur_radius: Opti
load_css(&display); load_css(&display);
// Decode texture once (if wallpaper available), share across all windows. // Resolve wallpaper once, share across all windows
// Blur is applied on the GPU via GskBlurNode at first widget realization, let config = config::load_config(None);
// then cached and reused by all subsequent windows. let bg_path = config::resolve_background_path(&config);
let texture = panel::load_background_texture(bg_path.as_deref());
let blur_cache = panel::new_blur_cache();
// Panel on focused output (no set_monitor → compositor picks focused) // Panel on focused output (no set_monitor → compositor picks focused)
let panel = panel::create_panel_window(texture.as_ref(), blur_radius, &blur_cache, app); let panel = panel::create_panel_window(&bg_path, app);
setup_layer_shell(&panel, true, gtk4_layer_shell::Layer::Overlay); setup_layer_shell(&panel, true, gtk4_layer_shell::Layer::Overlay);
panel.present(); panel.present();
@ -69,7 +64,7 @@ fn activate(app: &gtk::Application, bg_path: &Option<PathBuf>, blur_radius: Opti
let monitors = display.monitors(); let monitors = display.monitors();
for i in 0..monitors.n_items() { for i in 0..monitors.n_items() {
if let Some(monitor) = monitors.item(i).and_then(|obj| obj.downcast::<gdk::Monitor>().ok()) { if let Some(monitor) = monitors.item(i).and_then(|obj| obj.downcast::<gdk::Monitor>().ok()) {
let wallpaper = panel::create_wallpaper_window(texture.as_ref(), blur_radius, &blur_cache, app); let wallpaper = panel::create_wallpaper_window(&bg_path, app);
setup_layer_shell(&wallpaper, false, gtk4_layer_shell::Layer::Top); setup_layer_shell(&wallpaper, false, gtk4_layer_shell::Layer::Top);
wallpaper.set_monitor(Some(&monitor)); wallpaper.set_monitor(Some(&monitor));
wallpaper.present(); wallpaper.present();
@ -77,44 +72,19 @@ fn activate(app: &gtk::Application, bg_path: &Option<PathBuf>, blur_radius: Opti
} }
} }
fn setup_logging() {
match systemd_journal_logger::JournalLog::new() {
Ok(logger) => {
if let Err(e) = logger.install() {
eprintln!("Failed to install journal logger: {e}");
}
}
Err(e) => {
eprintln!("Failed to create journal logger: {e}");
}
}
// Require MOONSET_DEBUG=1 to raise verbosity so mere presence (empty
// value in a session script) cannot escalate journal noise with path
// information an attacker could use.
let level = match std::env::var("MOONSET_DEBUG").ok().as_deref() {
Some("1") => log::LevelFilter::Debug,
_ => log::LevelFilter::Info,
};
log::set_max_level(level);
}
fn main() { fn main() {
setup_logging(); env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Info)
.init();
log::info!("Moonset starting"); log::info!("Moonset starting");
// Register compiled GResources // Register compiled GResources
gio::resources_register_include!("moonset.gresource").expect("Failed to register resources"); gio::resources_register_include!("moonset.gresource").expect("Failed to register resources");
// Load config and resolve wallpaper path before GTK app start —
// no GTK types needed, avoids blocking the main loop.
let config = config::load_config(None);
let bg_path = config::resolve_background_path(&config);
let blur_radius = config.background_blur;
let app = gtk::Application::builder() let app = gtk::Application::builder()
.application_id("dev.moonarch.moonset") .application_id("dev.moonarch.moonset")
.build(); .build();
app.connect_activate(move |app| activate(app, &bg_path, blur_radius)); app.connect_activate(activate);
app.run(); app.run();
} }

View File

@ -7,9 +7,8 @@ use glib::clone;
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4::{self as gtk, gio}; use gtk4::{self as gtk, gio};
use std::cell::RefCell; use std::cell::RefCell;
use std::path::{Path, PathBuf}; use std::path::Path;
use std::rc::Rc; use std::rc::Rc;
use std::time::Duration;
use crate::i18n::{load_strings, Strings}; use crate::i18n::{load_strings, Strings};
use crate::power::{self, PowerError}; use crate::power::{self, PowerError};
@ -27,7 +26,6 @@ pub struct ActionDef {
pub label_attr: fn(&Strings) -> &'static str, pub label_attr: fn(&Strings) -> &'static str,
pub error_attr: fn(&Strings) -> &'static str, pub error_attr: fn(&Strings) -> &'static str,
pub confirm_attr: Option<fn(&Strings) -> &'static str>, pub confirm_attr: Option<fn(&Strings) -> &'static str>,
pub quit_after: bool,
} }
/// All 5 power action definitions. /// All 5 power action definitions.
@ -41,7 +39,6 @@ pub fn action_definitions() -> Vec<ActionDef> {
label_attr: |s| s.lock_label, label_attr: |s| s.lock_label,
error_attr: |s| s.lock_failed, error_attr: |s| s.lock_failed,
confirm_attr: None, confirm_attr: None,
quit_after: true,
}, },
ActionDef { ActionDef {
name: "logout", name: "logout",
@ -51,7 +48,6 @@ pub fn action_definitions() -> Vec<ActionDef> {
label_attr: |s| s.logout_label, label_attr: |s| s.logout_label,
error_attr: |s| s.logout_failed, error_attr: |s| s.logout_failed,
confirm_attr: Some(|s| s.logout_confirm), confirm_attr: Some(|s| s.logout_confirm),
quit_after: true,
}, },
ActionDef { ActionDef {
name: "hibernate", name: "hibernate",
@ -61,7 +57,6 @@ pub fn action_definitions() -> Vec<ActionDef> {
label_attr: |s| s.hibernate_label, label_attr: |s| s.hibernate_label,
error_attr: |s| s.hibernate_failed, error_attr: |s| s.hibernate_failed,
confirm_attr: Some(|s| s.hibernate_confirm), confirm_attr: Some(|s| s.hibernate_confirm),
quit_after: false,
}, },
ActionDef { ActionDef {
name: "reboot", name: "reboot",
@ -71,7 +66,6 @@ pub fn action_definitions() -> Vec<ActionDef> {
label_attr: |s| s.reboot_label, label_attr: |s| s.reboot_label,
error_attr: |s| s.reboot_failed, error_attr: |s| s.reboot_failed,
confirm_attr: Some(|s| s.reboot_confirm), confirm_attr: Some(|s| s.reboot_confirm),
quit_after: false,
}, },
ActionDef { ActionDef {
name: "shutdown", name: "shutdown",
@ -81,110 +75,19 @@ pub fn action_definitions() -> Vec<ActionDef> {
label_attr: |s| s.shutdown_label, label_attr: |s| s.shutdown_label,
error_attr: |s| s.shutdown_failed, error_attr: |s| s.shutdown_failed,
confirm_attr: Some(|s| s.shutdown_confirm), confirm_attr: Some(|s| s.shutdown_confirm),
quit_after: false,
}, },
] ]
} }
/// Load the wallpaper as a texture once, for sharing across all windows.
/// Returns None if no wallpaper path is configured (CSS background shows through).
pub fn load_background_texture(bg_path: Option<&Path>) -> Option<gdk::Texture> {
let bg_path = bg_path?;
log::debug!("Background: {}", bg_path.display());
let file = gio::File::for_path(bg_path);
match gdk::Texture::from_file(&file) {
Ok(texture) => Some(texture),
Err(e) => {
log::warn!("Failed to load wallpaper {}: {e}", bg_path.display());
None
}
}
}
// -- GPU blur via GskBlurNode -------------------------------------------------
// SYNC: MAX_BLUR_DIMENSION, render_blurred_texture, and create_background_picture
// are duplicated in moonlock/src/lockscreen.rs and moongreet/src/greeter.rs.
// Changes here must be mirrored to the other two projects.
/// Maximum texture dimension before downscaling for blur.
/// Keeps GPU work reasonable on 4K+ displays.
const MAX_BLUR_DIMENSION: f32 = 1920.0;
/// Render a blurred texture using the GPU via GskBlurNode.
///
/// To avoid edge darkening (blur samples transparent pixels outside bounds),
/// the texture is rendered with padding equal to 3x the blur sigma. The blur
/// is applied to the padded area, then cropped back to the original size.
///
/// Large textures (> MAX_BLUR_DIMENSION) are downscaled before blurring to
/// reduce GPU work. The sigma is scaled proportionally.
fn render_blurred_texture(
widget: &impl IsA<gtk::Widget>,
texture: &gdk::Texture,
sigma: f32,
) -> Option<gdk::Texture> {
let native = widget.native()?;
let renderer = native.renderer()?;
let orig_w = texture.width() as f32;
let orig_h = texture.height() as f32;
// Downscale large textures to reduce GPU blur work
let max_dim = orig_w.max(orig_h);
let scale = if max_dim > MAX_BLUR_DIMENSION {
MAX_BLUR_DIMENSION / max_dim
} else {
1.0
};
let w = (orig_w * scale).round();
let h = (orig_h * scale).round();
let scaled_sigma = sigma * scale;
// Padding must cover the blur kernel radius (typically ~3x sigma)
let pad = (scaled_sigma * 3.0).ceil();
let snapshot = gtk::Snapshot::new();
// Clip output to scaled texture size
snapshot.push_clip(&graphene_rs::Rect::new(pad, pad, w, h));
snapshot.push_blur(scaled_sigma as f64);
// Render texture with padding on all sides (edges repeat via oversized bounds)
snapshot.append_texture(texture, &graphene_rs::Rect::new(-pad, -pad, w + 2.0 * pad, h + 2.0 * pad));
snapshot.pop(); // blur
snapshot.pop(); // clip
let node = snapshot.to_node()?;
let viewport = graphene_rs::Rect::new(pad, pad, w, h);
Some(renderer.render_texture(&node, Some(&viewport)))
}
/// Fade out all windows and quit the app after the CSS transition completes.
fn fade_out_and_quit(app: &gtk::Application) {
for window in app.windows() {
window.remove_css_class("visible");
}
let app = app.clone();
glib::timeout_add_local_once(Duration::from_millis(250), move || {
app.quit();
});
}
/// Create a new shared blur cache for GPU-blurred wallpaper textures.
pub fn new_blur_cache() -> BlurCache {
Rc::new(RefCell::new(None))
}
/// Create a wallpaper-only window for secondary monitors. /// Create a wallpaper-only window for secondary monitors.
pub fn create_wallpaper_window(texture: Option<&gdk::Texture>, blur_radius: Option<f32>, blur_cache: &BlurCache, app: &gtk::Application) -> gtk::ApplicationWindow { pub fn create_wallpaper_window(bg_path: &Path, app: &gtk::Application) -> gtk::ApplicationWindow {
let window = gtk::ApplicationWindow::builder() let window = gtk::ApplicationWindow::builder()
.application(app) .application(app)
.build(); .build();
window.add_css_class("wallpaper"); window.add_css_class("wallpaper");
if let Some(texture) = texture { let background = create_background_picture(bg_path);
let background = create_background_picture(texture, blur_radius, blur_cache);
window.set_child(Some(&background)); window.set_child(Some(&background));
}
// Fade-in on map // Fade-in on map
window.connect_map(|w| { window.connect_map(|w| {
@ -201,25 +104,19 @@ pub fn create_wallpaper_window(texture: Option<&gdk::Texture>, blur_radius: Opti
} }
/// Create the main panel window with action buttons and confirm flow. /// Create the main panel window with action buttons and confirm flow.
pub fn create_panel_window(texture: Option<&gdk::Texture>, blur_radius: Option<f32>, blur_cache: &BlurCache, app: &gtk::Application) -> gtk::ApplicationWindow { pub fn create_panel_window(bg_path: &Path, app: &gtk::Application) -> gtk::ApplicationWindow {
let window = gtk::ApplicationWindow::builder() let window = gtk::ApplicationWindow::builder()
.application(app) .application(app)
.build(); .build();
window.add_css_class("panel"); window.add_css_class("panel");
let strings = load_strings(None); let strings = load_strings(None);
let user = users::get_current_user().unwrap_or_else(|| { let user = users::get_current_user().unwrap_or_else(|| users::User {
let home = dirs::home_dir().unwrap_or_else(|| {
log::warn!("Could not resolve HOME — using an empty path");
PathBuf::new()
});
users::User {
username: "user".to_string(), username: "user".to_string(),
display_name: "User".to_string(), display_name: "User".to_string(),
home, home: dirs::home_dir().unwrap_or_default(),
} uid: 0,
}); });
log::debug!("User: {} ({})", user.display_name, user.username);
// State for confirm box // State for confirm box
let confirm_box: Rc<RefCell<Option<gtk::Box>>> = Rc::new(RefCell::new(None)); let confirm_box: Rc<RefCell<Option<gtk::Box>>> = Rc::new(RefCell::new(None));
@ -228,11 +125,9 @@ pub fn create_panel_window(texture: Option<&gdk::Texture>, blur_radius: Option<f
let overlay = gtk::Overlay::new(); let overlay = gtk::Overlay::new();
window.set_child(Some(&overlay)); window.set_child(Some(&overlay));
// Background wallpaper (if available, otherwise CSS background shows through) // Background wallpaper
if let Some(texture) = texture { let background = create_background_picture(bg_path);
let background = create_background_picture(texture, blur_radius, blur_cache);
overlay.set_child(Some(&background)); overlay.set_child(Some(&background));
}
// Click on background dismisses the menu // Click on background dismisses the menu
let click_controller = gtk::GestureClick::new(); let click_controller = gtk::GestureClick::new();
@ -240,10 +135,10 @@ pub fn create_panel_window(texture: Option<&gdk::Texture>, blur_radius: Option<f
#[weak] #[weak]
app, app,
move |_, _, _, _| { move |_, _, _, _| {
fade_out_and_quit(&app); app.quit();
} }
)); ));
overlay.add_controller(click_controller); background.add_controller(click_controller);
// Centered content box // Centered content box
let content_box = gtk::Box::new(gtk::Orientation::Vertical, 0); let content_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
@ -262,8 +157,13 @@ pub fn create_panel_window(texture: Option<&gdk::Texture>, blur_radius: Option<f
avatar_frame.append(&avatar_image); avatar_frame.append(&avatar_image);
content_box.append(&avatar_frame); content_box.append(&avatar_frame);
// Load avatar (file-based avatars load asynchronously) // Load avatar
load_avatar_async(&avatar_image, &window, &user); let avatar_path = users::get_avatar_path(&user.home, Some(&user.username));
if let Some(path) = avatar_path {
set_avatar_from_file(&avatar_image, &path);
} else {
set_default_avatar(&avatar_image, &window);
}
// Username label // Username label
let username_label = gtk::Label::new(Some(&user.display_name)); let username_label = gtk::Label::new(Some(&user.display_name));
@ -297,7 +197,6 @@ pub fn create_panel_window(texture: Option<&gdk::Texture>, blur_radius: Option<f
&confirm_area, &confirm_area,
&confirm_box, &confirm_box,
&error_label, &error_label,
&button_box,
); );
button_box.append(&button); button_box.append(&button);
} }
@ -311,7 +210,7 @@ pub fn create_panel_window(texture: Option<&gdk::Texture>, blur_radius: Option<f
glib::Propagation::Proceed, glib::Propagation::Proceed,
move |_, keyval, _, _| { move |_, keyval, _, _| {
if keyval == gdk::Key::Escape { if keyval == gdk::Key::Escape {
fade_out_and_quit(&app); app.quit();
glib::Propagation::Stop glib::Propagation::Stop
} else { } else {
glib::Propagation::Proceed glib::Propagation::Proceed
@ -327,49 +226,27 @@ pub fn create_panel_window(texture: Option<&gdk::Texture>, blur_radius: Option<f
let bb = button_box_clone.clone(); let bb = button_box_clone.clone();
glib::idle_add_local_once(move || { glib::idle_add_local_once(move || {
w.add_css_class("visible"); w.add_css_class("visible");
glib::idle_add_local_once(move || {
if let Some(first) = bb.first_child() { if let Some(first) = bb.first_child() {
first.grab_focus(); first.grab_focus();
} }
}); });
}); });
});
window window
} }
/// Shared cache for the GPU-blurred wallpaper texture. /// Create a Picture widget for the wallpaper background.
/// Computed once on first window realize, reused by all subsequent windows. fn create_background_picture(bg_path: &Path) -> gtk::Picture {
type BlurCache = Rc<RefCell<Option<gdk::Texture>>>; let background = if bg_path.starts_with("/dev/moonarch/moonset") {
gtk::Picture::for_resource(bg_path.to_str().unwrap_or(""))
/// Create a Picture widget for the wallpaper background, optionally with GPU blur. } else {
/// When a blur_cache is provided, the blurred texture is computed once and shared. gtk::Picture::for_filename(bg_path.to_str().unwrap_or(""))
fn create_background_picture( };
texture: &gdk::Texture,
blur_radius: Option<f32>,
blur_cache: &BlurCache,
) -> gtk::Picture {
let background = gtk::Picture::for_paintable(texture);
background.set_content_fit(gtk::ContentFit::Cover); background.set_content_fit(gtk::ContentFit::Cover);
background.set_hexpand(true); background.set_hexpand(true);
background.set_vexpand(true); background.set_vexpand(true);
if let Some(sigma) = blur_radius.filter(|s| *s > 0.0) {
let texture = texture.clone();
let cache = blur_cache.clone();
background.connect_realize(move |picture| {
// Use cached blur if available, otherwise compute and cache
if let Some(ref cached) = *cache.borrow() {
log::debug!("Blur cache hit");
picture.set_paintable(Some(cached));
return;
}
log::debug!("Blur cache miss, rendering GPU blur");
if let Some(blurred) = render_blurred_texture(picture, &texture, sigma) {
picture.set_paintable(Some(&blurred));
*cache.borrow_mut() = Some(blurred);
}
});
}
background background
} }
@ -381,7 +258,6 @@ fn create_action_button(
confirm_area: &gtk::Box, confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>, confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label, error_label: &gtk::Label,
button_box: &gtk::Box,
) -> gtk::Button { ) -> gtk::Button {
let button_content = gtk::Box::new(gtk::Orientation::Vertical, 4); let button_content = gtk::Box::new(gtk::Orientation::Vertical, 4);
button_content.set_halign(gtk::Align::Center); button_content.set_halign(gtk::Align::Center);
@ -411,8 +287,6 @@ fn create_action_button(
confirm_box, confirm_box,
#[weak] #[weak]
error_label, error_label,
#[weak]
button_box,
move |_| { move |_| {
on_action_clicked( on_action_clicked(
&action_def, &action_def,
@ -421,7 +295,6 @@ fn create_action_button(
&confirm_area, &confirm_area,
&confirm_box, &confirm_box,
&error_label, &error_label,
&button_box,
); );
} }
)); ));
@ -429,9 +302,34 @@ fn create_action_button(
button button
} }
/// Load a symbolic icon using native GTK4 rendering at the target size. /// Load a symbolic icon at 22px and scale to 64px via GdkPixbuf.
fn load_scaled_icon(icon_name: &str) -> gtk::Image { fn load_scaled_icon(icon_name: &str) -> gtk::Image {
let icon = gtk::Image::from_icon_name(icon_name); let display = gdk::Display::default().unwrap();
let theme = gtk::IconTheme::for_display(&display);
let icon_paintable = theme.lookup_icon(
icon_name,
&[],
22,
1,
gtk::TextDirection::None,
gtk::IconLookupFlags::FORCE_SYMBOLIC,
);
let icon = gtk::Image::new();
if let Some(file) = icon_paintable.file() {
if let Some(path) = file.path() {
if let Ok(pixbuf) =
Pixbuf::from_file_at_scale(path.to_str().unwrap_or(""), 64, 64, true)
{
let texture = gdk::Texture::for_pixbuf(&pixbuf);
icon.set_paintable(Some(&texture));
return icon;
}
}
}
// Fallback: use icon name directly
icon.set_icon_name(Some(icon_name));
icon.set_pixel_size(64); icon.set_pixel_size(64);
icon icon
} }
@ -444,17 +342,16 @@ fn on_action_clicked(
confirm_area: &gtk::Box, confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>, confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label, error_label: &gtk::Label,
button_box: &gtk::Box,
) { ) {
dismiss_confirm(confirm_area, confirm_box); dismiss_confirm(confirm_area, confirm_box);
error_label.set_visible(false); error_label.set_visible(false);
if !action_def.needs_confirm { if !action_def.needs_confirm {
execute_action(action_def, strings, app, confirm_area, confirm_box, error_label, button_box); execute_action(action_def, strings, app, confirm_area, confirm_box, error_label);
return; return;
} }
show_confirm(action_def, strings, app, confirm_area, confirm_box, error_label, button_box); show_confirm(action_def, strings, app, confirm_area, confirm_box, error_label);
} }
/// Show inline confirmation below the action buttons. /// Show inline confirmation below the action buttons.
@ -465,7 +362,6 @@ fn show_confirm(
confirm_area: &gtk::Box, confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>, confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label, error_label: &gtk::Label,
button_box: &gtk::Box,
) { ) {
let new_box = gtk::Box::new(gtk::Orientation::Vertical, 8); let new_box = gtk::Box::new(gtk::Orientation::Vertical, 8);
new_box.set_halign(gtk::Align::Center); new_box.set_halign(gtk::Align::Center);
@ -493,8 +389,6 @@ fn show_confirm(
confirm_box, confirm_box,
#[weak] #[weak]
error_label, error_label,
#[weak]
button_box,
move |_| { move |_| {
execute_action( execute_action(
&action_def_clone, &action_def_clone,
@ -503,7 +397,6 @@ fn show_confirm(
&confirm_area, &confirm_area,
&confirm_box, &confirm_box,
&error_label, &error_label,
&button_box,
); );
} }
)); ));
@ -516,13 +409,8 @@ fn show_confirm(
confirm_area, confirm_area,
#[strong] #[strong]
confirm_box, confirm_box,
#[weak]
button_box,
move |_| { move |_| {
dismiss_confirm(&confirm_area, &confirm_box); dismiss_confirm(&confirm_area, &confirm_box);
if let Some(first) = button_box.first_child() {
first.grab_focus();
}
} }
)); ));
button_row.append(&no_btn); button_row.append(&no_btn);
@ -551,20 +439,13 @@ fn execute_action(
confirm_area: &gtk::Box, confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>, confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label, error_label: &gtk::Label,
button_box: &gtk::Box,
) { ) {
dismiss_confirm(confirm_area, confirm_box); dismiss_confirm(confirm_area, confirm_box);
log::debug!("Executing power action: {}", action_def.name);
let action_fn = action_def.action_fn; let action_fn = action_def.action_fn;
let action_name = action_def.name; let action_name = action_def.name;
let quit_after = action_def.quit_after;
let error_message = (action_def.error_attr)(strings).to_string(); let error_message = (action_def.error_attr)(strings).to_string();
// Desensitize buttons so a double-click or accidental keyboard repeat
// cannot fire the same action twice while it is in flight.
button_box.set_sensitive(false);
// Use glib::spawn_future_local + gio::spawn_blocking to avoid Send issues // Use glib::spawn_future_local + gio::spawn_blocking to avoid Send issues
// with GTK objects. The blocking closure runs on a thread pool, the result // with GTK objects. The blocking closure runs on a thread pool, the result
// is handled back on the main thread. // is handled back on the main thread.
@ -573,71 +454,40 @@ fn execute_action(
app, app,
#[weak] #[weak]
error_label, error_label,
#[weak]
button_box,
async move { async move {
let result = gio::spawn_blocking(action_fn).await; let result = gio::spawn_blocking(move || action_fn()).await;
match result { match result {
Ok(Ok(())) => { Ok(Ok(())) => {
if quit_after { // Lock action: quit after successful execution
fade_out_and_quit(&app); if action_name == "lock" {
} else { app.quit();
button_box.set_sensitive(true);
} }
} }
Ok(Err(e)) => { Ok(Err(e)) => {
log::error!("Power action '{}' failed: {}", action_name, e); log::error!("Power action '{}' failed: {}", action_name, e);
error_label.set_text(&error_message); error_label.set_text(&error_message);
error_label.set_visible(true); error_label.set_visible(true);
button_box.set_sensitive(true);
} }
Err(_) => { Err(_) => {
log::error!("Power action '{}' panicked", action_name); log::error!("Power action '{}' panicked", action_name);
error_label.set_text(&error_message); error_label.set_text(&error_message);
error_label.set_visible(true); error_label.set_visible(true);
button_box.set_sensitive(true);
} }
} }
} }
)); ));
} }
/// Load the avatar asynchronously. File-based avatars are decoded off the UI thread. /// Load an image file and set it as the avatar.
fn load_avatar_async(image: &gtk::Image, window: &gtk::ApplicationWindow, user: &users::User) { fn set_avatar_from_file(image: &gtk::Image, path: &Path) {
let avatar_path = users::get_avatar_path(&user.home, Some(&user.username)); match Pixbuf::from_file_at_scale(path.to_str().unwrap_or(""), AVATAR_SIZE, AVATAR_SIZE, true) {
Ok(pixbuf) => {
match avatar_path { let texture = gdk::Texture::for_pixbuf(&pixbuf);
Some(path) => { image.set_paintable(Some(&texture));
log::debug!("Avatar source: file {}", path.display());
// File-based avatar: load and scale in background thread
glib::spawn_future_local(clone!(
#[weak]
image,
async move {
let result = gio::spawn_blocking(move || {
Pixbuf::from_file_at_scale(
&path,
AVATAR_SIZE,
AVATAR_SIZE,
true,
)
.ok()
.map(|pb| gdk::Texture::for_pixbuf(&pb))
})
.await;
match result {
Ok(Some(texture)) => image.set_paintable(Some(&texture)),
_ => image.set_icon_name(Some("avatar-default-symbolic")),
} }
} Err(_) => {
)); image.set_icon_name(Some("avatar-default-symbolic"));
}
None => {
log::debug!("Avatar source: default SVG");
// Default SVG avatar: needs widget color, keep synchronous
set_default_avatar(image, window);
} }
} }
} }
@ -744,20 +594,4 @@ mod tests {
let confirm_fn = defs[1].confirm_attr.unwrap(); let confirm_fn = defs[1].confirm_attr.unwrap();
assert_eq!(confirm_fn(strings), "Wirklich abmelden?"); assert_eq!(confirm_fn(strings), "Wirklich abmelden?");
} }
#[test]
fn lock_and_logout_quit_after() {
let defs = action_definitions();
assert!(defs[0].quit_after, "lock should quit after");
assert!(defs[1].quit_after, "logout should quit after");
}
#[test]
fn destructive_actions_do_not_quit_after() {
let defs = action_definitions();
for def in &defs[2..] {
assert!(!def.quit_after, "{} should not quit after", def.name);
}
}
} }

View File

@ -2,17 +2,16 @@
// ABOUTME: Wrappers around system commands for the session power menu. // ABOUTME: Wrappers around system commands for the session power menu.
use std::fmt; use std::fmt;
use std::io::Read; use std::process::Command;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
#[allow(dead_code)]
const POWER_TIMEOUT: Duration = Duration::from_secs(30); const POWER_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug)] #[derive(Debug)]
pub enum PowerError { pub enum PowerError {
CommandFailed { action: &'static str, message: String }, CommandFailed { action: &'static str, message: String },
#[allow(dead_code)]
Timeout { action: &'static str }, Timeout { action: &'static str },
} }
@ -32,117 +31,56 @@ impl fmt::Display for PowerError {
impl std::error::Error for PowerError {} impl std::error::Error for PowerError {}
/// Run a command with timeout and return a PowerError on failure. /// Run a command with timeout and return a PowerError on failure.
///
/// Uses blocking `child.wait()` with a separate timeout thread that sends
/// SIGKILL after POWER_TIMEOUT. This runs inside `gio::spawn_blocking`,
/// so blocking is expected.
fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(), PowerError> { fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(), PowerError> {
log::debug!("Power action: {action} ({program} {args:?})"); let child = Command::new(program)
let mut child = Command::new(program)
.args(args) .args(args)
// stdout is discarded — piping without draining would deadlock if a
// command ever wrote more than one OS pipe buffer before wait() returned.
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn() .spawn()
.map_err(|e| PowerError::CommandFailed { .map_err(|e| PowerError::CommandFailed {
action, action,
message: e.to_string(), message: e.to_string(),
})?; })?;
let child_pid = nix::unistd::Pid::from_raw(child.id() as i32); let output = child
let done = Arc::new(AtomicBool::new(false)); .wait_with_output()
let timed_out = Arc::new(AtomicBool::new(false)); .map_err(|e| PowerError::CommandFailed {
let done_clone = done.clone();
let timed_out_clone = timed_out.clone();
let _timeout_thread = std::thread::spawn(move || {
let interval = Duration::from_millis(100);
let mut elapsed = Duration::ZERO;
while elapsed < POWER_TIMEOUT {
std::thread::sleep(interval);
if done_clone.load(Ordering::Acquire) {
return;
}
elapsed += interval;
}
// Record that we fired the kill so we don't misclassify an external
// SIGKILL (OOM killer, kill -9) as our timeout.
timed_out_clone.store(true, Ordering::Release);
// ESRCH if the process already exited — harmless
let _ = nix::sys::signal::kill(child_pid, nix::sys::signal::Signal::SIGKILL);
});
// Drop guard ensures the timeout thread sees done=true even if child.wait()
// errors out — otherwise the thread sleeps its full 30 s holding a slot in
// the gio::spawn_blocking pool.
struct DoneGuard(Arc<AtomicBool>);
impl Drop for DoneGuard {
fn drop(&mut self) {
self.0.store(true, Ordering::Release);
}
}
let _done_guard = DoneGuard(done);
let status = child.wait().map_err(|e| PowerError::CommandFailed {
action, action,
message: e.to_string(), message: e.to_string(),
})?; })?;
if status.success() { if !output.status.success() {
log::debug!("Power action {action} completed"); let stderr = String::from_utf8_lossy(&output.stderr);
Ok(()) return Err(PowerError::CommandFailed {
} else {
if timed_out.load(Ordering::Acquire) {
return Err(PowerError::Timeout { action });
}
let mut stderr_buf = String::new();
if let Some(mut stderr) = child.stderr.take() {
let _ = stderr.read_to_string(&mut stderr_buf);
}
Err(PowerError::CommandFailed {
action, action,
message: format!("exit code {}: {}", status, stderr_buf.trim()), message: format!("exit code {}: {}", output.status, stderr.trim()),
}) });
} }
Ok(())
} }
/// Lock the current session by launching moonlock. /// Lock the current session by launching moonlock.
/// Spawns moonlock as a detached process and returns immediately —
/// moonlock runs independently until the user unlocks.
pub fn lock() -> Result<(), PowerError> { pub fn lock() -> Result<(), PowerError> {
log::debug!("Power action: lock (spawning moonlock)"); run_command("lock", "moonlock", &[])
Command::new("/usr/bin/moonlock")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::inherit())
.spawn()
.map_err(|e| PowerError::CommandFailed {
action: "lock",
message: e.to_string(),
})?;
// Child handle is dropped here — moonlock continues running independently.
Ok(())
} }
/// Quit the Niri compositor (logout). /// Quit the Niri compositor (logout).
pub fn logout() -> Result<(), PowerError> { pub fn logout() -> Result<(), PowerError> {
run_command("logout", "/usr/bin/niri", &["msg", "action", "quit"]) run_command("logout", "niri", &["msg", "action", "quit"])
} }
/// Hibernate the system via systemctl. /// Hibernate the system via systemctl.
pub fn hibernate() -> Result<(), PowerError> { pub fn hibernate() -> Result<(), PowerError> {
run_command("hibernate", "/usr/bin/systemctl", &["hibernate"]) run_command("hibernate", "systemctl", &["hibernate"])
} }
/// Reboot the system via systemctl. /// Reboot the system via loginctl.
pub fn reboot() -> Result<(), PowerError> { pub fn reboot() -> Result<(), PowerError> {
run_command("reboot", "/usr/bin/systemctl", &["reboot"]) run_command("reboot", "loginctl", &["reboot"])
} }
/// Shut down the system via systemctl. /// Shut down the system via loginctl.
pub fn shutdown() -> Result<(), PowerError> { pub fn shutdown() -> Result<(), PowerError> {
run_command("shutdown", "/usr/bin/systemctl", &["poweroff"]) run_command("shutdown", "loginctl", &["poweroff"])
} }
#[cfg(test)] #[cfg(test)]

View File

@ -5,6 +5,7 @@ use nix::unistd::{getuid, User as NixUser};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
const DEFAULT_ACCOUNTSSERVICE_DIR: &str = "/var/lib/AccountsService/icons"; const DEFAULT_ACCOUNTSSERVICE_DIR: &str = "/var/lib/AccountsService/icons";
const GRESOURCE_PREFIX: &str = "/dev/moonarch/moonset";
/// Represents the current user for the power menu. /// Represents the current user for the power menu.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -12,6 +13,7 @@ pub struct User {
pub username: String, pub username: String,
pub display_name: String, pub display_name: String,
pub home: PathBuf, pub home: PathBuf,
pub uid: u32,
} }
/// Get the currently logged-in user's info from the system. /// Get the currently logged-in user's info from the system.
@ -36,6 +38,7 @@ pub fn get_current_user() -> Option<User> {
username: nix_user.name, username: nix_user.name,
display_name, display_name,
home: nix_user.dir, home: nix_user.dir,
uid: uid.as_raw(),
}) })
} }
@ -45,7 +48,6 @@ pub fn get_avatar_path(home: &Path, username: Option<&str>) -> Option<PathBuf> {
} }
/// Find avatar with configurable AccountsService dir (for testing). /// Find avatar with configurable AccountsService dir (for testing).
/// Rejects symlinks to prevent path traversal.
pub fn get_avatar_path_with( pub fn get_avatar_path_with(
home: &Path, home: &Path,
username: Option<&str>, username: Option<&str>,
@ -53,25 +55,15 @@ pub fn get_avatar_path_with(
) -> Option<PathBuf> { ) -> Option<PathBuf> {
// ~/.face takes priority // ~/.face takes priority
let face = home.join(".face"); let face = home.join(".face");
if let Ok(meta) = face.symlink_metadata() { if face.exists() {
if meta.file_type().is_symlink() {
log::warn!("Rejecting symlink avatar: {}", face.display());
} else if meta.is_file() {
log::debug!("Avatar: using ~/.face ({})", face.display());
return Some(face); return Some(face);
} }
}
// AccountsService icon fallback // AccountsService icon
if let Some(name) = username if let Some(name) = username {
&& accountsservice_dir.exists() if accountsservice_dir.exists() {
{
let icon = accountsservice_dir.join(name); let icon = accountsservice_dir.join(name);
if let Ok(meta) = icon.symlink_metadata() { if icon.exists() {
if meta.file_type().is_symlink() {
log::warn!("Rejecting symlink avatar: {}", icon.display());
} else if meta.is_file() {
log::debug!("Avatar: using AccountsService icon ({})", icon.display());
return Some(icon); return Some(icon);
} }
} }
@ -82,8 +74,7 @@ pub fn get_avatar_path_with(
/// Return the GResource path to the default avatar SVG. /// Return the GResource path to the default avatar SVG.
pub fn get_default_avatar_path() -> String { pub fn get_default_avatar_path() -> String {
let prefix = crate::GRESOURCE_PREFIX; format!("{GRESOURCE_PREFIX}/default-avatar.svg")
format!("{prefix}/default-avatar.svg")
} }
#[cfg(test)] #[cfg(test)]
@ -134,17 +125,6 @@ mod tests {
assert_eq!(path, Some(face)); assert_eq!(path, Some(face));
} }
#[test]
fn rejects_symlink_avatar() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("secret");
fs::write(&target, "secret content").unwrap();
let face = dir.path().join(".face");
std::os::unix::fs::symlink(&target, &face).unwrap();
let path = get_avatar_path_with(dir.path(), None, Path::new("/nonexistent"));
assert!(path.is_none());
}
#[test] #[test]
fn returns_none_when_no_avatar() { fn returns_none_when_no_avatar() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();