12 Commits

Author SHA1 Message Date
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
15 changed files with 555 additions and 360 deletions
+75
View File
@@ -0,0 +1,75 @@
# Changelog
All notable changes to this project will be documented in this file.
Format based on [Keep a Changelog](https://keepachangelog.com/).
## [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 ## 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, wallpaper.jpg, default-avatar.svg) - `resources/` — GResource-Assets (style.css, wallpaper.jpg komprimiert, default-avatar.svg)
- `config/` — Beispiel-Konfigurationsdateien - `config/` — Beispiel-Konfigurationsdateien
## Kommandos ## Kommandos
@@ -35,20 +35,25 @@ LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonset
## Architektur ## 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) - `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)
- `main.rs`Entry Point, GTK App, Layer Shell Setup, Multi-Monitor - `users.rs`User-Erkennung, Avatar-Loading (AccountsService, ~/.face, GResource-Fallback)
- `resources/style.css`Catppuccin Mocha Theme (aus Python-Version übernommen) - `resources/style.css`GTK-Theme-Colors für Konsistenz mit dem aktiven Desktop-Theme
## 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
- **Icon-Scaling**: 22px Theme-Variante laden, auf 64px skalieren via GdkPixbuf - **Absolute Pfade für Binaries**: `/usr/bin/systemctl` etc. statt relativer Pfade (Security)
- **GResource-Bundle**: CSS, Wallpaper und Default-Avatar sind in die Binary kompiliert - **GResource-Bundle**: CSS, Wallpaper (komprimiert) und Default-Avatar sind in die Binary kompiliert
- **Async Power Actions**: `glib::spawn_future_local` + `gio::spawn_blocking` statt raw Threads - **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. # 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"
@@ -124,12 +65,6 @@ 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"
@@ -151,29 +86,6 @@ 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"
@@ -642,42 +554,12 @@ 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"
@@ -734,19 +616,20 @@ dependencies = [
[[package]] [[package]]
name = "moonset" name = "moonset"
version = "0.1.0" version = "0.5.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",
] ]
@@ -769,12 +652,6 @@ 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"
@@ -817,21 +694,6 @@ 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"
@@ -886,35 +748,6 @@ 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"
@@ -1040,6 +873,16 @@ 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"
@@ -1192,12 +1035,6 @@ 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"
+3 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "moonset" name = "moonset"
version = "0.1.0" version = "0.6.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"
@@ -15,8 +15,9 @@ toml = "0.8"
dirs = "6" dirs = "6"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
nix = { version = "0.29", features = ["user"] } nix = { version = "0.29", features = ["user"] }
graphene-rs = { version = "0.22", package = "graphene-rs" }
log = "0.4" log = "0.4"
env_logger = "0.11" systemd-journal-logger = "2.2"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
+73
View File
@@ -0,0 +1,73 @@
# Decisions
Architectural and design decisions for Moonset, in reverse chronological order.
## 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 # 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** **Lock** · **Logout** · **Hibernate** · **Reboot** · **Shutdown**
## Features ## Features
- Rust + gtk4-rs + gtk4-layer-shell (OVERLAY Layer — über Waybar) - Rust + gtk4-rs + gtk4-layer-shell (OVERLAY layer — above Waybar)
- Catppuccin Mocha Theme - Catppuccin Mocha theme
- Multi-Monitor-Support (Wallpaper auf Sekundärmonitoren) - Multi-monitor support (wallpaper on secondary monitors)
- Inline-Confirmation für destruktive Aktionen - Inline confirmation for destructive actions
- Escape oder Hintergrund-Klick zum Schließen - Escape or background click to dismiss
- DE/EN Lokalisierung - DE/EN localization
- Konfigurierbare Wallpaper (TOML) - Configurable 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
``` ```
Oder via PKGBUILD: Or via PKGBUILD:
```bash ```bash
cd pkg && makepkg -si cd pkg && makepkg -si
``` ```
## Verwendung ## Usage
```bash ```bash
# Direkt starten # Launch directly
moonset moonset
# Per Niri-Keybind (in ~/.config/niri/config.kdl) # Via Niri keybind (in ~/.config/niri/config.kdl)
# binds { # binds {
# Mod+Escape { spawn "moonset"; } # 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 ```toml
# Pfad zum Hintergrundbild (optional) # Path to background image (optional)
background_path = "/usr/share/moonarch/wallpaper.jpg" 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 ```bash
# Tests # Tests
cargo test cargo test
# Release-Build # Release build
cargo build --release cargo build --release
``` ```
## Teil des Moonarch-Ökosystems ## Part of the Moonarch ecosystem
- **moonarch** — Reproduzierbares Arch-Linux-Setup - **moonarch** — Reproducible Arch Linux setup
- **moongreet** — greetd Greeter für Wayland - **moongreet** — greetd greeter for Wayland
- **moonlock** — Wayland Lockscreen - **moonlock** — Wayland lockscreen
- **moonset** — Session Power Menu - **moonset** — Session power menu
+3 -3
View File
@@ -1,6 +1,6 @@
# Moonset — Wayland Session Power Menu # 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) # Path to background image (optional)
# Fallback-Reihenfolge: config → /usr/share/moonarch/wallpaper.jpg → Package-Wallpaper # Fallback order: config → /usr/share/moonarch/wallpaper.jpg → bundled package wallpaper
# background_path = "/usr/share/moonarch/wallpaper.jpg" # 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
+13 -1
View File
@@ -6,11 +6,23 @@ 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 */
@@ -31,7 +43,7 @@ window.wallpaper {
margin-bottom: 40px; margin-bottom: 40px;
} }
/* Action button — square card */ /* Action button — circular card */
.action-button { .action-button {
min-width: 120px; min-width: 120px;
min-height: 120px; min-height: 120px;
+72 -3
View File
@@ -6,7 +6,6 @@ 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> {
@@ -21,6 +20,7 @@ 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,10 +31,19 @@ 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) {
if let Ok(parsed) = toml::from_str::<Config>(&content) { match 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;
} }
if parsed.background_blur.is_some() {
merged.background_blur = parsed.background_blur;
}
}
Err(e) => {
log::warn!("Failed to parse {}: {e}", path.display());
}
} }
} }
} }
@@ -55,17 +64,21 @@ pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &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 path.is_file() { if path.is_file() {
log::debug!("Wallpaper source: config ({})", path.display());
return path; return path;
} }
} }
// Moonarch ecosystem default // Moonarch ecosystem default
if moonarch_wallpaper.is_file() { if moonarch_wallpaper.is_file() {
log::debug!("Wallpaper source: moonarch default ({})", moonarch_wallpaper.display());
return moonarch_wallpaper.to_path_buf(); return moonarch_wallpaper.to_path_buf();
} }
// GResource fallback path (loaded from compiled resources at runtime) // GResource fallback path (loaded from compiled resources at runtime)
PathBuf::from(format!("{GRESOURCE_PREFIX}/wallpaper.jpg")) let prefix = crate::GRESOURCE_PREFIX;
log::debug!("Wallpaper source: GResource fallback");
PathBuf::from(format!("{prefix}/wallpaper.jpg"))
} }
#[cfg(test)] #[cfg(test)]
@@ -76,6 +89,7 @@ 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]
@@ -95,6 +109,28 @@ 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();
@@ -124,6 +160,7 @@ 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")),
@@ -135,6 +172,7 @@ 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"));
// Falls through to gresource fallback // Falls through to gresource fallback
@@ -156,4 +194,35 @@ mod tests {
let result = resolve_background_path_with(&config, Path::new("/nonexistent")); let result = resolve_background_path_with(&config, Path::new("/nonexistent"));
assert!(result.to_str().unwrap().contains("wallpaper.jpg")); 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_accepts_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, Some(-5.0));
}
} }
+11 -6
View File
@@ -112,15 +112,20 @@ fn read_lang_from_conf(path: &Path) -> Option<String> {
/// Determine the system language from LANG env var or /etc/locale.conf. /// Determine the system language from LANG env var or /etc/locale.conf.
pub fn detect_locale() -> String { pub fn detect_locale() -> String {
let lang = env::var("LANG") let (raw, source) = if let Some(val) = env::var("LANG").ok().filter(|s| !s.is_empty()) {
.ok() (Some(val), "LANG env")
.filter(|s| !s.is_empty()) } else if let Some(val) = read_lang_from_conf(Path::new(DEFAULT_LOCALE_CONF)) {
.or_else(|| read_lang_from_conf(Path::new(DEFAULT_LOCALE_CONF))); (Some(val), "locale.conf")
} else {
(None, "default")
};
match lang { 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.
+30 -7
View File
@@ -12,9 +12,11 @@ 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;
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("/dev/moonarch/moonset/style.css"); css_provider.load_from_resource(&format!("{GRESOURCE_PREFIX}/style.css"));
gtk::style_context_add_provider_for_display( gtk::style_context_add_provider_for_display(
display, display,
&css_provider, &css_provider,
@@ -51,12 +53,16 @@ fn activate(app: &gtk::Application) {
load_css(&display); load_css(&display);
// Resolve wallpaper once, share across all windows // Resolve wallpaper once, decode texture once, 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 config = config::load_config(None); let config = config::load_config(None);
let bg_path = config::resolve_background_path(&config); let bg_path = config::resolve_background_path(&config);
let texture = panel::load_background_texture(&bg_path);
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(&bg_path, app); let panel = panel::create_panel_window(&texture, config.background_blur, &blur_cache, 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();
@@ -64,7 +70,7 @@ fn activate(app: &gtk::Application) {
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(&bg_path, app); let wallpaper = panel::create_wallpaper_window(&texture, config.background_blur, &blur_cache, 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();
@@ -72,10 +78,27 @@ 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() { fn main() {
env_logger::Builder::from_default_env() setup_logging();
.filter_level(log::LevelFilter::Info)
.init();
log::info!("Moonset starting"); log::info!("Moonset starting");
// Register compiled GResources // Register compiled GResources
+158 -61
View File
@@ -9,6 +9,7 @@ use gtk4::{self as gtk, gio};
use std::cell::RefCell; use std::cell::RefCell;
use std::path::Path; 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};
@@ -26,6 +27,7 @@ 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.
@@ -39,6 +41,7 @@ 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",
@@ -48,6 +51,7 @@ 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",
@@ -57,6 +61,7 @@ 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",
@@ -66,6 +71,7 @@ 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",
@@ -75,18 +81,73 @@ 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.
/// Blur is applied on the GPU via GskBlurNode at widget realization time.
pub fn load_background_texture(bg_path: &Path) -> gdk::Texture {
let fallback = format!("{}/wallpaper.jpg", crate::GRESOURCE_PREFIX);
log::debug!("Background: {}", bg_path.display());
if bg_path.starts_with(crate::GRESOURCE_PREFIX) {
let resource_path = bg_path.to_str().unwrap_or(&fallback);
gdk::Texture::from_resource(resource_path)
} else {
let file = gio::File::for_path(bg_path);
gdk::Texture::from_file(&file).unwrap_or_else(|_| {
gdk::Texture::from_resource(&fallback)
})
}
}
// -- GPU blur via GskBlurNode -------------------------------------------------
/// Render a blurred texture using the GPU via GskBlurNode.
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 snapshot = gtk::Snapshot::new();
let bounds = graphene_rs::Rect::new(
0.0, 0.0, texture.width() as f32, texture.height() as f32,
);
snapshot.push_blur(sigma as f64);
snapshot.append_texture(texture, &bounds);
snapshot.pop();
let node = snapshot.to_node()?;
Some(renderer.render_texture(&node, None))
}
/// 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(bg_path: &Path, app: &gtk::Application) -> gtk::ApplicationWindow { pub fn create_wallpaper_window(texture: &gdk::Texture, blur_radius: Option<f32>, blur_cache: &BlurCache, 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");
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
@@ -104,7 +165,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. /// 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: &gdk::Texture, blur_radius: Option<f32>, blur_cache: &BlurCache, app: &gtk::Application) -> gtk::ApplicationWindow {
let window = gtk::ApplicationWindow::builder() let window = gtk::ApplicationWindow::builder()
.application(app) .application(app)
.build(); .build();
@@ -115,8 +176,9 @@ pub fn create_panel_window(bg_path: &Path, app: &gtk::Application) -> gtk::Appli
username: "user".to_string(), username: "user".to_string(),
display_name: "User".to_string(), display_name: "User".to_string(),
home: dirs::home_dir().unwrap_or_default(), home: dirs::home_dir().unwrap_or_default(),
uid: 0, uid: u32::MAX,
}); });
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));
@@ -126,7 +188,7 @@ pub fn create_panel_window(bg_path: &Path, app: &gtk::Application) -> gtk::Appli
window.set_child(Some(&overlay)); window.set_child(Some(&overlay));
// Background wallpaper // Background wallpaper
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
@@ -135,7 +197,7 @@ pub fn create_panel_window(bg_path: &Path, app: &gtk::Application) -> gtk::Appli
#[weak] #[weak]
app, app,
move |_, _, _, _| { move |_, _, _, _| {
app.quit(); fade_out_and_quit(&app);
} }
)); ));
background.add_controller(click_controller); background.add_controller(click_controller);
@@ -157,13 +219,8 @@ pub fn create_panel_window(bg_path: &Path, app: &gtk::Application) -> gtk::Appli
avatar_frame.append(&avatar_image); avatar_frame.append(&avatar_image);
content_box.append(&avatar_frame); content_box.append(&avatar_frame);
// Load avatar // Load avatar (file-based avatars load asynchronously)
let avatar_path = users::get_avatar_path(&user.home, Some(&user.username)); load_avatar_async(&avatar_image, &window, &user);
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));
@@ -210,7 +267,7 @@ pub fn create_panel_window(bg_path: &Path, app: &gtk::Application) -> gtk::Appli
glib::Propagation::Proceed, glib::Propagation::Proceed,
move |_, keyval, _, _| { move |_, keyval, _, _| {
if keyval == gdk::Key::Escape { if keyval == gdk::Key::Escape {
app.quit(); fade_out_and_quit(&app);
glib::Propagation::Stop glib::Propagation::Stop
} else { } else {
glib::Propagation::Proceed glib::Propagation::Proceed
@@ -226,27 +283,49 @@ pub fn create_panel_window(bg_path: &Path, app: &gtk::Application) -> gtk::Appli
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
} }
/// Create a Picture widget for the wallpaper background. /// Shared cache for the GPU-blurred wallpaper texture.
fn create_background_picture(bg_path: &Path) -> gtk::Picture { /// Computed once on first window realize, reused by all subsequent windows.
let background = if bg_path.starts_with("/dev/moonarch/moonset") { type BlurCache = Rc<RefCell<Option<gdk::Texture>>>;
gtk::Picture::for_resource(bg_path.to_str().unwrap_or(""))
} else { /// Create a Picture widget for the wallpaper background, optionally with GPU blur.
gtk::Picture::for_filename(bg_path.to_str().unwrap_or("")) /// 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_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
} }
@@ -302,34 +381,9 @@ fn create_action_button(
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 { fn load_scaled_icon(icon_name: &str) -> gtk::Image {
let display = gdk::Display::default().unwrap(); let icon = gtk::Image::from_icon_name(icon_name);
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
} }
@@ -441,9 +495,11 @@ fn execute_action(
error_label: &gtk::Label, error_label: &gtk::Label,
) { ) {
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();
// Use glib::spawn_future_local + gio::spawn_blocking to avoid Send issues // Use glib::spawn_future_local + gio::spawn_blocking to avoid Send issues
@@ -459,9 +515,8 @@ fn execute_action(
match result { match result {
Ok(Ok(())) => { Ok(Ok(())) => {
// Lock action: quit after successful execution if quit_after {
if action_name == "lock" { fade_out_and_quit(&app);
app.quit();
} }
} }
Ok(Err(e)) => { Ok(Err(e)) => {
@@ -479,15 +534,41 @@ fn execute_action(
)); ));
} }
/// Load an image file and set it as the avatar. /// Load the avatar asynchronously. File-based avatars are decoded off the UI thread.
fn set_avatar_from_file(image: &gtk::Image, path: &Path) { fn load_avatar_async(image: &gtk::Image, window: &gtk::ApplicationWindow, user: &users::User) {
match Pixbuf::from_file_at_scale(path.to_str().unwrap_or(""), AVATAR_SIZE, AVATAR_SIZE, true) { let avatar_path = users::get_avatar_path(&user.home, Some(&user.username));
Ok(pixbuf) => {
let texture = gdk::Texture::for_pixbuf(&pixbuf); match avatar_path {
image.set_paintable(Some(&texture)); 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 +675,20 @@ 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);
}
}
} }
+56 -22
View File
@@ -2,16 +2,15 @@
// 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::process::Command; use std::io::Read;
use std::time::Duration; use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
#[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,55 +31,90 @@ 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.
fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(), PowerError> { 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) .args(args)
.stdout(Stdio::piped())
.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 output = child let deadline = Instant::now() + POWER_TIMEOUT;
.wait_with_output() loop {
.map_err(|e| PowerError::CommandFailed { match child.try_wait() {
action, Ok(Some(status)) => {
message: e.to_string(), if status.success() {
})?; log::debug!("Power action {action} completed");
}
if !output.status.success() { if !status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let mut stderr_buf = String::new();
if let Some(mut stderr) = child.stderr.take() {
let _ = stderr.read_to_string(&mut stderr_buf);
}
return Err(PowerError::CommandFailed { return Err(PowerError::CommandFailed {
action, action,
message: format!("exit code {}: {}", output.status, stderr.trim()), message: format!("exit code {}: {}", status, stderr_buf.trim()),
}); });
} }
return Ok(());
Ok(()) }
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Err(PowerError::Timeout { action });
}
std::thread::sleep(Duration::from_millis(100));
}
Err(e) => {
return Err(PowerError::CommandFailed {
action,
message: e.to_string(),
});
}
}
}
} }
/// 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> {
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). /// Quit the Niri compositor (logout).
pub fn logout() -> Result<(), PowerError> { 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. /// Hibernate the system via systemctl.
pub fn hibernate() -> Result<(), PowerError> { pub fn hibernate() -> Result<(), PowerError> {
run_command("hibernate", "systemctl", &["hibernate"]) run_command("hibernate", "/usr/bin/systemctl", &["hibernate"])
} }
/// Reboot the system via loginctl. /// Reboot the system via loginctl.
pub fn reboot() -> Result<(), PowerError> { pub fn reboot() -> Result<(), PowerError> {
run_command("reboot", "loginctl", &["reboot"]) run_command("reboot", "/usr/bin/loginctl", &["reboot"])
} }
/// Shut down the system via loginctl. /// Shut down the system via loginctl.
pub fn shutdown() -> Result<(), PowerError> { pub fn shutdown() -> Result<(), PowerError> {
run_command("shutdown", "loginctl", &["poweroff"]) run_command("shutdown", "/usr/bin/loginctl", &["poweroff"])
} }
#[cfg(test)] #[cfg(test)]
+4 -2
View File
@@ -5,7 +5,6 @@ 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)]
@@ -56,6 +55,7 @@ pub fn get_avatar_path_with(
// ~/.face takes priority // ~/.face takes priority
let face = home.join(".face"); let face = home.join(".face");
if face.exists() { if face.exists() {
log::debug!("Avatar: using ~/.face");
return Some(face); return Some(face);
} }
@@ -64,6 +64,7 @@ pub fn get_avatar_path_with(
if accountsservice_dir.exists() { if accountsservice_dir.exists() {
let icon = accountsservice_dir.join(name); let icon = accountsservice_dir.join(name);
if icon.exists() { if icon.exists() {
log::debug!("Avatar: using AccountsService icon");
return Some(icon); return Some(icon);
} }
} }
@@ -74,7 +75,8 @@ 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 {
format!("{GRESOURCE_PREFIX}/default-avatar.svg") let prefix = crate::GRESOURCE_PREFIX;
format!("{prefix}/default-avatar.svg")
} }
#[cfg(test)] #[cfg(test)]