15 Commits

Author SHA1 Message Date
nevaforget 2ca572773e fix: elevate CSS priority to override GTK4 user theme (v0.7.2)
Colloid-Catppuccin theme loaded via ~/.config/gtk-4.0/gtk.css at
PRIORITY_USER (800) was overriding moonset's PRIORITY_APPLICATION (600),
causing action buttons to lose their circular border-radius.

- Use STYLE_PROVIDER_PRIORITY_USER for app CSS provider
- Replace border-radius: 50% with 9999px (GTK4 CSS percentage quirk)
2026-03-29 14:23:33 +02:00
nevaforget efc55aa372 fix: prevent edge darkening on GPU-blurred wallpaper (v0.7.1)
GskBlurNode samples pixels outside texture bounds as transparent,
causing visible darkening at wallpaper edges. Fix renders the texture
with 3x-sigma padding before blur, then clips back to original size.
2026-03-28 23:15:47 +01:00
nevaforget 5a6900e85a fix: address audit findings — polling, symlinks, validation, wallpaper removal (v0.7.0)
Three parallel audits (quality, performance, security) identified issues
across the codebase. This commit addresses all remaining findings:

- Replace busy-loop polling in run_command with child.wait() + timeout thread
- Canonicalize ~/.face and AccountsService avatar paths to prevent symlink abuse
- Add detect_locale_with() DI function for testable locale detection
- Move config I/O from activate() to main() to avoid blocking GTK main loop
- Validate background_blur range (0–200), reject invalid values with warning
- Remove embedded wallpaper from GResource — moonarch provides it via filesystem
  (binary size ~3.2MB → ~1.3MB)
2026-03-28 23:09:29 +01:00
nevaforget 71670eb263 feat: switch to systemd-journal-logger, add debug logging (v0.6.0)
Replace env_logger with systemd-journal-logger for consistent logging
across moonset/moonlock/moongreet. Add MOONSET_DEBUG env var and debug
statements across all modules. Also includes shared blur cache for
multi-monitor and detached moonlock spawn for lock action.
2026-03-28 22:58:25 +01:00
nevaforget 14affb1533 perf: replace CPU blur with GPU blur via GskBlurNode (v0.5.0)
Replace image crate + disk cache blur with GPU-side GskBlurNode,
symmetric with moonlock and moongreet. Removes ~15 transitive
dependencies and ~160 lines of caching code. Blur now happens once
on the GPU at widget realization — zero startup latency, no cache
management needed.
2026-03-28 22:35:18 +01:00
nevaforget 4d8e306b74 feat: add fade-out animation on dismiss for smooth visual exit
Without this, app.quit() destroys windows instantly, creating a jarring
pop-out. Now all windows fade out over 250ms (matching the fade-in)
before the app exits. Uses the same CSS opacity transition — just
removes the "visible" class and defers quit via glib timeout.
2026-03-28 21:50:03 +01:00
nevaforget 2e88a9b6c4 feat: activate fade-in animation for panel and wallpaper windows
The Rust code already adds a "visible" CSS class on map, but the
stylesheet had no corresponding opacity transition. Add 250ms ease-in
fade via GPU-accelerated CSS opacity to eliminate the visual pop-in.
2026-03-28 21:46:08 +01:00
nevaforget 412ed159a4 fix: address audit findings — blur channel mismatch, logout quit, config error logging
- Fix BGRA→RGBA channel swap in apply_blur so image::RgbaImage semantics
  match the actual pixel data from GDK texture download
- Logout now calls app.quit() like lock does, via new quit_after field on
  ActionDef (replaces fragile magic string comparison)
- Log TOML parse errors to stderr instead of silently ignoring
- Remove pointless zlib compression of JPEG wallpaper in GResource
- Add tests for quit_after behavior and config error handling
2026-03-28 21:39:34 +01:00
nevaforget 478caed8e0 perf: cache blurred wallpaper to disk to avoid re-blur on startup
First launch with blur blurs and saves to ~/.cache/moonset/. Subsequent
starts load the cached PNG directly (~9x faster). Cache invalidates
when wallpaper path, size, mtime, or sigma changes.
2026-03-28 21:22:48 +01:00
nevaforget 622b06da3f chore: bump version to 0.4.0 2026-03-28 14:55:17 +01:00
nevaforget 529a1a54ae feat: add optional background blur via image crate
Gaussian blur applied at texture load time when `background_blur` is
set in moonset.toml. Blur runs once, result is shared across monitors.
2026-03-28 14:53:04 +01:00
nevaforget 473bed479a docs: add CHANGELOG.md, DECISIONS.md, bump version to 0.1.1
Add CHANGELOG documenting all changes since 0.1.0 and the initial
release. Add DECISIONS.md as an architectural decision log. Update
CLAUDE.md to reflect current architecture. Bump to 0.1.1 for the
security and correctness fixes in the previous commit.
2026-03-28 10:17:22 +01:00
nevaforget 496a7a4c72 fix: address audit findings — security, performance, and correctness
- Use absolute paths for all binaries in power.rs to prevent PATH hijacking
- Implement POWER_TIMEOUT via try_wait() polling (was declared but unused)
- Fix potential panic in load_background_texture when GResource path
  fails to_str() — now falls back to known wallpaper resource path
- Compress wallpaper.jpg in GResource bundle (saves ~374 KB in binary)
- Merge double idle_add_local_once into single cycle for faster focus
- Centralize GRESOURCE_PREFIX as pub(crate) const in main.rs
- Fix fallback user UID from 0 (root) to u32::MAX
- Fix CSS comment: "square card" → "circular card" (border-radius: 50%)
2026-03-28 10:13:18 +01:00
nevaforget 2d1d364270 i18n: migrate German text to English, remove stale journal
Translate README.md and config/moonset.toml comments from German
to English to enforce the repo language policy. Remove journal.md
as it was a one-time snapshot, not an actively maintained document.
2026-03-28 09:53:10 +01:00
nevaforget b22172c3a0 perf: optimize startup by caching icons, texture, and async avatar
- Replace manual icon theme lookup + Pixbuf scaling with native
  GTK4 Image::from_icon_name() (uses internal cache + GPU rendering)
- Decode wallpaper texture once and share across all windows
  instead of N+1 separate JPEG decodes
- Load file-based avatars asynchronously via gio::spawn_blocking
  to avoid blocking the UI thread
2026-03-28 09:47:47 +01:00
17 changed files with 757 additions and 389 deletions
+107
View File
@@ -0,0 +1,107 @@
# Changelog
All notable changes to this project will be documented in this file.
Format based on [Keep a Changelog](https://keepachangelog.com/).
## [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
+12 -7
View File
@@ -17,7 +17,7 @@ Lock, Logout, Hibernate, Reboot, Shutdown.
## Projektstruktur
- `src/` — Rust-Quellcode (main.rs, power.rs, i18n.rs, config.rs, users.rs, panel.rs)
- `resources/` — GResource-Assets (style.css, wallpaper.jpg, default-avatar.svg)
- `resources/` — GResource-Assets (style.css, default-avatar.svg)
- `config/` — Beispiel-Konfigurationsdateien
## Kommandos
@@ -35,20 +35,25 @@ LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonset
## Architektur
- `power.rs`5 Power-Action-Wrapper (lock, logout, hibernate, reboot, shutdown)
- `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 mit absoluten Pfaden und 30s Timeout (lock, logout, hibernate, reboot, shutdown)
- `i18n.rs` — Locale-Erkennung und String-Tabellen (DE/EN)
- `config.rs` — TOML-Config + Wallpaper-Fallback
- `panel.rs` — GTK4 UI (Action-Buttons, Inline-Confirmation, WallpaperWindow)
- `main.rs`Entry Point, GTK App, Layer Shell Setup, Multi-Monitor
- `resources/style.css`Catppuccin Mocha Theme (aus Python-Version übernommen)
- `users.rs`User-Erkennung, Avatar-Loading (AccountsService, ~/.face, GResource-Fallback)
- `resources/style.css`GTK-Theme-Colors für Konsistenz mit dem aktiven Desktop-Theme
## 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
- **Niri-spezifischer Logout** (`niri msg action quit`): Moonarch setzt fest auf Niri
- **Einmal-Start per Keybind**: Kein Daemon, GTK `application_id` verhindert Doppelstart
- **System-Icons**: Adwaita/Catppuccin liefern alle benötigten symbolischen Icons
- **Lock ohne Confirmation**: Lock ist sofort reversibel, braucht kein Confirm
- **Icon-Scaling**: 22px Theme-Variante laden, auf 64px skalieren via GdkPixbuf
- **GResource-Bundle**: CSS, Wallpaper und Default-Avatar sind in die Binary kompiliert
- **Async Power Actions**: `glib::spawn_future_local` + `gio::spawn_blocking` statt raw Threads
- **Absolute Pfade für Binaries**: `/usr/bin/systemctl` etc. statt relativer Pfade (Security)
- **GResource-Bundle**: CSS und Default-Avatar sind in die Binary kompiliert (Wallpaper kommt vom Dateisystem)
- **Async Power Actions**: `glib::spawn_future_local` + `gio::spawn_blocking` mit 30s Timeout
- **Journal-Logging**: `systemd-journal-logger` statt File-Logging — `journalctl -t moonset`, Debug-Level per `MOONSET_DEBUG` Env-Var
Generated
+13 -176
View File
@@ -2,65 +2,6 @@
# It is not intended for manual editing.
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]]
name = "anyhow"
version = "1.0.102"
@@ -124,12 +65,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "dirs"
version = "6.0.0"
@@ -151,29 +86,6 @@ dependencies = [
"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]]
name = "equivalent"
version = "1.0.2"
@@ -642,42 +554,12 @@ dependencies = [
"serde_core",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "khronos_api"
version = "3.1.0"
@@ -734,19 +616,20 @@ dependencies = [
[[package]]
name = "moonset"
version = "0.1.0"
version = "0.7.2"
dependencies = [
"dirs",
"env_logger",
"gdk-pixbuf",
"gdk4",
"glib",
"glib-build-tools",
"graphene-rs",
"gtk4",
"gtk4-layer-shell",
"log",
"nix",
"serde",
"systemd-journal-logger",
"tempfile",
"toml 0.8.23",
]
@@ -769,12 +652,6 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -817,21 +694,6 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "prettyplease"
version = "0.2.37"
@@ -886,35 +748,6 @@ dependencies = [
"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]]
name = "rustc_version"
version = "0.4.1"
@@ -1040,6 +873,16 @@ dependencies = [
"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]]
name = "target-lexicon"
version = "0.13.3"
@@ -1192,12 +1035,6 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "version-compare"
version = "0.2.1"
+4 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "moonset"
version = "0.1.0"
version = "0.7.2"
edition = "2024"
description = "Wayland session power menu with GTK4 and Layer Shell"
license = "MIT"
@@ -14,9 +14,10 @@ gdk-pixbuf = "0.22"
toml = "0.8"
dirs = "6"
serde = { version = "1", features = ["derive"] }
nix = { version = "0.29", features = ["user"] }
nix = { version = "0.29", features = ["user", "signal"] }
graphene-rs = { version = "0.22", package = "graphene-rs" }
log = "0.4"
env_logger = "0.11"
systemd-journal-logger = "2.2"
[dev-dependencies]
tempfile = "3"
+80
View File
@@ -0,0 +1,80 @@
# Decisions
Architectural and design decisions for Moonset, in reverse chronological order.
## 2026-03-28 Remove wallpaper from GResource bundle
- **Who**: Ragnar, 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**: Ragnar, 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**: Hekate, 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**: Ragnar, 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**: Hekate, 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**: Hekate, 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**: Hekate, 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**: Hekate, 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**: Hekate, 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**: Hekate, 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**: Hekate, 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"])`.
+24 -24
View File
@@ -1,19 +1,19 @@
# Moonset
Wayland Session Power Menu für das Moonarch-Ökosystem.
Wayland Session Power Menu for the Moonarch ecosystem.
Per Keybind aufrufbares Fullscreen-Overlay mit 5 Aktionen:
A fullscreen overlay triggered by keybind with 5 actions:
**Lock** · **Logout** · **Hibernate** · **Reboot** · **Shutdown**
## Features
- Rust + gtk4-rs + gtk4-layer-shell (OVERLAY Layer — über Waybar)
- Catppuccin Mocha Theme
- Multi-Monitor-Support (Wallpaper auf Sekundärmonitoren)
- Inline-Confirmation für destruktive Aktionen
- Escape oder Hintergrund-Klick zum Schließen
- DE/EN Lokalisierung
- Konfigurierbare Wallpaper (TOML)
- Rust + gtk4-rs + gtk4-layer-shell (OVERLAY layer — above Waybar)
- Catppuccin Mocha theme
- Multi-monitor support (wallpaper on secondary monitors)
- Inline confirmation for destructive actions
- Escape or background click to dismiss
- DE/EN localization
- Configurable wallpaper (TOML)
## Installation
@@ -22,48 +22,48 @@ cargo build --release
install -Dm755 target/release/moonset /usr/bin/moonset
```
Oder via PKGBUILD:
Or via PKGBUILD:
```bash
cd pkg && makepkg -si
```
## Verwendung
## Usage
```bash
# Direkt starten
# Launch directly
moonset
# Per Niri-Keybind (in ~/.config/niri/config.kdl)
# Via Niri keybind (in ~/.config/niri/config.kdl)
# binds {
# Mod+Escape { spawn "moonset"; }
# }
```
## Konfiguration
## Configuration
Konfigurationsdatei: `~/.config/moonset/moonset.toml` oder `/etc/moonset/moonset.toml`
Config file: `~/.config/moonset/moonset.toml` or `/etc/moonset/moonset.toml`
```toml
# Pfad zum Hintergrundbild (optional)
# Path to background image (optional)
background_path = "/usr/share/moonarch/wallpaper.jpg"
```
Wallpaper-Fallback: Konfiguration`/usr/share/moonarch/wallpaper.jpg`eingebettetes Package-Wallpaper
Wallpaper fallback: config → `/usr/share/moonarch/wallpaper.jpg`bundled package wallpaper
## Entwicklung
## Development
```bash
# Tests
cargo test
# Release-Build
# Release build
cargo build --release
```
## Teil des Moonarch-Ökosystems
## Part of the Moonarch ecosystem
- **moonarch** — Reproduzierbares Arch-Linux-Setup
- **moongreet** — greetd Greeter für Wayland
- **moonlock** — Wayland Lockscreen
- **moonset** — Session Power Menu
- **moonarch** — Reproducible Arch Linux setup
- **moongreet** — greetd greeter for Wayland
- **moonlock** — Wayland lockscreen
- **moonset** — Session power menu
+3 -3
View File
@@ -1,6 +1,6 @@
# Moonset — Wayland Session Power Menu
# Konfigurationsdatei: ~/.config/moonset/moonset.toml oder /etc/moonset/moonset.toml
# Config file: ~/.config/moonset/moonset.toml or /etc/moonset/moonset.toml
# Pfad zum Hintergrundbild (optional)
# Fallback-Reihenfolge: config → /usr/share/moonarch/wallpaper.jpg → Package-Wallpaper
# Path to background image (optional)
# Fallback order: config → /usr/share/moonarch/wallpaper.jpg → bundled package wallpaper
# background_path = "/usr/share/moonarch/wallpaper.jpg"
-38
View File
@@ -1,38 +0,0 @@
# 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
-1
View File
@@ -2,7 +2,6 @@
<gresources>
<gresource prefix="/dev/moonarch/moonset">
<file>style.css</file>
<file>wallpaper.jpg</file>
<file>default-avatar.svg</file>
</gresource>
</gresources>
+15 -3
View File
@@ -6,16 +6,28 @@ window.panel {
background-color: @theme_bg_color;
background-size: cover;
background-position: center;
opacity: 0;
transition: opacity 250ms ease-in;
}
window.panel.visible {
opacity: 1;
}
/* Wallpaper-only window for secondary monitors */
window.wallpaper {
background-color: @theme_bg_color;
opacity: 0;
transition: opacity 250ms ease-in;
}
window.wallpaper.visible {
opacity: 1;
}
/* Round avatar image */
.avatar {
border-radius: 50%;
border-radius: 9999px;
min-width: 128px;
min-height: 128px;
background-color: @theme_selected_bg_color;
@@ -31,12 +43,12 @@ window.wallpaper {
margin-bottom: 40px;
}
/* Action button — square card */
/* Action button — circular card */
.action-button {
min-width: 120px;
min-height: 120px;
padding: 16px;
border-radius: 50%;
border-radius: 9999px;
background-color: alpha(@theme_base_color, 0.55);
color: @theme_fg_color;
border: none;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

+132 -17
View File
@@ -6,7 +6,6 @@ use std::fs;
use std::path::{Path, PathBuf};
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.
fn default_config_paths() -> Vec<PathBuf> {
@@ -21,6 +20,7 @@ fn default_config_paths() -> Vec<PathBuf> {
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Config {
pub background_path: Option<String>,
pub background_blur: Option<f32>,
}
/// Load config from TOML files. Later paths override earlier ones.
@@ -31,41 +31,61 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
let mut merged = Config::default();
for path in paths {
if let Ok(content) = fs::read_to_string(path) {
if let Ok(parsed) = toml::from_str::<Config>(&content) {
if parsed.background_path.is_some() {
merged.background_path = parsed.background_path;
match toml::from_str::<Config>(&content) {
Ok(parsed) => {
log::debug!("Config loaded: {}", path.display());
if parsed.background_path.is_some() {
merged.background_path = parsed.background_path;
}
if parsed.background_blur.is_some() {
merged.background_blur = parsed.background_blur;
}
}
Err(e) => {
log::warn!("Failed to parse {}: {e}", path.display());
}
}
}
}
// Validate blur range
if let Some(blur) = merged.background_blur {
if !blur.is_finite() || blur < 0.0 || blur > 200.0 {
log::warn!("Invalid background_blur value {blur}, ignoring");
merged.background_blur = None;
}
}
merged
}
/// Resolve the wallpaper path using the fallback hierarchy.
///
/// Priority: config background_path > Moonarch system default > gresource fallback.
pub fn resolve_background_path(config: &Config) -> PathBuf {
/// Priority: config background_path > Moonarch system default.
/// Returns None if no wallpaper is available (CSS background shows through).
pub fn resolve_background_path(config: &Config) -> Option<PathBuf> {
resolve_background_path_with(config, Path::new(MOONARCH_WALLPAPER))
}
/// Resolve with configurable moonarch wallpaper path (for testing).
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> PathBuf {
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> Option<PathBuf> {
// User-configured path
if let Some(ref bg) = config.background_path {
let path = PathBuf::from(bg);
if path.is_file() {
return path;
log::debug!("Wallpaper source: config ({})", path.display());
return Some(path);
}
}
// Moonarch ecosystem default
if moonarch_wallpaper.is_file() {
return moonarch_wallpaper.to_path_buf();
log::debug!("Wallpaper source: moonarch default ({})", moonarch_wallpaper.display());
return Some(moonarch_wallpaper.to_path_buf());
}
// GResource fallback path (loaded from compiled resources at runtime)
PathBuf::from(format!("{GRESOURCE_PREFIX}/wallpaper.jpg"))
log::debug!("No wallpaper found, using CSS background");
None
}
#[cfg(test)]
@@ -76,6 +96,7 @@ mod tests {
fn default_config_has_none_background() {
let config = Config::default();
assert!(config.background_path.is_none());
assert!(config.background_blur.is_none());
}
#[test]
@@ -95,6 +116,28 @@ mod tests {
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]
fn load_config_later_paths_override_earlier() {
let dir = tempfile::tempdir().unwrap();
@@ -124,10 +167,11 @@ mod tests {
fs::write(&wallpaper, "fake").unwrap();
let config = Config {
background_path: Some(wallpaper.to_str().unwrap().to_string()),
..Config::default()
};
assert_eq!(
resolve_background_path_with(&config, Path::new("/nonexistent")),
wallpaper
Some(wallpaper)
);
}
@@ -135,10 +179,10 @@ mod tests {
fn resolve_ignores_config_path_when_file_missing() {
let config = Config {
background_path: Some("/nonexistent/wallpaper.jpg".to_string()),
..Config::default()
};
let result = resolve_background_path_with(&config, Path::new("/nonexistent"));
// Falls through to gresource fallback
assert!(result.to_str().unwrap().contains("moonset"));
assert_eq!(result, None);
}
#[test]
@@ -147,13 +191,84 @@ mod tests {
let moonarch_wp = dir.path().join("wallpaper.jpg");
fs::write(&moonarch_wp, "fake").unwrap();
let config = Config::default();
assert_eq!(resolve_background_path_with(&config, &moonarch_wp), moonarch_wp);
assert_eq!(resolve_background_path_with(&config, &moonarch_wp), Some(moonarch_wp));
}
#[test]
fn resolve_uses_gresource_fallback_as_last_resort() {
fn resolve_returns_none_when_no_wallpaper_available() {
let config = Config::default();
let result = resolve_background_path_with(&config, Path::new("/nonexistent"));
assert!(result.to_str().unwrap().contains("wallpaper.jpg"));
assert_eq!(result, None);
}
#[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));
}
}
+50 -6
View File
@@ -112,15 +112,25 @@ fn read_lang_from_conf(path: &Path) -> Option<String> {
/// Determine the system language from LANG env var or /etc/locale.conf.
pub fn detect_locale() -> String {
let lang = env::var("LANG")
.ok()
.filter(|s| !s.is_empty())
.or_else(|| read_lang_from_conf(Path::new(DEFAULT_LOCALE_CONF)));
detect_locale_with(env::var("LANG").ok().as_deref(), Path::new(DEFAULT_LOCALE_CONF))
}
match lang {
/// Determine locale with configurable inputs (for testing).
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()), "LANG 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),
None => "en".to_string(),
}
};
log::debug!("Detected locale: {result} (source: {source})");
result
}
/// Return the string table for the given locale, defaulting to English.
@@ -259,6 +269,40 @@ 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]
fn error_messages_contain_failed() {
let s = load_strings(Some("en"));
+40 -12
View File
@@ -11,14 +11,17 @@ use gdk4 as gdk;
use gtk4::prelude::*;
use gtk4::{self as gtk, gio};
use gtk4_layer_shell::LayerShell;
use std::path::PathBuf;
pub(crate) const GRESOURCE_PREFIX: &str = "/dev/moonarch/moonset";
fn load_css(display: &gdk::Display) {
let css_provider = gtk::CssProvider::new();
css_provider.load_from_resource("/dev/moonarch/moonset/style.css");
css_provider.load_from_resource(&format!("{GRESOURCE_PREFIX}/style.css"));
gtk::style_context_add_provider_for_display(
display,
&css_provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
gtk::STYLE_PROVIDER_PRIORITY_USER,
);
}
@@ -40,7 +43,7 @@ fn setup_layer_shell(
window.set_anchor(gtk4_layer_shell::Edge::Right, true);
}
fn activate(app: &gtk::Application) {
fn activate(app: &gtk::Application, bg_path: &Option<PathBuf>, blur_radius: Option<f32>) {
let display = match gdk::Display::default() {
Some(d) => d,
None => {
@@ -51,12 +54,14 @@ fn activate(app: &gtk::Application) {
load_css(&display);
// Resolve wallpaper once, share across all windows
let config = config::load_config(None);
let bg_path = config::resolve_background_path(&config);
// Decode texture once (if wallpaper available), share across all windows.
// Blur is applied on the GPU via GskBlurNode at first widget realization,
// then cached and reused by all subsequent windows.
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)
let panel = panel::create_panel_window(&bg_path, app);
let panel = panel::create_panel_window(texture.as_ref(), blur_radius, &blur_cache, app);
setup_layer_shell(&panel, true, gtk4_layer_shell::Layer::Overlay);
panel.present();
@@ -64,7 +69,7 @@ fn activate(app: &gtk::Application) {
let monitors = display.monitors();
for i in 0..monitors.n_items() {
if let Some(monitor) = monitors.item(i).and_then(|obj| obj.downcast::<gdk::Monitor>().ok()) {
let wallpaper = panel::create_wallpaper_window(&bg_path, app);
let wallpaper = panel::create_wallpaper_window(texture.as_ref(), blur_radius, &blur_cache, app);
setup_layer_shell(&wallpaper, false, gtk4_layer_shell::Layer::Top);
wallpaper.set_monitor(Some(&monitor));
wallpaper.present();
@@ -72,19 +77,42 @@ fn activate(app: &gtk::Application) {
}
}
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}");
}
}
let level = if std::env::var("MOONSET_DEBUG").is_ok() {
log::LevelFilter::Debug
} else {
log::LevelFilter::Info
};
log::set_max_level(level);
}
fn main() {
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Info)
.init();
setup_logging();
log::info!("Moonset starting");
// Register compiled GResources
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()
.application_id("dev.moonarch.moonset")
.build();
app.connect_activate(activate);
app.connect_activate(move |app| activate(app, &bg_path, blur_radius));
app.run();
}
+180 -68
View File
@@ -9,6 +9,7 @@ use gtk4::{self as gtk, gio};
use std::cell::RefCell;
use std::path::Path;
use std::rc::Rc;
use std::time::Duration;
use crate::i18n::{load_strings, Strings};
use crate::power::{self, PowerError};
@@ -26,6 +27,7 @@ pub struct ActionDef {
pub label_attr: fn(&Strings) -> &'static str,
pub error_attr: fn(&Strings) -> &'static str,
pub confirm_attr: Option<fn(&Strings) -> &'static str>,
pub quit_after: bool,
}
/// All 5 power action definitions.
@@ -39,6 +41,7 @@ pub fn action_definitions() -> Vec<ActionDef> {
label_attr: |s| s.lock_label,
error_attr: |s| s.lock_failed,
confirm_attr: None,
quit_after: true,
},
ActionDef {
name: "logout",
@@ -48,6 +51,7 @@ pub fn action_definitions() -> Vec<ActionDef> {
label_attr: |s| s.logout_label,
error_attr: |s| s.logout_failed,
confirm_attr: Some(|s| s.logout_confirm),
quit_after: true,
},
ActionDef {
name: "hibernate",
@@ -57,6 +61,7 @@ pub fn action_definitions() -> Vec<ActionDef> {
label_attr: |s| s.hibernate_label,
error_attr: |s| s.hibernate_failed,
confirm_attr: Some(|s| s.hibernate_confirm),
quit_after: false,
},
ActionDef {
name: "reboot",
@@ -66,6 +71,7 @@ pub fn action_definitions() -> Vec<ActionDef> {
label_attr: |s| s.reboot_label,
error_attr: |s| s.reboot_failed,
confirm_attr: Some(|s| s.reboot_confirm),
quit_after: false,
},
ActionDef {
name: "shutdown",
@@ -75,19 +81,87 @@ pub fn action_definitions() -> Vec<ActionDef> {
label_attr: |s| s.shutdown_label,
error_attr: |s| s.shutdown_failed,
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 -------------------------------------------------
/// 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.
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 w = texture.width() as f32;
let h = texture.height() as f32;
// Padding must cover the blur kernel radius (typically ~3x sigma)
let pad = (sigma * 3.0).ceil();
let snapshot = gtk::Snapshot::new();
// Clip output to original texture size
snapshot.push_clip(&graphene_rs::Rect::new(pad, pad, w, h));
snapshot.push_blur(sigma as f64);
// Render texture with padding on all sides (edges repeat via oversized bounds)
snapshot.append_texture(texture, &graphene_rs::Rect::new(0.0, 0.0, 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.
pub fn create_wallpaper_window(bg_path: &Path, app: &gtk::Application) -> gtk::ApplicationWindow {
pub fn create_wallpaper_window(texture: Option<&gdk::Texture>, blur_radius: Option<f32>, blur_cache: &BlurCache, app: &gtk::Application) -> gtk::ApplicationWindow {
let window = gtk::ApplicationWindow::builder()
.application(app)
.build();
window.add_css_class("wallpaper");
let background = create_background_picture(bg_path);
window.set_child(Some(&background));
if let Some(texture) = texture {
let background = create_background_picture(texture, blur_radius, blur_cache);
window.set_child(Some(&background));
}
// Fade-in on map
window.connect_map(|w| {
@@ -104,7 +178,7 @@ pub fn create_wallpaper_window(bg_path: &Path, app: &gtk::Application) -> gtk::A
}
/// Create the main panel window with action buttons and confirm flow.
pub fn create_panel_window(bg_path: &Path, app: &gtk::Application) -> gtk::ApplicationWindow {
pub fn create_panel_window(texture: Option<&gdk::Texture>, blur_radius: Option<f32>, blur_cache: &BlurCache, app: &gtk::Application) -> gtk::ApplicationWindow {
let window = gtk::ApplicationWindow::builder()
.application(app)
.build();
@@ -115,8 +189,9 @@ pub fn create_panel_window(bg_path: &Path, app: &gtk::Application) -> gtk::Appli
username: "user".to_string(),
display_name: "User".to_string(),
home: dirs::home_dir().unwrap_or_default(),
uid: 0,
uid: u32::MAX,
});
log::debug!("User: {} ({})", user.display_name, user.username);
// State for confirm box
let confirm_box: Rc<RefCell<Option<gtk::Box>>> = Rc::new(RefCell::new(None));
@@ -125,9 +200,11 @@ pub fn create_panel_window(bg_path: &Path, app: &gtk::Application) -> gtk::Appli
let overlay = gtk::Overlay::new();
window.set_child(Some(&overlay));
// Background wallpaper
let background = create_background_picture(bg_path);
overlay.set_child(Some(&background));
// Background wallpaper (if available, otherwise CSS background shows through)
if let Some(texture) = texture {
let background = create_background_picture(texture, blur_radius, blur_cache);
overlay.set_child(Some(&background));
}
// Click on background dismisses the menu
let click_controller = gtk::GestureClick::new();
@@ -135,10 +212,10 @@ pub fn create_panel_window(bg_path: &Path, app: &gtk::Application) -> gtk::Appli
#[weak]
app,
move |_, _, _, _| {
app.quit();
fade_out_and_quit(&app);
}
));
background.add_controller(click_controller);
overlay.add_controller(click_controller);
// Centered content box
let content_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
@@ -157,13 +234,8 @@ pub fn create_panel_window(bg_path: &Path, app: &gtk::Application) -> gtk::Appli
avatar_frame.append(&avatar_image);
content_box.append(&avatar_frame);
// Load avatar
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);
}
// Load avatar (file-based avatars load asynchronously)
load_avatar_async(&avatar_image, &window, &user);
// Username label
let username_label = gtk::Label::new(Some(&user.display_name));
@@ -210,7 +282,7 @@ pub fn create_panel_window(bg_path: &Path, app: &gtk::Application) -> gtk::Appli
glib::Propagation::Proceed,
move |_, keyval, _, _| {
if keyval == gdk::Key::Escape {
app.quit();
fade_out_and_quit(&app);
glib::Propagation::Stop
} else {
glib::Propagation::Proceed
@@ -226,27 +298,49 @@ pub fn create_panel_window(bg_path: &Path, app: &gtk::Application) -> gtk::Appli
let bb = button_box_clone.clone();
glib::idle_add_local_once(move || {
w.add_css_class("visible");
glib::idle_add_local_once(move || {
if let Some(first) = bb.first_child() {
first.grab_focus();
}
});
if let Some(first) = bb.first_child() {
first.grab_focus();
}
});
});
window
}
/// Create a Picture widget for the wallpaper background.
fn create_background_picture(bg_path: &Path) -> gtk::Picture {
let background = if bg_path.starts_with("/dev/moonarch/moonset") {
gtk::Picture::for_resource(bg_path.to_str().unwrap_or(""))
} else {
gtk::Picture::for_filename(bg_path.to_str().unwrap_or(""))
};
/// Shared cache for the GPU-blurred wallpaper texture.
/// Computed once on first window realize, reused by all subsequent windows.
type BlurCache = Rc<RefCell<Option<gdk::Texture>>>;
/// Create a Picture widget for the wallpaper background, optionally with GPU blur.
/// When a blur_cache is provided, the blurred texture is computed once and shared.
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_hexpand(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
}
@@ -302,34 +396,9 @@ fn create_action_button(
button
}
/// Load a symbolic icon at 22px and scale to 64px via GdkPixbuf.
/// Load a symbolic icon using native GTK4 rendering at the target size.
fn load_scaled_icon(icon_name: &str) -> gtk::Image {
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));
let icon = gtk::Image::from_icon_name(icon_name);
icon.set_pixel_size(64);
icon
}
@@ -441,9 +510,11 @@ fn execute_action(
error_label: &gtk::Label,
) {
dismiss_confirm(confirm_area, confirm_box);
log::debug!("Executing power action: {}", action_def.name);
let action_fn = action_def.action_fn;
let action_name = action_def.name;
let quit_after = action_def.quit_after;
let error_message = (action_def.error_attr)(strings).to_string();
// Use glib::spawn_future_local + gio::spawn_blocking to avoid Send issues
@@ -459,9 +530,8 @@ fn execute_action(
match result {
Ok(Ok(())) => {
// Lock action: quit after successful execution
if action_name == "lock" {
app.quit();
if quit_after {
fade_out_and_quit(&app);
}
}
Ok(Err(e)) => {
@@ -479,15 +549,41 @@ fn execute_action(
));
}
/// Load an image file and set it as the avatar.
fn set_avatar_from_file(image: &gtk::Image, path: &Path) {
match Pixbuf::from_file_at_scale(path.to_str().unwrap_or(""), AVATAR_SIZE, AVATAR_SIZE, true) {
Ok(pixbuf) => {
let texture = gdk::Texture::for_pixbuf(&pixbuf);
image.set_paintable(Some(&texture));
/// Load the avatar asynchronously. File-based avatars are decoded off the UI thread.
fn load_avatar_async(image: &gtk::Image, window: &gtk::ApplicationWindow, user: &users::User) {
let avatar_path = users::get_avatar_path(&user.home, Some(&user.username));
match avatar_path {
Some(path) => {
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);
}
}
}
@@ -594,4 +690,20 @@ mod tests {
let confirm_fn = defs[1].confirm_attr.unwrap();
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);
}
}
}
+76 -22
View File
@@ -2,16 +2,17 @@
// ABOUTME: Wrappers around system commands for the session power menu.
use std::fmt;
use std::process::Command;
use std::io::Read;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
#[allow(dead_code)]
const POWER_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug)]
pub enum PowerError {
CommandFailed { action: &'static str, message: String },
#[allow(dead_code)]
Timeout { action: &'static str },
}
@@ -31,56 +32,109 @@ impl fmt::Display for PowerError {
impl std::error::Error for PowerError {}
/// 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> {
let child = Command::new(program)
log::debug!("Power action: {action} ({program} {args:?})");
let mut child = Command::new(program)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| PowerError::CommandFailed {
action,
message: e.to_string(),
})?;
let output = child
.wait_with_output()
.map_err(|e| PowerError::CommandFailed {
action,
message: e.to_string(),
})?;
let child_pid = nix::unistd::Pid::from_raw(child.id() as i32);
let done = Arc::new(AtomicBool::new(false));
let done_clone = done.clone();
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PowerError::CommandFailed {
let timeout_thread = std::thread::spawn(move || {
// Sleep in short intervals so we can exit early when the child finishes
let interval = Duration::from_millis(100);
let mut elapsed = Duration::ZERO;
while elapsed < POWER_TIMEOUT {
std::thread::sleep(interval);
if done_clone.load(Ordering::Relaxed) {
return;
}
elapsed += interval;
}
// ESRCH if the process already exited — harmless
let _ = nix::sys::signal::kill(child_pid, nix::sys::signal::Signal::SIGKILL);
});
let status = child.wait().map_err(|e| PowerError::CommandFailed {
action,
message: e.to_string(),
})?;
done.store(true, Ordering::Relaxed);
let _ = timeout_thread.join();
if status.success() {
log::debug!("Power action {action} completed");
Ok(())
} else {
// Check if killed by our timeout (SIGKILL = signal 9)
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if status.signal() == Some(9) {
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,
message: format!("exit code {}: {}", output.status, stderr.trim()),
});
message: format!("exit code {}: {}", status, stderr_buf.trim()),
})
}
Ok(())
}
/// 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> {
run_command("lock", "moonlock", &[])
log::debug!("Power action: lock (spawning moonlock)");
Command::new("/usr/bin/moonlock")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.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).
pub fn logout() -> Result<(), PowerError> {
run_command("logout", "niri", &["msg", "action", "quit"])
run_command("logout", "/usr/bin/niri", &["msg", "action", "quit"])
}
/// Hibernate the system via systemctl.
pub fn hibernate() -> Result<(), PowerError> {
run_command("hibernate", "systemctl", &["hibernate"])
run_command("hibernate", "/usr/bin/systemctl", &["hibernate"])
}
/// Reboot the system via loginctl.
pub fn reboot() -> Result<(), PowerError> {
run_command("reboot", "loginctl", &["reboot"])
run_command("reboot", "/usr/bin/loginctl", &["reboot"])
}
/// Shut down the system via loginctl.
pub fn shutdown() -> Result<(), PowerError> {
run_command("shutdown", "loginctl", &["poweroff"])
run_command("shutdown", "/usr/bin/loginctl", &["poweroff"])
}
#[cfg(test)]
+21 -9
View File
@@ -5,7 +5,6 @@ use nix::unistd::{getuid, User as NixUser};
use std::path::{Path, PathBuf};
const DEFAULT_ACCOUNTSSERVICE_DIR: &str = "/var/lib/AccountsService/icons";
const GRESOURCE_PREFIX: &str = "/dev/moonarch/moonset";
/// Represents the current user for the power menu.
#[derive(Debug, Clone)]
@@ -53,18 +52,27 @@ pub fn get_avatar_path_with(
username: Option<&str>,
accountsservice_dir: &Path,
) -> Option<PathBuf> {
// ~/.face takes priority
// ~/.face takes priority — canonicalize to resolve symlinks
let face = home.join(".face");
if face.exists() {
return Some(face);
if let Ok(canonical) = std::fs::canonicalize(&face) {
log::debug!("Avatar: using ~/.face ({})", canonical.display());
return Some(canonical);
}
// canonicalize failed (e.g. permissions) — skip rather than return unresolved symlink
log::warn!("Avatar: ~/.face exists but canonicalize failed, skipping");
}
// AccountsService icon
// AccountsService icon — also canonicalize for consistency
if let Some(name) = username {
if accountsservice_dir.exists() {
let icon = accountsservice_dir.join(name);
if icon.exists() {
return Some(icon);
if let Ok(canonical) = std::fs::canonicalize(&icon) {
log::debug!("Avatar: using AccountsService icon ({})", canonical.display());
return Some(canonical);
}
log::warn!("Avatar: AccountsService icon exists but canonicalize failed, skipping");
}
}
}
@@ -74,7 +82,8 @@ pub fn get_avatar_path_with(
/// Return the GResource path to the default avatar SVG.
pub fn get_default_avatar_path() -> String {
format!("{GRESOURCE_PREFIX}/default-avatar.svg")
let prefix = crate::GRESOURCE_PREFIX;
format!("{prefix}/default-avatar.svg")
}
#[cfg(test)]
@@ -98,7 +107,8 @@ mod tests {
let face = dir.path().join(".face");
fs::write(&face, "fake image").unwrap();
let path = get_avatar_path_with(dir.path(), None, Path::new("/nonexistent"));
assert_eq!(path, Some(face));
let expected = fs::canonicalize(&face).unwrap();
assert_eq!(path, Some(expected));
}
#[test]
@@ -109,7 +119,8 @@ mod tests {
let icon = icons_dir.join("testuser");
fs::write(&icon, "fake image").unwrap();
let path = get_avatar_path_with(dir.path(), Some("testuser"), &icons_dir);
assert_eq!(path, Some(icon));
let expected = fs::canonicalize(&icon).unwrap();
assert_eq!(path, Some(expected));
}
#[test]
@@ -122,7 +133,8 @@ mod tests {
let icon = icons_dir.join("testuser");
fs::write(&icon, "fake image").unwrap();
let path = get_avatar_path_with(dir.path(), Some("testuser"), &icons_dir);
assert_eq!(path, Some(face));
let expected = fs::canonicalize(&face).unwrap();
assert_eq!(path, Some(expected));
}
#[test]