23 Commits

Author SHA1 Message Date
nevaforget 510d45a9b1 fix: audit LOW fixes — rejection-path tests, wallpaper-fallback docs (v0.9.1)
Update PKGBUILD version / update-pkgver (push) Successful in 4s
- Test AccountsService-icon symlink rejection (users.rs)
- Tests for wallpaper symlink/extension/size rejection (config.rs)
- Fix stale 'bundled package wallpaper' fallback docs (README, example config) — bundled tier removed 2026-03-28, actual chain is two-tier
2026-06-17 13:06:15 +02:00
nevaforget 7dae48f6cc feat(power): logout via loginctl, not Niri-locked
Default `loginctl terminate-session $XDG_SESSION_ID`; `logout_command`
TOML override for other compositors.
2026-06-17 12:40:24 +02:00
nevaforget 115cfe6bb1 docs: translate CLAUDE.md to English
Per the committed=English rule.
2026-06-16 10:46:13 +02:00
nevaforget 05ddbdc81e ci: switch update-pkgver to tag-trigger (no-suffix pkgname) 2026-06-10 18:41:35 +02:00
nevaforget 8285bcdf44 fix: audit LOW fixes — dead uid, home_dir warn, clippy sweep, debug value (v0.8.5)
Update PKGBUILD version / update-pkgver (push) Successful in 2s
- users::User: drop the unused `uid` field and its getuid() assignment.
  The compiler dead_code warning is gone, and the synthetic `u32::MAX`
  sentinel in the panel fallback is obsolete too.
- panel: surface a log::warn! when dirs::home_dir() returns None instead
  of silently falling back to an empty PathBuf that would make avatars
  look for .face in the current working directory.
- Apply three clippy suggestions: two collapsible if-let + && chains in
  users::get_avatar_path_with and config::resolve_background_path_with,
  and a redundant closure in panel::execute_action's spawn_blocking.
- main: require MOONSET_DEBUG=1 to escalate log verbosity — mere
  presence of the var must not dump path info into the journal.
2026-04-24 14:14:11 +02:00
nevaforget 0789e8fc27 fix: audit MEDIUM fixes — timeout guard, POSIX locale, button gate, wallpaper allowlist (v0.8.4)
- power: RAII DoneGuard sets done=true on every wait() exit path, so the
  timeout thread no longer sleeps its full 30 s holding a spawn_blocking
  slot when child.wait() errors. A separate timed_out AtomicBool marks
  our own SIGKILL so we do not misclassify an external OOM-kill. Memory
  ordering on the flags is now Release/Acquire.
- i18n: detect_locale now reads LC_ALL, LC_MESSAGES, LANG in POSIX
  priority order before falling back to /etc/locale.conf, so systems
  installed in English with LC_ALL=de_DE.UTF-8 pick up the correct UI.
- panel: execute_action desensitizes button_box on entry and re-enables
  it on error paths, so double-click or keyboard repeat cannot fire the
  same power action twice.
- config: accept_wallpaper helper applies an extension allowlist (jpg,
  jpeg, png, webp) plus symlink rejection and a 10 MB size cap, applied
  to both the user-configured path and the Moonarch ecosystem fallback.
  Bounds worst-case decode latency and narrows the gdk-pixbuf parser
  attack surface.
2026-04-24 13:49:48 +02:00
nevaforget 13b5ac1704 fix: audit fix — avoid latent stdout pipe deadlock in run_command (v0.8.3)
Piping stdout without draining while blocking in child.wait() risks deadlock
if a command writes more than one OS pipe buffer (~64 KB on Linux). Current
callers (systemctl, niri msg, loginctl) stay well under that, but the
structure was fragile. stdout is now discarded; stderr continues to be
captured for error reporting.
2026-04-24 13:01:48 +02:00
nevaforget a47fdff1dd docs: drop Hekate persona, unify attribution on ClaudeCode
Remove the Hekate persona block from CLAUDE.md and rewrite prior
DECISIONS entries from Hekate and leftover Ragnar to ClaudeCode
for consistency with the rest of the ecosystem.
2026-04-21 09:03:22 +02:00
nevaforget d030f1360a fix: restore keyboard focus on action buttons after dismiss (v0.8.2)
Update PKGBUILD version / update-pkgver (push) Successful in 2s
After cancelling a confirmation prompt, the focused widget was removed
from the tree without restoring focus to the action buttons. With
layer-shell exclusive keyboard mode, GTK does not recover focus
automatically — the UI became keyboard-unreachable.
2026-04-06 22:36:36 +02:00
nevaforget e97535e41b Remove unnecessary pacman git install from CI workflow
Git is already available in the runner image.
2026-04-02 08:28:08 +02:00
nevaforget b518572d0f Revert CI workaround: remove pacman install step
Update PKGBUILD version / update-pkgver (push) Failing after 0s
The act_runner now uses a custom Arch-based image with git
pre-installed, so per-workflow installs are no longer needed.
2026-04-01 16:17:48 +02:00
nevaforget b3ed7fb292 chore: update Cargo.lock for v0.8.1
Update PKGBUILD version / update-pkgver (push) Successful in 2s
2026-03-31 12:53:20 +02:00
nevaforget 358c228645 fix: audit fixes — release profile, GResource compression, lock stderr, sync markers (v0.8.1)
Update PKGBUILD version / update-pkgver (push) Successful in 1s
- Add [profile.release] with LTO, codegen-units=1, strip
- Add compressed="true" to GResource CSS/SVG entries
- Inherit moonlock stderr instead of /dev/null (errors visible in journal)
- Add SYNC comments to duplicated blur/background functions
2026-03-31 11:08:43 +02:00
nevaforget a4564f2b71 docs: add v0.8.0 changelog entry, fix build.rs comment
CHANGELOG was missing the v0.8.0 entry (symlink-safe avatars, blur
downscale + padding fix, config validation). build.rs comment still
referenced removed wallpaper.jpg.
2026-03-31 09:36:01 +02:00
nevaforget 8aca2bf331 fix: audit fixes — symlink-safe avatars, blur downscale + padding, config validation (v0.8.0)
Update PKGBUILD version / update-pkgver (push) Successful in 2s
- Replace canonicalize() with symlink_metadata + is_file + !is_symlink for avatar
  lookup (prevents symlink traversal to arbitrary files)
- Fix blur padding offset from (0,0) to (-pad,-pad) to prevent edge darkening
- Add MAX_BLUR_DIMENSION (1920px) downscale before GPU blur
- Validate blur per config source (invalid user value preserves system default)
- Wallpaper: use symlink_metadata + is_file + !is_symlink in resolve_background_path
2026-03-30 16:08:50 +02:00
nevaforget f01c6bd25d ci: also update .SRCINFO in pkgver workflow
Update PKGBUILD version / update-pkgver (push) Successful in 1s
paru reads .SRCINFO (not PKGBUILD) for version comparison during
sysupgrade. Without updating .SRCINFO, paru never detects upgrades
for PKGBUILD repository packages.
2026-03-30 13:49:16 +02:00
nevaforget 7cd1f8cb6d ci: replace actions/checkout with plain git clone (no node needed)
Update PKGBUILD version / update-pkgver (push) Successful in 2s
2026-03-29 23:02:58 +02:00
nevaforget c22bc5bca1 ci: use moonarch runner label instead of ubuntu-latest
Update PKGBUILD version / update-pkgver (push) Failing after 18s
2026-03-29 23:02:09 +02:00
nevaforget 069387761b ci: add workflow to auto-update pkgver in moonarch-pkgbuilds
Update PKGBUILD version / update-pkgver (push) Failing after 1m23s
2026-03-29 22:55:48 +02:00
nevaforget e59ed53d7a fix: use systemctl for reboot/shutdown — loginctl lacks these verbs (v0.7.3) 2026-03-29 18:59:00 +02:00
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
19 changed files with 813 additions and 195 deletions
+43
View File
@@ -0,0 +1,43 @@
# ABOUTME: Updates pkgver in moonarch-pkgbuilds when a new moonset tag is pushed.
# ABOUTME: Reads the latest version tag and bumps the PKGBUILD + .SRCINFO.
name: Update PKGBUILD version
on:
push:
tags:
- 'v*'
jobs:
update-pkgver:
runs-on: moonarch
steps:
- name: Determine pkgver from latest tag
run: |
git clone --bare http://gitea:3000/nevaforget/moonset.git source.git
cd source.git
PKGVER=$(git describe --tags --abbrev=0 | sed 's/^v//')
echo "New pkgver: $PKGVER"
echo "$PKGVER" > /tmp/pkgver
- name: Update PKGBUILD
run: |
PKGVER=$(cat /tmp/pkgver)
git clone http://gitea:3000/nevaforget/moonarch-pkgbuilds.git pkgbuilds
cd pkgbuilds
OLD_VER=$(grep '^pkgver=' moonset/PKGBUILD | cut -d= -f2)
if [ "$OLD_VER" = "$PKGVER" ]; then
echo "pkgver already up to date ($PKGVER)"
exit 0
fi
sed -i "s/^pkgver=.*/pkgver=$PKGVER/" moonset/PKGBUILD
sed -i "s/^\tpkgver = .*/\tpkgver = $PKGVER/" moonset/.SRCINFO
echo "Updated pkgver: $OLD_VER → $PKGVER"
git config user.name "pkgver-bot"
git config user.email "gitea@moonarch.de"
git add moonset/PKGBUILD moonset/.SRCINFO
git commit -m "chore(moonset): bump pkgver to $PKGVER"
git -c http.extraHeader="Authorization: token ${{ secrets.PKGBUILD_TOKEN }}" push
+117
View File
@@ -3,6 +3,123 @@
All notable changes to this project will be documented in this file.
Format based on [Keep a Changelog](https://keepachangelog.com/).
## [0.9.1] - 2026-06-17
### Fixed
- Wallpaper fallback docs (README, example config) referenced a removed "bundled package wallpaper" tier; corrected to two-tier (config → moonarch default → CSS background)
### Added
- Tests for avatar/wallpaper rejection paths (AccountsService symlink, wallpaper symlink/extension/size)
## [0.9.0] - 2026-06-17
### Changed
- Logout is no longer Niri-locked — default terminates the logind session via `loginctl terminate-session $XDG_SESSION_ID` (compositor-agnostic)
### Added
- `logout_command` config key — override the logout command (e.g. `niri msg action quit`) for non-logind or compositor-specific setups
## [0.8.5] - 2026-04-24
### Changed
- Require `MOONSET_DEBUG=1` (not mere presence of the variable) to raise log verbosity, so path information is not written to the journal by accident
### Fixed
- Warn when the home directory cannot be resolved instead of silently searching for `.face` in the current working directory
### Removed
- Drop the unused `uid` field from `users::User` and its obsolete `u32::MAX` fallback sentinel
## [0.8.4] - 2026-04-24
### Security
- Restrict wallpaper paths to an image-extension allowlist (jpg, jpeg, png, webp), reject symlinks, and cap file size at 10 MB — narrows the gdk-pixbuf parser attack surface
### Fixed
- Free the `spawn_blocking` slot on every `wait()` exit path (RAII guard) so a failed `wait()` no longer pins a thread for the full 30 s timeout; distinguish the timeout's own SIGKILL from an external OOM-kill
- Honor POSIX locale precedence (`LC_ALL``LC_MESSAGES``LANG`) before `/etc/locale.conf`, so an English install with `LC_ALL=de_DE.UTF-8` shows the German UI
- Disable the action buttons while an action runs, so a double-click or key repeat cannot trigger the same power action twice
## [0.8.3] - 2026-04-24
### Fixed
- Discard child stdout in `run_command` to remove a latent pipe-deadlock risk when a command writes more than one OS pipe buffer (~64 KB); stderr is still captured for error reporting
## [0.8.2] - 2026-04-06
### Fixed
- Restore keyboard focus to the action buttons after cancelling a confirmation — under layer-shell exclusive keyboard mode the menu otherwise became keyboard-unreachable
## [0.8.1] - 2026-03-31
### Changed
- Optimize the release profile (LTO, single codegen unit, symbol stripping)
- Compress the bundled GResource assets (CSS, SVG)
- Inherit moonlock's stderr so its errors surface in the journal instead of being discarded
## [0.8.0] - 2026-03-30
### Changed
- Replace `canonicalize()` with `symlink_metadata` + `is_file` + `!is_symlink` for avatar lookup — prevents symlink traversal to arbitrary files
- Replace `canonicalize()` with same symlink-safe check in `resolve_background_path`
- Downscale wallpaper to `MAX_BLUR_DIMENSION` (1920px) before GPU blur — prevents excessive memory use on high-res images
- Validate `background_blur` per config source — invalid user value preserves system default instead of silently falling back to 0
### Fixed
- Fix blur padding offset from `(0,0)` to `(-pad,-pad)` to prevent edge darkening on blurred wallpaper
## [0.7.3] - 2026-03-29
### Fixed
- Fix shutdown and reboot — `loginctl` does not support `poweroff`/`reboot` verbs, switched to `systemctl poweroff` and `systemctl reboot`
## [0.7.2] - 2026-03-29
### Fixed
- Fix CSS priority so app styles override GTK4 user theme (Colloid-Catppuccin) — use `STYLE_PROVIDER_PRIORITY_USER` instead of `STYLE_PROVIDER_PRIORITY_APPLICATION`
- Replace `border-radius: 50%` with `9999px` — GTK4 CSS does not reliably support percentage-based border-radius
## [0.7.1] - 2026-03-28
### Fixed
- Fix edge darkening on blurred wallpaper — GskBlurNode sampled transparent pixels outside texture bounds, now renders with 3x-sigma padding and crops back
## [0.7.0] - 2026-03-28
### Added
- Blur validation: `background_blur` must be 0.0200.0 (negative, NaN, infinite, and extreme values are rejected with a warning)
- `detect_locale_with()` testable DI function for locale detection (4 new tests)
- Path canonicalization for `~/.face` and AccountsService avatar paths (resolves symlinks, prevents passing arbitrary files to gdk-pixbuf)
### Changed
- Replace busy-loop polling (`try_wait` + `sleep(100ms)`) in `run_command` with blocking `child.wait()` + timeout thread — eliminates poll latency and thread waste
- Move config loading from `activate()` to `main()` — filesystem I/O no longer blocks the GTK main loop
- Click-to-dismiss now attached to overlay instead of background picture (works with or without wallpaper)
### Removed
- Embedded fallback wallpaper from GResource bundle — moonarch provides `/usr/share/moonarch/wallpaper.jpg` at install time, binary size dropped from ~3.2MB to ~1.3MB
- GResource fallback path in `resolve_background_path` — returns `Option<PathBuf>` now, `None` falls through to CSS background
## [0.6.0] - 2026-03-28
### Added
+17 -47
View File
@@ -1,59 +1,29 @@
# Moonset
**Name**: Hekate (Göttin der Wegkreuzungen — passend zum Power-Menu, das den Weg der Session bestimmt)
Wayland session power menu, part of the Moonarch ecosystem.
Keybind-invoked overlay with 5 actions: Lock, Logout, Hibernate, Reboot, Shutdown.
## Projekt
Stack: Rust / gtk4-rs / gtk4-layer-shell (OVERLAY layer). Versions live in `Cargo.toml`.
Moonset ist ein Wayland Session Power Menu, gebaut mit Rust + gtk4-rs + gtk4-layer-shell.
Teil des Moonarch-Ökosystems. Per Keybind aufrufbares Overlay mit 5 Aktionen:
Lock, Logout, Hibernate, Reboot, Shutdown.
## Tech-Stack
- Rust (Edition 2024), gtk4-rs 0.11, glib 0.22
- gtk4-layer-shell 0.8 für Wayland Layer Shell (OVERLAY Layer)
- `cargo test` für Unit-Tests
## Projektstruktur
- `src/` — Rust-Quellcode (main.rs, power.rs, i18n.rs, config.rs, users.rs, panel.rs)
- `resources/` — GResource-Assets (style.css, wallpaper.jpg komprimiert, default-avatar.svg)
- `config/` — Beispiel-Konfigurationsdateien
## Kommandos
## Commands
```bash
# Tests ausführen
cargo test
# Release-Build
cargo build --release
# Power-Menu starten (in Niri-Session)
LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonset
cargo test # unit tests
cargo build --release # release build
LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonset # run (in Niri)
```
## Architektur
## Source (`src/`)
- `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)
- `users.rs`User-Erkennung, Avatar-Loading (AccountsService, ~/.face, GResource-Fallback)
- `resources/style.css` — GTK-Theme-Colors für Konsistenz mit dem aktiven Desktop-Theme
- `main.rs`entry point, GTK app, Layer Shell, multi-monitor, journal logging (`MOONSET_DEBUG`), `GRESOURCE_PREFIX`
- `power.rs` — 5 power-action wrappers (absolute paths, 30s timeout)
- `panel.rs`GTK4 UI (action buttons, inline confirmation, WallpaperWindow)
- `users.rs`user detection, avatar loading (AccountsService, ~/.face, GResource fallback)
- `config.rs`TOML config + wallpaper fallback
- `i18n.rs`locale detection, DE/EN string tables
## Design Decisions
`resources/` holds the GResource bundle (style.css, default-avatar.svg); `config/` holds example configs.
Siehe `DECISIONS.md` für das vollständige Entscheidungsprotokoll.
## Decisions
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
- **Absolute Pfade für Binaries**: `/usr/bin/systemctl` etc. statt relativer Pfade (Security)
- **GResource-Bundle**: CSS, Wallpaper (komprimiert) und Default-Avatar sind in die Binary kompiliert
- **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
See `DECISIONS.md` for the full decision log (layer choice, Niri logout, async power actions, journal logging, …).
Generated
+1 -1
View File
@@ -616,7 +616,7 @@ dependencies = [
[[package]]
name = "moonset"
version = "0.5.0"
version = "0.9.1"
dependencies = [
"dirs",
"gdk-pixbuf",
+7 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "moonset"
version = "0.6.0"
version = "0.9.1"
edition = "2024"
description = "Wayland session power menu with GTK4 and Layer Shell"
license = "MIT"
@@ -14,7 +14,7 @@ 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"
systemd-journal-logger = "2.2"
@@ -22,5 +22,10 @@ systemd-journal-logger = "2.2"
[dev-dependencies]
tempfile = "3"
[profile.release]
lto = "thin"
codegen-units = 1
strip = true
[build-dependencies]
glib-build-tools = "0.22"
+53 -10
View File
@@ -2,72 +2,115 @@
Architectural and design decisions for Moonset, in reverse chronological order.
## 2026-04-24 Audit LOW fixes: dead uid field, home_dir warn, clippy sweep, debug value (v0.8.5)
- **Who**: ClaudeCode, Dom
- **Why**: Five LOW findings cleared in one pass. (1) `User::uid` was populated from `getuid()` but never read — a compiler `dead_code` warning for a field on the public API. (2) Falling back to a synthetic user when `get_current_user()` returned None used `uid: u32::MAX`, an undocumented sentinel that became moot once uid was removed. (3) `dirs::home_dir().unwrap_or_default()` silently yielded `PathBuf::new()` on failure; avatars would then look for `.face` in the current working directory. (4) `cargo clippy` flagged three suggestions (two collapsible `if`, one redundant closure) that had crept in. (5) `MOONSET_DEBUG` promoted log verbosity on mere presence, leaking path information into the journal.
- **Tradeoffs**: Dropping `uid` from `User` is a minor API break for any internal caller expecting the field — none existed. The synthetic fallback now surfaces `log::warn!` when home resolution fails, which should be rare outside of pathological sandbox environments.
- **How**: (1) Remove `pub uid: u32` from `User` and the `uid: uid.as_raw()` assignment in `get_current_user`. (2) Panel fallback drops the `uid` field entirely. (3) `dirs::home_dir().unwrap_or_else(|| { log::warn!(...); PathBuf::new() })`. (4) `cargo clippy --fix` for the two collapsible ifs, manual collapse of `if-let` + `&&` chain, redundant closure replaced with the function itself. (5) `MOONSET_DEBUG` now requires the literal value `"1"` to escalate to Debug.
## 2026-04-24 Audit MEDIUM fixes: timeout guard, POSIX locale, button desensitize, wallpaper allowlist (v0.8.4)
- **Who**: ClaudeCode, Dom
- **Why**: Five MEDIUM findings: (1) `run_command`'s timeout thread leaked a 30 s gio::spawn_blocking slot if `child.wait()` errored, because `done.store(true)` ran after the `?`. (2) Timeout detection compared `status.signal() == Some(9)` — a hardcoded signal number that also misclassifies OOM-killer SIGKILL as our timeout. (3) `execute_action` never desensitized the button_box, so a double-click or accidental keyboard repeat fired the action twice. (4) `detect_locale` read only `LANG`, ignoring POSIX priority order (`LC_ALL` > `LC_MESSAGES` > `LANG`) — a common dual-language setup picked the wrong UI language. (5) The wallpaper path was passed to gdk-pixbuf without extension or size restriction, widening the image-parser attack surface and allowing unbounded decode latency.
- **Tradeoffs**: The extension allowlist (`jpg`, `jpeg`, `png`, `webp`) rejects exotic formats users might have used before. The 10 MB size cap rejects uncompressed/high-quality 4K wallpapers; acceptable for a power menu. Memory ordering on the `done` flag is now `Release`/`Acquire` instead of `Relaxed` — no runtime cost but correct across threads.
- **How**: (1) RAII `DoneGuard` struct sets `done.store(true, Release)` in its `Drop`, so the flag fires on every function exit path. A second `timed_out` AtomicBool distinguishes our SIGKILL from an external one. (2) Replace `Some(9)` with the `timed_out` flag check. (3) `execute_action` now takes `button_box: &gtk::Box`, calls `set_sensitive(false)` on entry and re-enables it on error paths; success paths that quit skip the re-enable. All call sites updated. (4) `detect_locale` reads `LC_ALL`, `LC_MESSAGES`, `LANG` in order, picking the first non-empty value before falling back to `/etc/locale.conf`. (5) `accept_wallpaper` helper applies extension allowlist + symlink rejection + `MAX_WALLPAPER_FILE_SIZE = 10 MB`, and is called for both config-path and Moonarch fallback.
## 2026-04-24 Audit fix: avoid latent stdout pipe deadlock in run_command (v0.8.3)
- **Who**: ClaudeCode, Dom
- **Why**: Audit found that `run_command` piped the child's `stdout` but never drained it, then called blocking `child.wait()`. A child writing more than one OS pipe buffer (~64 KB on Linux) would block on `write()` while the parent blocked in `wait()` — classic pipe deadlock, broken only by the 30 s SIGKILL timeout. Current callers (`systemctl`, `niri msg`, `loginctl`) do not emit that much output, but the structure was fragile and would bite on any future command or changed behaviour.
- **Tradeoffs**: stdout is now fully discarded. If a future caller needs stdout, it will have to drain it concurrently with `wait()` (separate reader thread).
- **How**: Replace `.stdout(Stdio::piped())` with `.stdout(Stdio::null())` in `run_command`. `stderr` stays piped — it is drained after `wait()`, which is safe because `wait()` already reaped the child and no further writes can occur.
## 2026-03-31 Fourth audit: release profile, GResource compression, lock stderr, sync markers
- **Who**: ClaudeCode, Dom
- **Why**: Fourth triple audit found missing release profile (LTO/strip), uncompressed GResource assets, moonlock stderr suppressed (errors invisible), and duplicated code without sync markers.
- **Tradeoffs**: moonlock stderr now inherited instead of null — errors appear in moonset's journal context. Acceptable for debugging, no security leak since moonlock logs to its own journal identifier.
- **How**: (1) `[profile.release]` with LTO, codegen-units=1, strip. (2) `compressed="true"` on GResource entries. (3) `Stdio::inherit()` for moonlock stderr in lock(). (4) SYNC comments on duplicated blur/background functions.
## 2026-03-28 Remove wallpaper from GResource bundle
- **Who**: ClaudeCode, Dom
- **Why**: All three Moon projects (moonset, moongreet, moonlock) embedded a 374kB fallback wallpaper in the binary via GResource. Moonarch already installs `/usr/share/moonarch/wallpaper.jpg` at system setup time, making the embedded fallback unnecessary dead weight (~2MB in binary size).
- **Tradeoffs**: If `/usr/share/moonarch/wallpaper.jpg` is missing and no user config exists, moonset shows a solid CSS background instead of a wallpaper. Acceptable — the power menu is functional without a wallpaper image.
- **How**: Removed `wallpaper.jpg` from GResource XML and resources directory. `resolve_background_path` returns `Option<PathBuf>`. All wallpaper-related functions handle `None` gracefully. Binary size dropped from ~3.2MB to ~1.3MB.
## 2026-03-28 Switch from env_logger to systemd-journal-logger
- **Who**: Ragnar, Dom
- **Who**: ClaudeCode, Dom
- **Why**: moonlock and moongreet already use systemd-journal-logger. moonset used env_logger which writes to stderr — not useful for a GUI app launched via keybind. Journal integration enables `journalctl -t moonset` and consistent troubleshooting across all three Moon projects.
- **Tradeoffs**: Requires systemd at runtime. Graceful fallback to eprintln if journal logger fails. Acceptable since Moonarch targets systemd-based Arch Linux.
- **How**: Replace `env_logger` dep with `systemd-journal-logger`, add `setup_logging()` with `MOONSET_DEBUG` env var for debug-level output. Same pattern as moonlock/moongreet.
## 2026-03-28 Replace action name dispatch with `quit_after` field
- **Who**: Hekate, Dom
- **Who**: ClaudeCode, Dom
- **Why**: Post-action behavior (quit the app or not) was controlled by comparing `action_name == "lock"` — a magic string duplicated from the action definition. Renaming an action would silently break the dispatch.
- **Tradeoffs**: Adds a field to `ActionDef` that most actions set to `false`. Acceptable because it makes the contract explicit and testable.
- **How**: `ActionDef.quit_after: bool``true` for lock and logout, `false` for hibernate/reboot/shutdown.
## 2026-03-28 GPU blur via GskBlurNode replaces CPU blur
- **Who**: Ragnar, Dom
- **Who**: ClaudeCode, Dom
- **Why**: CPU-side Gaussian blur (`image` crate) blocked startup and added caching complexity. moonlock already migrated to GPU blur.
- **Tradeoffs**: GPU blur quality is slightly different (box-blur approximation vs true Gaussian), acceptable for wallpaper backgrounds. Removes `image` crate dependency entirely. No disk cache needed.
- **How**: `Snapshot::push_blur()` + `GskRenderer::render_texture()` on `connect_realize`. Blur happens once on the GPU when the widget gets its renderer. Symmetric with moonlock and moongreet.
## 2026-03-28 Use absolute paths for system binaries
- **Who**: Hekate, Dom
- **Who**: ClaudeCode, Dom
- **Why**: Security audit flagged PATH hijacking risk — relative binary names allow a malicious `$PATH` entry to intercept `systemctl`, `loginctl`, etc.
- **Tradeoffs**: Hardcoded paths reduce portability to non-Arch distros where binaries may live elsewhere (e.g. `/sbin/`). Acceptable because Moonarch targets Arch Linux exclusively.
- **How**: All five power action wrappers now use `/usr/bin/` prefixed paths.
## 2026-03-28 Implement power action timeout via try_wait polling
- **Who**: Hekate, Dom
- **Who**: ClaudeCode, Dom
- **Why**: `POWER_TIMEOUT` and `PowerError::Timeout` were declared but never wired up. A hanging `systemctl hibernate` (e.g. blocked NFS mount) would freeze the power menu indefinitely.
- **Tradeoffs**: Polling with `try_wait()` + 100ms sleep is slightly less efficient than a dedicated timeout crate, but avoids adding a dependency for a single use case.
- **How**: `run_command` now polls `child.try_wait()` against a 30s deadline, kills the child on timeout.
## 2026-03-28 Centralize GRESOURCE_PREFIX
- **Who**: Hekate, Dom
- **Who**: ClaudeCode, Dom
- **Why**: The string `/dev/moonarch/moonset` was duplicated in `config.rs`, `users.rs`, and as literal strings in `panel.rs` and `main.rs`. Changing the application ID would require edits in 4+ locations.
- **Tradeoffs**: Modules now depend on `crate::GRESOURCE_PREFIX` — tighter coupling to main.rs, but acceptable for an internal constant.
- **How**: Single `pub(crate) const GRESOURCE_PREFIX` in `main.rs`, referenced everywhere else.
## 2026-03-28 Remove journal.md
- **Who**: Hekate, Dom
- **Who**: ClaudeCode, Dom
- **Why**: One-time development notes from the Rust rewrite, never updated after initial session. Overlapped with memory system and git history.
- **Tradeoffs**: Historical context lost from the file, but the information is preserved in git history and the memory system.
- **How**: Deleted. Useful technical learnings migrated to persistent memory.
## 2026-03-27 OVERLAY layer instead of TOP
- **Who**: Hekate, Dom
- **Who**: ClaudeCode, Dom
- **Why**: Waybar occupies the TOP layer. The power menu must appear above it.
- **Tradeoffs**: OVERLAY is the highest layer — nothing can render above moonset while it's open. This is intentional for a session power menu.
- **How**: `setup_layer_shell` uses `gtk4_layer_shell::Layer::Overlay` for the panel window.
## 2026-03-27 Lock without confirmation
- **Who**: Hekate, Dom
- **Who**: ClaudeCode, Dom
- **Why**: Lock is immediately reversible (just unlock). All other actions (logout, hibernate, reboot, shutdown) are destructive or disruptive.
- **Tradeoffs**: One less click for the most common action. Risk of accidental lock is negligible since unlocking is trivial.
- **How**: `ActionDef.needs_confirm = false` for lock; all others require inline confirmation.
## 2026-03-27 Niri-specific logout via `niri msg action quit`
- **Who**: Hekate, Dom
- **Who**: ClaudeCode, Dom
- **Why**: Moonarch is built exclusively for the Niri compositor. Generic Wayland logout mechanisms don't exist — each compositor has its own.
- **Tradeoffs**: Hard dependency on Niri. If the compositor changes, `power::logout()` must be updated.
- **How**: `Command::new("/usr/bin/niri").args(["msg", "action", "quit"])`.
- **Superseded by** the 2026-06-17 entry below.
## 2026-06-17 Compositor-agnostic logout via `loginctl terminate-session`
- **Who**: ClaudeCode, Dom
- **Why**: Reverses the 2026-03-27 decision. Hardcoding `niri msg action quit` made logout the only compositor-locked action (lock/hibernate/reboot/shutdown were already agnostic via moonlock/systemctl). There is no Wayland protocol for "end session", but systemd-logind sits one layer below and offers a portable mechanism.
- **Tradeoffs**: `loginctl terminate-session` SIGTERMs all session processes rather than letting the compositor quit itself — functionally equivalent for logout. Requires a logind session (verified: greetd's PAM chain loads `pam_systemd`, which sets `XDG_SESSION_ID`). Niri's own quit command remains available via the override.
- **How**: `power::logout()` resolves a command — TOML `logout_command` override (space-separated) if set, else `/usr/bin/loginctl terminate-session $XDG_SESSION_ID`. Errors with a hint to set `logout_command` when no session id is present.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Dominik Kressler
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+16 -1
View File
@@ -15,6 +15,13 @@ A fullscreen overlay triggered by keybind with 5 actions:
- DE/EN localization
- Configurable wallpaper (TOML)
## Requirements
- A Wayland compositor with `wlr-layer-shell` (Moonarch targets **Niri**)
- A systemd-logind session for the default logout (`loginctl terminate-session`); other setups can override via `logout_command`
- Runtime: `gtk4`, `gtk4-layer-shell`
- Build: `cargo`, `git`
## Installation
```bash
@@ -47,9 +54,13 @@ Config file: `~/.config/moonset/moonset.toml` or `/etc/moonset/moonset.toml`
```toml
# Path to background image (optional)
background_path = "/usr/share/moonarch/wallpaper.jpg"
# Logout command override (optional)
# Default: loginctl terminate-session $XDG_SESSION_ID
logout_command = "niri msg action quit"
```
Wallpaper fallback: config → `/usr/share/moonarch/wallpaper.jpg`bundled package wallpaper
Wallpaper fallback: config → `/usr/share/moonarch/wallpaper.jpg`CSS background (no image)
## Development
@@ -67,3 +78,7 @@ cargo build --release
- **moongreet** — greetd greeter for Wayland
- **moonlock** — Wayland lockscreen
- **moonset** — Session power menu
## License
MIT
+1 -1
View File
@@ -1,5 +1,5 @@
// ABOUTME: Build script for compiling GResource bundle.
// ABOUTME: Bundles style.css, wallpaper.jpg, and default-avatar.svg into the binary.
// ABOUTME: Bundles style.css and default-avatar.svg into the binary.
fn main() {
glib_build_tools::compile_resources(
+7 -1
View File
@@ -2,5 +2,11 @@
# Config file: ~/.config/moonset/moonset.toml or /etc/moonset/moonset.toml
# Path to background image (optional)
# Fallback order: config → /usr/share/moonarch/wallpaper.jpg → bundled package wallpaper
# Fallback order: config → /usr/share/moonarch/wallpaper.jpg → CSS background (no image)
# background_path = "/usr/share/moonarch/wallpaper.jpg"
# Logout command override (optional, space-separated program + args)
# Default: terminate the logind session via `loginctl terminate-session $XDG_SESSION_ID`
# (compositor-agnostic). Set this only for non-logind setups or to delegate
# logout to the compositor.
# logout_command = "niri msg action quit"
+2 -3
View File
@@ -1,8 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/dev/moonarch/moonset">
<file>style.css</file>
<file>wallpaper.jpg</file>
<file>default-avatar.svg</file>
<file compressed="true">style.css</file>
<file compressed="true">default-avatar.svg</file>
</gresource>
</gresources>
+2 -2
View File
@@ -27,7 +27,7 @@ window.wallpaper.visible {
/* Round avatar image */
.avatar {
border-radius: 50%;
border-radius: 9999px;
min-width: 128px;
min-height: 128px;
background-color: @theme_selected_bg_color;
@@ -48,7 +48,7 @@ window.wallpaper.visible {
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

+155 -22
View File
@@ -21,6 +21,9 @@ fn default_config_paths() -> Vec<PathBuf> {
pub struct Config {
pub background_path: Option<String>,
pub background_blur: Option<f32>,
/// Override for the logout command (space-separated program + args).
/// When unset, logout terminates the logind session via `loginctl`.
pub logout_command: Option<String>,
}
/// Load config from TOML files. Later paths override earlier ones.
@@ -37,8 +40,14 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
if parsed.background_path.is_some() {
merged.background_path = parsed.background_path;
}
if parsed.background_blur.is_some() {
if parsed.logout_command.is_some() {
merged.logout_command = parsed.logout_command;
}
// Validate blur per source — invalid values preserve the previous default
if parsed.background_blur.is_some_and(|b| b.is_finite() && (0.0..=200.0).contains(&b)) {
merged.background_blur = parsed.background_blur;
} else if parsed.background_blur.is_some() {
log::warn!("Invalid background_blur in {}, ignoring", path.display());
}
}
Err(e) => {
@@ -53,32 +62,68 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
/// 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))
}
/// Wallpapers are passed to gdk-pixbuf's image loader; restrict to common image
/// extensions to reduce the parser-attack surface for user-controlled paths.
const ALLOWED_BG_EXT: &[&str] = &["jpg", "jpeg", "png", "webp"];
/// Bound wallpaper decode latency (10 MB covers typical 4K JPEGs at Q95).
const MAX_WALLPAPER_FILE_SIZE: u64 = 10 * 1024 * 1024;
fn is_allowed_wallpaper(path: &Path) -> bool {
match path.extension().and_then(|e| e.to_str()) {
Some(ext) => ALLOWED_BG_EXT.iter().any(|a| a.eq_ignore_ascii_case(ext)),
None => false,
}
}
fn accept_wallpaper(path: &Path) -> bool {
if !is_allowed_wallpaper(path) {
log::warn!("Wallpaper rejected (extension not in allowlist): {}", path.display());
return false;
}
match path.symlink_metadata() {
Ok(meta) if meta.file_type().is_symlink() => {
log::warn!("Wallpaper rejected (symlink): {}", path.display());
false
}
Ok(meta) if !meta.is_file() => false,
Ok(meta) if meta.len() > MAX_WALLPAPER_FILE_SIZE => {
log::warn!(
"Wallpaper rejected ({} bytes > {} limit): {}",
meta.len(), MAX_WALLPAPER_FILE_SIZE, path.display()
);
false
}
Ok(_) => true,
Err(_) => false,
}
}
/// Resolve with configurable moonarch wallpaper path (for testing).
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> PathBuf {
// User-configured path
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> Option<PathBuf> {
// User-configured path — reject symlinks, non-image extensions, and oversized files
if let Some(ref bg) = config.background_path {
let path = PathBuf::from(bg);
if path.is_file() {
if accept_wallpaper(&path) {
log::debug!("Wallpaper source: config ({})", path.display());
return path;
return Some(path);
}
}
// Moonarch ecosystem default
if moonarch_wallpaper.is_file() {
// Moonarch ecosystem default — apply the same checks for consistency
if accept_wallpaper(moonarch_wallpaper) {
log::debug!("Wallpaper source: moonarch default ({})", moonarch_wallpaper.display());
return moonarch_wallpaper.to_path_buf();
return Some(moonarch_wallpaper.to_path_buf());
}
// GResource fallback path (loaded from compiled resources at runtime)
let prefix = crate::GRESOURCE_PREFIX;
log::debug!("Wallpaper source: GResource fallback");
PathBuf::from(format!("{prefix}/wallpaper.jpg"))
log::debug!("No wallpaper found, using CSS background");
None
}
#[cfg(test)]
@@ -90,6 +135,17 @@ mod tests {
let config = Config::default();
assert!(config.background_path.is_none());
assert!(config.background_blur.is_none());
assert!(config.logout_command.is_none());
}
#[test]
fn load_config_reads_logout_command() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moonset.toml");
fs::write(&conf, "logout_command = \"niri msg action quit\"\n").unwrap();
let paths = vec![conf];
let config = load_config(Some(&paths));
assert_eq!(config.logout_command.as_deref(), Some("niri msg action quit"));
}
#[test]
@@ -164,7 +220,7 @@ mod tests {
};
assert_eq!(
resolve_background_path_with(&config, Path::new("/nonexistent")),
wallpaper
Some(wallpaper)
);
}
@@ -175,8 +231,7 @@ mod tests {
..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]
@@ -185,14 +240,52 @@ 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 resolve_rejects_symlinked_config_wallpaper() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("real.jpg");
fs::write(&target, "fake").unwrap();
let link = dir.path().join("link.jpg");
std::os::unix::fs::symlink(&target, &link).unwrap();
let config = Config {
background_path: Some(link.to_str().unwrap().to_string()),
..Config::default()
};
assert_eq!(resolve_background_path_with(&config, Path::new("/nonexistent")), None);
}
#[test]
fn resolve_rejects_disallowed_extension() {
let dir = tempfile::tempdir().unwrap();
let wp = dir.path().join("wallpaper.bmp");
fs::write(&wp, "fake").unwrap();
let config = Config {
background_path: Some(wp.to_str().unwrap().to_string()),
..Config::default()
};
assert_eq!(resolve_background_path_with(&config, Path::new("/nonexistent")), None);
}
#[test]
fn resolve_rejects_oversized_wallpaper() {
let dir = tempfile::tempdir().unwrap();
let wp = dir.path().join("huge.jpg");
fs::write(&wp, vec![0u8; (MAX_WALLPAPER_FILE_SIZE + 1) as usize]).unwrap();
let config = Config {
background_path: Some(wp.to_str().unwrap().to_string()),
..Config::default()
};
assert_eq!(resolve_background_path_with(&config, Path::new("/nonexistent")), None);
}
#[test]
@@ -217,12 +310,52 @@ mod tests {
}
#[test]
fn load_config_accepts_negative_blur() {
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, Some(-5.0));
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 -4
View File
@@ -110,11 +110,23 @@ fn read_lang_from_conf(path: &Path) -> Option<String> {
None
}
/// Determine the system language from LANG env var or /etc/locale.conf.
/// Determine the system language from POSIX locale env vars or /etc/locale.conf.
/// Checks LC_ALL, LC_MESSAGES, LANG in POSIX priority order (LC_ALL overrides
/// everything; LC_MESSAGES overrides LANG for text categories).
pub fn detect_locale() -> String {
let (raw, source) = if let Some(val) = env::var("LANG").ok().filter(|s| !s.is_empty()) {
(Some(val), "LANG env")
} else if let Some(val) = read_lang_from_conf(Path::new(DEFAULT_LOCALE_CONF)) {
let env_val = env::var("LC_ALL")
.ok()
.filter(|s| !s.is_empty())
.or_else(|| env::var("LC_MESSAGES").ok().filter(|s| !s.is_empty()))
.or_else(|| env::var("LANG").ok().filter(|s| !s.is_empty()));
detect_locale_with(env_val.as_deref(), Path::new(DEFAULT_LOCALE_CONF))
}
/// Determine locale with configurable inputs (for testing).
pub fn detect_locale_with(env_lang: Option<&str>, locale_conf_path: &Path) -> String {
let (raw, source) = if let Some(val) = env_lang.filter(|s| !s.is_empty()) {
(Some(val.to_string()), "env")
} else if let Some(val) = read_lang_from_conf(locale_conf_path) {
(Some(val), "locale.conf")
} else {
(None, "default")
@@ -264,6 +276,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"));
+20 -13
View File
@@ -11,6 +11,7 @@ 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";
@@ -20,7 +21,7 @@ fn load_css(display: &gdk::Display) {
gtk::style_context_add_provider_for_display(
display,
&css_provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
gtk::STYLE_PROVIDER_PRIORITY_USER,
);
}
@@ -42,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 => {
@@ -53,16 +54,14 @@ fn activate(app: &gtk::Application) {
load_css(&display);
// Resolve wallpaper once, decode texture once, share across all windows.
// 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 config = config::load_config(None);
let bg_path = config::resolve_background_path(&config);
let texture = panel::load_background_texture(&bg_path);
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(&texture, config.background_blur, &blur_cache, 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();
@@ -70,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(&texture, config.background_blur, &blur_cache, 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();
@@ -89,10 +88,12 @@ fn setup_logging() {
eprintln!("Failed to create journal logger: {e}");
}
}
let level = if std::env::var("MOONSET_DEBUG").is_ok() {
log::LevelFilter::Debug
} else {
log::LevelFilter::Info
// Require MOONSET_DEBUG=1 to raise verbosity so mere presence (empty
// value in a session script) cannot escalate journal noise with path
// information an attacker could use.
let level = match std::env::var("MOONSET_DEBUG").ok().as_deref() {
Some("1") => log::LevelFilter::Debug,
_ => log::LevelFilter::Info,
};
log::set_max_level(level);
}
@@ -104,10 +105,16 @@ fn main() {
// 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();
}
+105 -36
View File
@@ -7,7 +7,7 @@ use glib::clone;
use gtk4::prelude::*;
use gtk4::{self as gtk, gio};
use std::cell::RefCell;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::time::Duration;
@@ -87,25 +87,38 @@ pub fn action_definitions() -> Vec<ActionDef> {
}
/// 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);
/// 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());
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)
})
let file = gio::File::for_path(bg_path);
match gdk::Texture::from_file(&file) {
Ok(texture) => Some(texture),
Err(e) => {
log::warn!("Failed to load wallpaper {}: {e}", bg_path.display());
None
}
}
}
// -- GPU blur via GskBlurNode -------------------------------------------------
// SYNC: MAX_BLUR_DIMENSION, render_blurred_texture, and create_background_picture
// are duplicated in moonlock/src/lockscreen.rs and moongreet/src/greeter.rs.
// Changes here must be mirrored to the other two projects.
/// Maximum texture dimension before downscaling for blur.
/// Keeps GPU work reasonable on 4K+ displays.
const MAX_BLUR_DIMENSION: f32 = 1920.0;
/// Render a blurred texture using the GPU via GskBlurNode.
///
/// To avoid edge darkening (blur samples transparent pixels outside bounds),
/// the texture is rendered with padding equal to 3x the blur sigma. The blur
/// is applied to the padded area, then cropped back to the original size.
///
/// Large textures (> MAX_BLUR_DIMENSION) are downscaled before blurring to
/// reduce GPU work. The sigma is scaled proportionally.
fn render_blurred_texture(
widget: &impl IsA<gtk::Widget>,
texture: &gdk::Texture,
@@ -113,15 +126,36 @@ fn render_blurred_texture(
) -> Option<gdk::Texture> {
let native = widget.native()?;
let renderer = native.renderer()?;
let orig_w = texture.width() as f32;
let orig_h = texture.height() as f32;
// Downscale large textures to reduce GPU blur work
let max_dim = orig_w.max(orig_h);
let scale = if max_dim > MAX_BLUR_DIMENSION {
MAX_BLUR_DIMENSION / max_dim
} else {
1.0
};
let w = (orig_w * scale).round();
let h = (orig_h * scale).round();
let scaled_sigma = sigma * scale;
// Padding must cover the blur kernel radius (typically ~3x sigma)
let pad = (scaled_sigma * 3.0).ceil();
let snapshot = gtk::Snapshot::new();
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();
// Clip output to scaled texture size
snapshot.push_clip(&graphene_rs::Rect::new(pad, pad, w, h));
snapshot.push_blur(scaled_sigma as f64);
// Render texture with padding on all sides (edges repeat via oversized bounds)
snapshot.append_texture(texture, &graphene_rs::Rect::new(-pad, -pad, w + 2.0 * pad, h + 2.0 * pad));
snapshot.pop(); // blur
snapshot.pop(); // clip
let node = snapshot.to_node()?;
Some(renderer.render_texture(&node, None))
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.
@@ -141,14 +175,16 @@ pub fn new_blur_cache() -> BlurCache {
}
/// Create a wallpaper-only window for secondary monitors.
pub fn create_wallpaper_window(texture: &gdk::Texture, blur_radius: Option<f32>, blur_cache: &BlurCache, 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(texture, blur_radius, blur_cache);
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| {
@@ -165,18 +201,23 @@ pub fn create_wallpaper_window(texture: &gdk::Texture, blur_radius: Option<f32>,
}
/// Create the main panel window with action buttons and confirm flow.
pub fn create_panel_window(texture: &gdk::Texture, blur_radius: Option<f32>, blur_cache: &BlurCache, 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();
window.add_css_class("panel");
let strings = load_strings(None);
let user = users::get_current_user().unwrap_or_else(|| users::User {
username: "user".to_string(),
display_name: "User".to_string(),
home: dirs::home_dir().unwrap_or_default(),
uid: u32::MAX,
let user = users::get_current_user().unwrap_or_else(|| {
let home = dirs::home_dir().unwrap_or_else(|| {
log::warn!("Could not resolve HOME — using an empty path");
PathBuf::new()
});
users::User {
username: "user".to_string(),
display_name: "User".to_string(),
home,
}
});
log::debug!("User: {} ({})", user.display_name, user.username);
@@ -187,9 +228,11 @@ pub fn create_panel_window(texture: &gdk::Texture, blur_radius: Option<f32>, blu
let overlay = gtk::Overlay::new();
window.set_child(Some(&overlay));
// Background wallpaper
let background = create_background_picture(texture, blur_radius, blur_cache);
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();
@@ -200,7 +243,7 @@ pub fn create_panel_window(texture: &gdk::Texture, blur_radius: Option<f32>, blu
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);
@@ -254,6 +297,7 @@ pub fn create_panel_window(texture: &gdk::Texture, blur_radius: Option<f32>, blu
&confirm_area,
&confirm_box,
&error_label,
&button_box,
);
button_box.append(&button);
}
@@ -337,6 +381,7 @@ fn create_action_button(
confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label,
button_box: &gtk::Box,
) -> gtk::Button {
let button_content = gtk::Box::new(gtk::Orientation::Vertical, 4);
button_content.set_halign(gtk::Align::Center);
@@ -366,6 +411,8 @@ fn create_action_button(
confirm_box,
#[weak]
error_label,
#[weak]
button_box,
move |_| {
on_action_clicked(
&action_def,
@@ -374,6 +421,7 @@ fn create_action_button(
&confirm_area,
&confirm_box,
&error_label,
&button_box,
);
}
));
@@ -396,16 +444,17 @@ fn on_action_clicked(
confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label,
button_box: &gtk::Box,
) {
dismiss_confirm(confirm_area, confirm_box);
error_label.set_visible(false);
if !action_def.needs_confirm {
execute_action(action_def, strings, app, confirm_area, confirm_box, error_label);
execute_action(action_def, strings, app, confirm_area, confirm_box, error_label, button_box);
return;
}
show_confirm(action_def, strings, app, confirm_area, confirm_box, error_label);
show_confirm(action_def, strings, app, confirm_area, confirm_box, error_label, button_box);
}
/// Show inline confirmation below the action buttons.
@@ -416,6 +465,7 @@ fn show_confirm(
confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label,
button_box: &gtk::Box,
) {
let new_box = gtk::Box::new(gtk::Orientation::Vertical, 8);
new_box.set_halign(gtk::Align::Center);
@@ -443,6 +493,8 @@ fn show_confirm(
confirm_box,
#[weak]
error_label,
#[weak]
button_box,
move |_| {
execute_action(
&action_def_clone,
@@ -451,6 +503,7 @@ fn show_confirm(
&confirm_area,
&confirm_box,
&error_label,
&button_box,
);
}
));
@@ -463,8 +516,13 @@ fn show_confirm(
confirm_area,
#[strong]
confirm_box,
#[weak]
button_box,
move |_| {
dismiss_confirm(&confirm_area, &confirm_box);
if let Some(first) = button_box.first_child() {
first.grab_focus();
}
}
));
button_row.append(&no_btn);
@@ -493,6 +551,7 @@ fn execute_action(
confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label,
button_box: &gtk::Box,
) {
dismiss_confirm(confirm_area, confirm_box);
log::debug!("Executing power action: {}", action_def.name);
@@ -502,6 +561,10 @@ fn execute_action(
let quit_after = action_def.quit_after;
let error_message = (action_def.error_attr)(strings).to_string();
// Desensitize buttons so a double-click or accidental keyboard repeat
// cannot fire the same action twice while it is in flight.
button_box.set_sensitive(false);
// Use glib::spawn_future_local + gio::spawn_blocking to avoid Send issues
// with GTK objects. The blocking closure runs on a thread pool, the result
// is handled back on the main thread.
@@ -510,24 +573,30 @@ fn execute_action(
app,
#[weak]
error_label,
#[weak]
button_box,
async move {
let result = gio::spawn_blocking(move || action_fn()).await;
let result = gio::spawn_blocking(action_fn).await;
match result {
Ok(Ok(())) => {
if quit_after {
fade_out_and_quit(&app);
} else {
button_box.set_sensitive(true);
}
}
Ok(Err(e)) => {
log::error!("Power action '{}' failed: {}", action_name, e);
error_label.set_text(&error_message);
error_label.set_visible(true);
button_box.set_sensitive(true);
}
Err(_) => {
log::error!("Power action '{}' panicked", action_name);
error_label.set_text(&error_message);
error_label.set_visible(true);
button_box.set_sensitive(true);
}
}
}
+153 -41
View File
@@ -4,7 +4,9 @@
use std::fmt;
use std::io::Read;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
const POWER_TIMEOUT: Duration = Duration::from_secs(30);
@@ -30,11 +32,17 @@ 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> {
log::debug!("Power action: {action} ({program} {args:?})");
let mut child = Command::new(program)
.args(args)
.stdout(Stdio::piped())
// stdout is discarded — piping without draining would deadlock if a
// command ever wrote more than one OS pipe buffer before wait() returned.
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| PowerError::CommandFailed {
@@ -42,40 +50,60 @@ fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(),
message: e.to_string(),
})?;
let deadline = Instant::now() + POWER_TIMEOUT;
loop {
match child.try_wait() {
Ok(Some(status)) => {
if status.success() {
log::debug!("Power action {action} completed");
}
if !status.success() {
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 {
action,
message: format!("exit code {}: {}", status, stderr_buf.trim()),
});
}
return 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(),
});
let child_pid = nix::unistd::Pid::from_raw(child.id() as i32);
let done = Arc::new(AtomicBool::new(false));
let timed_out = Arc::new(AtomicBool::new(false));
let done_clone = done.clone();
let timed_out_clone = timed_out.clone();
let _timeout_thread = std::thread::spawn(move || {
let interval = Duration::from_millis(100);
let mut elapsed = Duration::ZERO;
while elapsed < POWER_TIMEOUT {
std::thread::sleep(interval);
if done_clone.load(Ordering::Acquire) {
return;
}
elapsed += interval;
}
// Record that we fired the kill so we don't misclassify an external
// SIGKILL (OOM killer, kill -9) as our timeout.
timed_out_clone.store(true, Ordering::Release);
// ESRCH if the process already exited — harmless
let _ = nix::sys::signal::kill(child_pid, nix::sys::signal::Signal::SIGKILL);
});
// Drop guard ensures the timeout thread sees done=true even if child.wait()
// errors out — otherwise the thread sleeps its full 30 s holding a slot in
// the gio::spawn_blocking pool.
struct DoneGuard(Arc<AtomicBool>);
impl Drop for DoneGuard {
fn drop(&mut self) {
self.0.store(true, Ordering::Release);
}
}
let _done_guard = DoneGuard(done);
let status = child.wait().map_err(|e| PowerError::CommandFailed {
action,
message: e.to_string(),
})?;
if status.success() {
log::debug!("Power action {action} completed");
Ok(())
} else {
if timed_out.load(Ordering::Acquire) {
return Err(PowerError::Timeout { action });
}
let mut stderr_buf = String::new();
if let Some(mut stderr) = child.stderr.take() {
let _ = stderr.read_to_string(&mut stderr_buf);
}
Err(PowerError::CommandFailed {
action,
message: format!("exit code {}: {}", status, stderr_buf.trim()),
})
}
}
@@ -87,7 +115,7 @@ pub fn lock() -> Result<(), PowerError> {
Command::new("/usr/bin/moonlock")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.stderr(Stdio::inherit())
.spawn()
.map_err(|e| PowerError::CommandFailed {
action: "lock",
@@ -97,9 +125,50 @@ pub fn lock() -> Result<(), PowerError> {
Ok(())
}
/// Quit the Niri compositor (logout).
/// Resolve the logout command into program + arguments.
///
/// Priority: a user-configured `logout_command` (space-separated) overrides
/// everything. Otherwise the logind session is terminated via
/// `loginctl terminate-session <id>` — compositor-agnostic, since every
/// pam_systemd session sets `XDG_SESSION_ID`.
fn resolve_logout_command(
override_cmd: Option<&str>,
session_id: Option<&str>,
) -> Result<Vec<String>, PowerError> {
if let Some(cmd) = override_cmd {
let parts: Vec<String> = cmd.split_whitespace().map(str::to_string).collect();
if parts.is_empty() {
return Err(PowerError::CommandFailed {
action: "logout",
message: "logout_command is empty".to_string(),
});
}
return Ok(parts);
}
match session_id {
Some(id) if !id.is_empty() => Ok(vec![
"/usr/bin/loginctl".to_string(),
"terminate-session".to_string(),
id.to_string(),
]),
_ => Err(PowerError::CommandFailed {
action: "logout",
message: "XDG_SESSION_ID unset; set logout_command in moonset.toml".to_string(),
}),
}
}
/// End the session (logout).
///
/// Terminates the logind session by default; honours a `logout_command`
/// override from the config for non-logind or compositor-specific setups.
pub fn logout() -> Result<(), PowerError> {
run_command("logout", "/usr/bin/niri", &["msg", "action", "quit"])
let config = crate::config::load_config(None);
let session_id = std::env::var("XDG_SESSION_ID").ok();
let parts = resolve_logout_command(config.logout_command.as_deref(), session_id.as_deref())?;
let args: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
run_command("logout", &parts[0], &args)
}
/// Hibernate the system via systemctl.
@@ -107,14 +176,14 @@ pub fn hibernate() -> Result<(), PowerError> {
run_command("hibernate", "/usr/bin/systemctl", &["hibernate"])
}
/// Reboot the system via loginctl.
/// Reboot the system via systemctl.
pub fn reboot() -> Result<(), PowerError> {
run_command("reboot", "/usr/bin/loginctl", &["reboot"])
run_command("reboot", "/usr/bin/systemctl", &["reboot"])
}
/// Shut down the system via loginctl.
/// Shut down the system via systemctl.
pub fn shutdown() -> Result<(), PowerError> {
run_command("shutdown", "/usr/bin/loginctl", &["poweroff"])
run_command("shutdown", "/usr/bin/systemctl", &["poweroff"])
}
#[cfg(test)]
@@ -164,4 +233,47 @@ mod tests {
let result = run_command("test", "echo", &["hello", "world"]);
assert!(result.is_ok());
}
#[test]
fn logout_default_uses_loginctl_with_session_id() {
let parts = resolve_logout_command(None, Some("3")).unwrap();
assert_eq!(parts, vec!["/usr/bin/loginctl", "terminate-session", "3"]);
}
#[test]
fn logout_override_takes_precedence() {
let parts = resolve_logout_command(Some("niri msg action quit"), Some("3")).unwrap();
assert_eq!(parts, vec!["niri", "msg", "action", "quit"]);
}
#[test]
fn logout_override_ignores_session_id() {
// An override resolves even without a session id.
let parts = resolve_logout_command(Some("/usr/bin/swaymsg exit"), None).unwrap();
assert_eq!(parts, vec!["/usr/bin/swaymsg", "exit"]);
}
#[test]
fn logout_default_errors_without_session_id() {
assert!(matches!(
resolve_logout_command(None, None),
Err(PowerError::CommandFailed { action: "logout", .. })
));
}
#[test]
fn logout_default_errors_on_empty_session_id() {
assert!(matches!(
resolve_logout_command(None, Some("")),
Err(PowerError::CommandFailed { action: "logout", .. })
));
}
#[test]
fn logout_override_errors_when_blank() {
assert!(matches!(
resolve_logout_command(Some(" "), Some("3")),
Err(PowerError::CommandFailed { action: "logout", .. })
));
}
}
+43 -11
View File
@@ -12,7 +12,6 @@ pub struct User {
pub username: String,
pub display_name: String,
pub home: PathBuf,
pub uid: u32,
}
/// Get the currently logged-in user's info from the system.
@@ -37,7 +36,6 @@ pub fn get_current_user() -> Option<User> {
username: nix_user.name,
display_name,
home: nix_user.dir,
uid: uid.as_raw(),
})
}
@@ -47,6 +45,7 @@ pub fn get_avatar_path(home: &Path, username: Option<&str>) -> Option<PathBuf> {
}
/// Find avatar with configurable AccountsService dir (for testing).
/// Rejects symlinks to prevent path traversal.
pub fn get_avatar_path_with(
home: &Path,
username: Option<&str>,
@@ -54,17 +53,25 @@ pub fn get_avatar_path_with(
) -> Option<PathBuf> {
// ~/.face takes priority
let face = home.join(".face");
if face.exists() {
log::debug!("Avatar: using ~/.face");
return Some(face);
if let Ok(meta) = face.symlink_metadata() {
if meta.file_type().is_symlink() {
log::warn!("Rejecting symlink avatar: {}", face.display());
} else if meta.is_file() {
log::debug!("Avatar: using ~/.face ({})", face.display());
return Some(face);
}
}
// AccountsService icon
if let Some(name) = username {
if accountsservice_dir.exists() {
let icon = accountsservice_dir.join(name);
if icon.exists() {
log::debug!("Avatar: using AccountsService icon");
// AccountsService icon fallback
if let Some(name) = username
&& accountsservice_dir.exists()
{
let icon = accountsservice_dir.join(name);
if let Ok(meta) = icon.symlink_metadata() {
if meta.file_type().is_symlink() {
log::warn!("Rejecting symlink avatar: {}", icon.display());
} else if meta.is_file() {
log::debug!("Avatar: using AccountsService icon ({})", icon.display());
return Some(icon);
}
}
@@ -127,6 +134,31 @@ mod tests {
assert_eq!(path, Some(face));
}
#[test]
fn rejects_symlink_avatar() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("secret");
fs::write(&target, "secret content").unwrap();
let face = dir.path().join(".face");
std::os::unix::fs::symlink(&target, &face).unwrap();
let path = get_avatar_path_with(dir.path(), None, Path::new("/nonexistent"));
assert!(path.is_none());
}
#[test]
fn rejects_symlink_accountsservice_icon() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("secret");
fs::write(&target, "secret content").unwrap();
let icons_dir = dir.path().join("icons");
fs::create_dir(&icons_dir).unwrap();
let icon = icons_dir.join("testuser");
std::os::unix::fs::symlink(&target, &icon).unwrap();
// No ~/.face, so resolution falls through to the AccountsService branch
let path = get_avatar_path_with(dir.path(), Some("testuser"), &icons_dir);
assert!(path.is_none());
}
#[test]
fn returns_none_when_no_avatar() {
let dir = tempfile::tempdir().unwrap();