Compare commits

..

25 Commits
v0.4.0 ... main

Author SHA1 Message Date
8285bcdf44 fix: audit LOW fixes — dead uid, home_dir warn, clippy sweep, debug value (v0.8.5)
All checks were successful
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
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
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
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
d030f1360a fix: restore keyboard focus on action buttons after dismiss (v0.8.2)
All checks were successful
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
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
b518572d0f Revert CI workaround: remove pacman install step
Some checks failed
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
b3ed7fb292 chore: update Cargo.lock for v0.8.1
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 2s
2026-03-31 12:53:20 +02:00
358c228645 fix: audit fixes — release profile, GResource compression, lock stderr, sync markers (v0.8.1)
All checks were successful
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
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
8aca2bf331 fix: audit fixes — symlink-safe avatars, blur downscale + padding, config validation (v0.8.0)
All checks were successful
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
f01c6bd25d ci: also update .SRCINFO in pkgver workflow
All checks were successful
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
7cd1f8cb6d ci: replace actions/checkout with plain git clone (no node needed)
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 2s
2026-03-29 23:02:58 +02:00
c22bc5bca1 ci: use moonarch runner label instead of ubuntu-latest
Some checks failed
Update PKGBUILD version / update-pkgver (push) Failing after 18s
2026-03-29 23:02:09 +02:00
069387761b ci: add workflow to auto-update pkgver in moonarch-pkgbuilds
Some checks failed
Update PKGBUILD version / update-pkgver (push) Failing after 1m23s
2026-03-29 22:55:48 +02:00
e59ed53d7a fix: use systemctl for reboot/shutdown — loginctl lacks these verbs (v0.7.3) 2026-03-29 18:59:00 +02:00
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
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
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
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
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
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
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
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
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
16 changed files with 770 additions and 481 deletions

View File

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

View File

@ -3,11 +3,93 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
Format based on [Keep a Changelog](https://keepachangelog.com/). Format based on [Keep a Changelog](https://keepachangelog.com/).
## [0.8.0] - 2026-03-30
### Changed
- Replace `canonicalize()` with `symlink_metadata` + `is_file` + `!is_symlink` for avatar lookup — prevents symlink traversal to arbitrary files
- Replace `canonicalize()` with same symlink-safe check in `resolve_background_path`
- Downscale wallpaper to `MAX_BLUR_DIMENSION` (1920px) before GPU blur — prevents excessive memory use on high-res images
- Validate `background_blur` per config source — invalid user value preserves system default instead of silently falling back to 0
### Fixed
- Fix blur padding offset from `(0,0)` to `(-pad,-pad)` to prevent edge darkening on blurred wallpaper
## [0.7.3] - 2026-03-29
### Fixed
- Fix shutdown and reboot — `loginctl` does not support `poweroff`/`reboot` verbs, switched to `systemctl poweroff` and `systemctl reboot`
## [0.7.2] - 2026-03-29
### Fixed
- Fix CSS priority so app styles override GTK4 user theme (Colloid-Catppuccin) — use `STYLE_PROVIDER_PRIORITY_USER` instead of `STYLE_PROVIDER_PRIORITY_APPLICATION`
- Replace `border-radius: 50%` with `9999px` — GTK4 CSS does not reliably support percentage-based border-radius
## [0.7.1] - 2026-03-28
### Fixed
- Fix edge darkening on blurred wallpaper — GskBlurNode sampled transparent pixels outside texture bounds, now renders with 3x-sigma padding and crops back
## [0.7.0] - 2026-03-28
### Added
- Blur validation: `background_blur` must be 0.0200.0 (negative, NaN, infinite, and extreme values are rejected with a warning)
- `detect_locale_with()` testable DI function for locale detection (4 new tests)
- Path canonicalization for `~/.face` and AccountsService avatar paths (resolves symlinks, prevents passing arbitrary files to gdk-pixbuf)
### Changed
- Replace busy-loop polling (`try_wait` + `sleep(100ms)`) in `run_command` with blocking `child.wait()` + timeout thread — eliminates poll latency and thread waste
- Move config loading from `activate()` to `main()` — filesystem I/O no longer blocks the GTK main loop
- Click-to-dismiss now attached to overlay instead of background picture (works with or without wallpaper)
### Removed
- Embedded fallback wallpaper from GResource bundle — moonarch provides `/usr/share/moonarch/wallpaper.jpg` at install time, binary size dropped from ~3.2MB to ~1.3MB
- GResource fallback path in `resolve_background_path` — returns `Option<PathBuf>` now, `None` falls through to CSS background
## [0.6.0] - 2026-03-28
### Added
- Systemd journal logging (`journalctl -t moonset`) replacing env_logger stderr output
- `MOONSET_DEBUG` env var to enable debug-level journal output
- Debug logging across all modules (config resolution, wallpaper source, avatar loading, power actions, locale detection, blur cache)
- Shared blur cache for multi-monitor — GPU blur computed once, reused by all windows
### Changed
- Lock action spawns moonlock as detached process instead of blocking via run_command — moonset can quit immediately while moonlock runs independently
## [0.4.1] - 2026-03-28
### Added
- Fade-in/fade-out animation (250ms ease-in) for panel and wallpaper windows via CSS opacity transition
### Fixed
- Fix pixel format mismatch in blur path — `texture.download()` yields BGRA but was passed to `RgbaImage` without channel swap, now explicitly converts B↔R
- Logout action now calls `app.quit()` to dismiss the menu immediately (previously only Lock did)
- Log TOML parse errors to stderr instead of silently falling back to defaults
### Changed
- Replace magic string `"lock"` comparison with `quit_after` field on `ActionDef` for type-safe action dispatch
- Remove `compressed="true"` from JPEG wallpaper in GResource — JPEG is already compressed, zlib overhead hurts startup for negligible size savings
## [0.4.0] - 2026-03-28 ## [0.4.0] - 2026-03-28
### Added ### Added
- Optional background blur via `background_blur` config option (Gaussian blur, `image` crate) - 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 ## [0.1.1] - 2026-03-28

View File

@ -1,7 +1,5 @@
# Moonset # Moonset
**Name**: Hekate (Göttin der Wegkreuzungen — passend zum Power-Menu, das den Weg der Session bestimmt)
## Projekt ## Projekt
Moonset ist ein Wayland Session Power Menu, gebaut mit Rust + gtk4-rs + gtk4-layer-shell. Moonset ist ein Wayland Session Power Menu, gebaut mit Rust + gtk4-rs + gtk4-layer-shell.
@ -17,7 +15,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 komprimiert, default-avatar.svg) - `resources/` — GResource-Assets (style.css, default-avatar.svg)
- `config/` — Beispiel-Konfigurationsdateien - `config/` — Beispiel-Konfigurationsdateien
## Kommandos ## Kommandos
@ -35,7 +33,7 @@ LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonset
## Architektur ## Architektur
- `main.rs` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor, zentrale `GRESOURCE_PREFIX`-Konstante - `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) - `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
@ -54,5 +52,6 @@ Kurzfassung der wichtigsten Entscheidungen:
- **System-Icons**: Adwaita/Catppuccin liefern alle benötigten symbolischen Icons - **System-Icons**: Adwaita/Catppuccin liefern alle benötigten symbolischen Icons
- **Lock ohne Confirmation**: Lock ist sofort reversibel, braucht kein Confirm - **Lock ohne Confirmation**: Lock ist sofort reversibel, braucht kein Confirm
- **Absolute Pfade für Binaries**: `/usr/bin/systemctl` etc. statt relativer Pfade (Security) - **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 - **GResource-Bundle**: CSS und Default-Avatar sind in die Binary kompiliert (Wallpaper kommt vom Dateisystem)
- **Async Power Actions**: `glib::spawn_future_local` + `gio::spawn_blocking` mit 30s Timeout - **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

320
Cargo.lock generated
View File

@ -2,71 +2,6 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[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"
@ -85,18 +20,6 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "cairo-rs" name = "cairo-rs"
version = "0.22.0" version = "0.22.0"
@ -142,21 +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]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "dirs" name = "dirs"
version = "6.0.0" version = "6.0.0"
@ -178,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"
@ -223,15 +108,6 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]] [[package]]
name = "field-offset" name = "field-offset"
version = "0.3.6" version = "0.3.6"
@ -242,16 +118,6 @@ dependencies = [
"rustc_version", "rustc_version",
] ]
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.1.5" version = "0.1.5"
@ -676,21 +542,6 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.13.0" version = "2.13.0"
@ -703,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"
@ -793,46 +614,26 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]] [[package]]
name = "moonset" name = "moonset"
version = "0.1.1" version = "0.8.5"
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",
"image",
"log", "log",
"nix", "nix",
"serde", "serde",
"systemd-journal-logger",
"tempfile", "tempfile",
"toml 0.8.23", "toml 0.8.23",
] ]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]] [[package]]
name = "nix" name = "nix"
version = "0.29.0" version = "0.29.0"
@ -845,27 +646,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" 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"
@ -908,34 +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 = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[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"
@ -964,12 +722,6 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "pxfm"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@ -996,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"
@ -1114,12 +837,6 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@ -1156,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"
@ -1308,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"
@ -1510,18 +1231,3 @@ name = "zmij"
version = "1.0.21" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]

View File

@ -1,6 +1,6 @@
[package] [package]
name = "moonset" name = "moonset"
version = "0.4.0" version = "0.8.5"
edition = "2024" edition = "2024"
description = "Wayland session power menu with GTK4 and Layer Shell" description = "Wayland session power menu with GTK4 and Layer Shell"
license = "MIT" license = "MIT"
@ -14,13 +14,18 @@ gdk-pixbuf = "0.22"
toml = "0.8" toml = "0.8"
dirs = "6" dirs = "6"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
nix = { version = "0.29", features = ["user"] } nix = { version = "0.29", features = ["user", "signal"] }
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } 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"
[profile.release]
lto = "thin"
codegen-units = 1
strip = true
[build-dependencies] [build-dependencies]
glib-build-tools = "0.22" glib-build-tools = "0.22"

View File

@ -2,58 +2,107 @@
Architectural and design decisions for Moonset, in reverse chronological order. Architectural and design decisions for Moonset, in reverse chronological order.
## 2026-03-28 Optional background blur via `image` crate ## 2026-04-24 Audit LOW fixes: dead uid field, home_dir warn, clippy sweep, debug value (v0.8.5)
- **Who**: Hekate, Dom - **Who**: ClaudeCode, Dom
- **Why**: Blurred wallpaper as background is a common UX pattern for overlay menus - **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**: Adds `image` crate dependency (~15 transitive crates); CPU-side Gaussian blur at load time adds startup latency proportional to image size and sigma. Acceptable because blur runs once and the texture is shared across monitors. - **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**: `load_background_texture(bg_path, blur_radius)` loads texture, optionally applies `imageops::blur()`, returns `gdk::Texture`. Config option `background_blur: Option<f32>` in TOML. - **How**: (1) Remove `pub uid: u32` from `User` and the `uid: uid.as_raw()` assignment in `get_current_user`. (2) Panel fallback drops the `uid` field entirely. (3) `dirs::home_dir().unwrap_or_else(|| { log::warn!(...); PathBuf::new() })`. (4) `cargo clippy --fix` for the two collapsible ifs, manual collapse of `if-let` + `&&` chain, redundant closure replaced with the function itself. (5) `MOONSET_DEBUG` now requires the literal value `"1"` to escalate to Debug.
## 2026-04-24 Audit MEDIUM fixes: timeout guard, POSIX locale, button desensitize, wallpaper allowlist (v0.8.4)
- **Who**: ClaudeCode, Dom
- **Why**: Five MEDIUM findings: (1) `run_command`'s timeout thread leaked a 30 s gio::spawn_blocking slot if `child.wait()` errored, because `done.store(true)` ran after the `?`. (2) Timeout detection compared `status.signal() == Some(9)` — a hardcoded signal number that also misclassifies OOM-killer SIGKILL as our timeout. (3) `execute_action` never desensitized the button_box, so a double-click or accidental keyboard repeat fired the action twice. (4) `detect_locale` read only `LANG`, ignoring POSIX priority order (`LC_ALL` > `LC_MESSAGES` > `LANG`) — a common dual-language setup picked the wrong UI language. (5) The wallpaper path was passed to gdk-pixbuf without extension or size restriction, widening the image-parser attack surface and allowing unbounded decode latency.
- **Tradeoffs**: The extension allowlist (`jpg`, `jpeg`, `png`, `webp`) rejects exotic formats users might have used before. The 10 MB size cap rejects uncompressed/high-quality 4K wallpapers; acceptable for a power menu. Memory ordering on the `done` flag is now `Release`/`Acquire` instead of `Relaxed` — no runtime cost but correct across threads.
- **How**: (1) RAII `DoneGuard` struct sets `done.store(true, Release)` in its `Drop`, so the flag fires on every function exit path. A second `timed_out` AtomicBool distinguishes our SIGKILL from an external one. (2) Replace `Some(9)` with the `timed_out` flag check. (3) `execute_action` now takes `button_box: &gtk::Box`, calls `set_sensitive(false)` on entry and re-enables it on error paths; success paths that quit skip the re-enable. All call sites updated. (4) `detect_locale` reads `LC_ALL`, `LC_MESSAGES`, `LANG` in order, picking the first non-empty value before falling back to `/etc/locale.conf`. (5) `accept_wallpaper` helper applies extension allowlist + symlink rejection + `MAX_WALLPAPER_FILE_SIZE = 10 MB`, and is called for both config-path and Moonarch fallback.
## 2026-04-24 Audit fix: avoid latent stdout pipe deadlock in run_command (v0.8.3)
- **Who**: ClaudeCode, Dom
- **Why**: Audit found that `run_command` piped the child's `stdout` but never drained it, then called blocking `child.wait()`. A child writing more than one OS pipe buffer (~64 KB on Linux) would block on `write()` while the parent blocked in `wait()` — classic pipe deadlock, broken only by the 30 s SIGKILL timeout. Current callers (`systemctl`, `niri msg`, `loginctl`) do not emit that much output, but the structure was fragile and would bite on any future command or changed behaviour.
- **Tradeoffs**: stdout is now fully discarded. If a future caller needs stdout, it will have to drain it concurrently with `wait()` (separate reader thread).
- **How**: Replace `.stdout(Stdio::piped())` with `.stdout(Stdio::null())` in `run_command`. `stderr` stays piped — it is drained after `wait()`, which is safe because `wait()` already reaped the child and no further writes can occur.
## 2026-03-31 Fourth audit: release profile, GResource compression, lock stderr, sync markers
- **Who**: ClaudeCode, Dom
- **Why**: Fourth triple audit found missing release profile (LTO/strip), uncompressed GResource assets, moonlock stderr suppressed (errors invisible), and duplicated code without sync markers.
- **Tradeoffs**: moonlock stderr now inherited instead of null — errors appear in moonset's journal context. Acceptable for debugging, no security leak since moonlock logs to its own journal identifier.
- **How**: (1) `[profile.release]` with LTO, codegen-units=1, strip. (2) `compressed="true"` on GResource entries. (3) `Stdio::inherit()` for moonlock stderr in lock(). (4) SYNC comments on duplicated blur/background functions.
## 2026-03-28 Remove wallpaper from GResource bundle
- **Who**: ClaudeCode, Dom
- **Why**: All three Moon projects (moonset, moongreet, moonlock) embedded a 374kB fallback wallpaper in the binary via GResource. Moonarch already installs `/usr/share/moonarch/wallpaper.jpg` at system setup time, making the embedded fallback unnecessary dead weight (~2MB in binary size).
- **Tradeoffs**: If `/usr/share/moonarch/wallpaper.jpg` is missing and no user config exists, moonset shows a solid CSS background instead of a wallpaper. Acceptable — the power menu is functional without a wallpaper image.
- **How**: Removed `wallpaper.jpg` from GResource XML and resources directory. `resolve_background_path` returns `Option<PathBuf>`. All wallpaper-related functions handle `None` gracefully. Binary size dropped from ~3.2MB to ~1.3MB.
## 2026-03-28 Switch from env_logger to systemd-journal-logger
- **Who**: ClaudeCode, Dom
- **Why**: moonlock and moongreet already use systemd-journal-logger. moonset used env_logger which writes to stderr — not useful for a GUI app launched via keybind. Journal integration enables `journalctl -t moonset` and consistent troubleshooting across all three Moon projects.
- **Tradeoffs**: Requires systemd at runtime. Graceful fallback to eprintln if journal logger fails. Acceptable since Moonarch targets systemd-based Arch Linux.
- **How**: Replace `env_logger` dep with `systemd-journal-logger`, add `setup_logging()` with `MOONSET_DEBUG` env var for debug-level output. Same pattern as moonlock/moongreet.
## 2026-03-28 Replace action name dispatch with `quit_after` field
- **Who**: ClaudeCode, Dom
- **Why**: Post-action behavior (quit the app or not) was controlled by comparing `action_name == "lock"` — a magic string duplicated from the action definition. Renaming an action would silently break the dispatch.
- **Tradeoffs**: Adds a field to `ActionDef` that most actions set to `false`. Acceptable because it makes the contract explicit and testable.
- **How**: `ActionDef.quit_after: bool``true` for lock and logout, `false` for hibernate/reboot/shutdown.
## 2026-03-28 GPU blur via GskBlurNode replaces CPU blur
- **Who**: ClaudeCode, Dom
- **Why**: CPU-side Gaussian blur (`image` crate) blocked startup and added caching complexity. moonlock already migrated to GPU blur.
- **Tradeoffs**: GPU blur quality is slightly different (box-blur approximation vs true Gaussian), acceptable for wallpaper backgrounds. Removes `image` crate dependency entirely. No disk cache needed.
- **How**: `Snapshot::push_blur()` + `GskRenderer::render_texture()` on `connect_realize`. Blur happens once on the GPU when the widget gets its renderer. Symmetric with moonlock and moongreet.
## 2026-03-28 Use absolute paths for system binaries ## 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. - **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. - **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. - **How**: All five power action wrappers now use `/usr/bin/` prefixed paths.
## 2026-03-28 Implement power action timeout via try_wait polling ## 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. - **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. - **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. - **How**: `run_command` now polls `child.try_wait()` against a 30s deadline, kills the child on timeout.
## 2026-03-28 Centralize GRESOURCE_PREFIX ## 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. - **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. - **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. - **How**: Single `pub(crate) const GRESOURCE_PREFIX` in `main.rs`, referenced everywhere else.
## 2026-03-28 Remove journal.md ## 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. - **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. - **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. - **How**: Deleted. Useful technical learnings migrated to persistent memory.
## 2026-03-27 OVERLAY layer instead of TOP ## 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. - **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. - **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. - **How**: `setup_layer_shell` uses `gtk4_layer_shell::Layer::Overlay` for the panel window.
## 2026-03-27 Lock without confirmation ## 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. - **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. - **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. - **How**: `ActionDef.needs_confirm = false` for lock; all others require inline confirmation.
## 2026-03-27 Niri-specific logout via `niri msg action quit` ## 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. - **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. - **Tradeoffs**: Hard dependency on Niri. If the compositor changes, `power::logout()` must be updated.
- **How**: `Command::new("/usr/bin/niri").args(["msg", "action", "quit"])`. - **How**: `Command::new("/usr/bin/niri").args(["msg", "action", "quit"])`.

View File

@ -1,5 +1,5 @@
// ABOUTME: Build script for compiling GResource bundle. // 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() { fn main() {
glib_build_tools::compile_resources( glib_build_tools::compile_resources(

View File

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

View File

@ -6,16 +6,28 @@ window.panel {
background-color: @theme_bg_color; background-color: @theme_bg_color;
background-size: cover; background-size: cover;
background-position: center; background-position: center;
opacity: 0;
transition: opacity 250ms ease-in;
}
window.panel.visible {
opacity: 1;
} }
/* Wallpaper-only window for secondary monitors */ /* Wallpaper-only window for secondary monitors */
window.wallpaper { window.wallpaper {
background-color: @theme_bg_color; background-color: @theme_bg_color;
opacity: 0;
transition: opacity 250ms ease-in;
}
window.wallpaper.visible {
opacity: 1;
} }
/* Round avatar image */ /* Round avatar image */
.avatar { .avatar {
border-radius: 50%; border-radius: 9999px;
min-width: 128px; min-width: 128px;
min-height: 128px; min-height: 128px;
background-color: @theme_selected_bg_color; background-color: @theme_selected_bg_color;
@ -36,7 +48,7 @@ window.wallpaper {
min-width: 120px; min-width: 120px;
min-height: 120px; min-height: 120px;
padding: 16px; padding: 16px;
border-radius: 50%; border-radius: 9999px;
background-color: alpha(@theme_base_color, 0.55); background-color: alpha(@theme_base_color, 0.55);
color: @theme_fg_color; color: @theme_fg_color;
border: none; border: none;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

View File

@ -31,12 +31,21 @@ 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() { // 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; merged.background_blur = parsed.background_blur;
} else if parsed.background_blur.is_some() {
log::warn!("Invalid background_blur in {}, ignoring", path.display());
}
}
Err(e) => {
log::warn!("Failed to parse {}: {e}", path.display());
} }
} }
} }
@ -47,29 +56,68 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
/// Resolve the wallpaper path using the fallback hierarchy. /// Resolve the wallpaper path using the fallback hierarchy.
/// ///
/// Priority: config background_path > Moonarch system default > gresource fallback. /// Priority: config background_path > Moonarch system default.
pub fn resolve_background_path(config: &Config) -> PathBuf { /// Returns None if no wallpaper is available (CSS background shows through).
pub fn resolve_background_path(config: &Config) -> Option<PathBuf> {
resolve_background_path_with(config, Path::new(MOONARCH_WALLPAPER)) resolve_background_path_with(config, Path::new(MOONARCH_WALLPAPER))
} }
/// Wallpapers are passed to gdk-pixbuf's image loader; restrict to common image
/// extensions to reduce the parser-attack surface for user-controlled paths.
const ALLOWED_BG_EXT: &[&str] = &["jpg", "jpeg", "png", "webp"];
/// Bound wallpaper decode latency (10 MB covers typical 4K JPEGs at Q95).
const MAX_WALLPAPER_FILE_SIZE: u64 = 10 * 1024 * 1024;
fn is_allowed_wallpaper(path: &Path) -> bool {
match path.extension().and_then(|e| e.to_str()) {
Some(ext) => ALLOWED_BG_EXT.iter().any(|a| a.eq_ignore_ascii_case(ext)),
None => false,
}
}
fn accept_wallpaper(path: &Path) -> bool {
if !is_allowed_wallpaper(path) {
log::warn!("Wallpaper rejected (extension not in allowlist): {}", path.display());
return false;
}
match path.symlink_metadata() {
Ok(meta) if meta.file_type().is_symlink() => {
log::warn!("Wallpaper rejected (symlink): {}", path.display());
false
}
Ok(meta) if !meta.is_file() => false,
Ok(meta) if meta.len() > MAX_WALLPAPER_FILE_SIZE => {
log::warn!(
"Wallpaper rejected ({} bytes > {} limit): {}",
meta.len(), MAX_WALLPAPER_FILE_SIZE, path.display()
);
false
}
Ok(_) => true,
Err(_) => false,
}
}
/// Resolve with configurable moonarch wallpaper path (for testing). /// Resolve with configurable moonarch wallpaper path (for testing).
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> PathBuf { pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> Option<PathBuf> {
// User-configured path // User-configured path — reject symlinks, non-image extensions, and oversized files
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 accept_wallpaper(&path) {
return path; log::debug!("Wallpaper source: config ({})", path.display());
return Some(path);
} }
} }
// Moonarch ecosystem default // Moonarch ecosystem default — apply the same checks for consistency
if moonarch_wallpaper.is_file() { if accept_wallpaper(moonarch_wallpaper) {
return moonarch_wallpaper.to_path_buf(); log::debug!("Wallpaper source: moonarch default ({})", moonarch_wallpaper.display());
return Some(moonarch_wallpaper.to_path_buf());
} }
// GResource fallback path (loaded from compiled resources at runtime) log::debug!("No wallpaper found, using CSS background");
let prefix = crate::GRESOURCE_PREFIX; None
PathBuf::from(format!("{prefix}/wallpaper.jpg"))
} }
#[cfg(test)] #[cfg(test)]
@ -155,7 +203,7 @@ mod tests {
}; };
assert_eq!( assert_eq!(
resolve_background_path_with(&config, Path::new("/nonexistent")), resolve_background_path_with(&config, Path::new("/nonexistent")),
wallpaper Some(wallpaper)
); );
} }
@ -166,8 +214,7 @@ mod tests {
..Config::default() ..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 assert_eq!(result, None);
assert!(result.to_str().unwrap().contains("moonset"));
} }
#[test] #[test]
@ -176,13 +223,84 @@ mod tests {
let moonarch_wp = dir.path().join("wallpaper.jpg"); let moonarch_wp = dir.path().join("wallpaper.jpg");
fs::write(&moonarch_wp, "fake").unwrap(); fs::write(&moonarch_wp, "fake").unwrap();
let config = Config::default(); let config = Config::default();
assert_eq!(resolve_background_path_with(&config, &moonarch_wp), moonarch_wp); assert_eq!(resolve_background_path_with(&config, &moonarch_wp), Some(moonarch_wp));
} }
#[test] #[test]
fn resolve_uses_gresource_fallback_as_last_resort() { fn resolve_returns_none_when_no_wallpaper_available() {
let config = Config::default(); let config = Config::default();
let result = resolve_background_path_with(&config, Path::new("/nonexistent")); let result = resolve_background_path_with(&config, Path::new("/nonexistent"));
assert!(result.to_str().unwrap().contains("wallpaper.jpg")); assert_eq!(result, None);
}
#[test]
fn load_config_ignores_invalid_toml_syntax() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("bad.toml");
fs::write(&conf, "this is not valid [[[ toml").unwrap();
let paths = vec![conf];
let config = load_config(Some(&paths));
assert!(config.background_path.is_none());
assert!(config.background_blur.is_none());
}
#[test]
fn load_config_ignores_wrong_field_types() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("wrong_type.toml");
fs::write(&conf, "background_blur = \"not_a_number\"\n").unwrap();
let paths = vec![conf];
let config = load_config(Some(&paths));
assert!(config.background_blur.is_none());
}
#[test]
fn load_config_rejects_negative_blur() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("negative.toml");
fs::write(&conf, "background_blur = -5.0\n").unwrap();
let paths = vec![conf];
let config = load_config(Some(&paths));
assert_eq!(config.background_blur, None);
}
#[test]
fn load_config_rejects_excessive_blur() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("huge.toml");
fs::write(&conf, "background_blur = 999.0\n").unwrap();
let paths = vec![conf];
let config = load_config(Some(&paths));
assert_eq!(config.background_blur, None);
}
#[test]
fn load_config_accepts_valid_blur_range() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("valid.toml");
fs::write(&conf, "background_blur = 50.0\n").unwrap();
let paths = vec![conf];
let config = load_config(Some(&paths));
assert_eq!(config.background_blur, Some(50.0));
}
#[test]
fn load_config_accepts_zero_blur() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("zero.toml");
fs::write(&conf, "background_blur = 0.0\n").unwrap();
let paths = vec![conf];
let config = load_config(Some(&paths));
assert_eq!(config.background_blur, Some(0.0));
}
#[test]
fn load_config_accepts_max_blur() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("max.toml");
fs::write(&conf, "background_blur = 200.0\n").unwrap();
let paths = vec![conf];
let config = load_config(Some(&paths));
assert_eq!(config.background_blur, Some(200.0));
} }
} }

View File

@ -110,17 +110,34 @@ fn read_lang_from_conf(path: &Path) -> Option<String> {
None 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 { pub fn detect_locale() -> String {
let lang = env::var("LANG") let env_val = env::var("LC_ALL")
.ok() .ok()
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.or_else(|| read_lang_from_conf(Path::new(DEFAULT_LOCALE_CONF))); .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))
}
match lang { /// Determine locale with configurable inputs (for testing).
pub fn detect_locale_with(env_lang: Option<&str>, locale_conf_path: &Path) -> String {
let (raw, source) = if let Some(val) = env_lang.filter(|s| !s.is_empty()) {
(Some(val.to_string()), "env")
} else if let Some(val) = read_lang_from_conf(locale_conf_path) {
(Some(val), "locale.conf")
} else {
(None, "default")
};
let result = match raw {
Some(l) => parse_lang_prefix(&l), Some(l) => parse_lang_prefix(&l),
None => "en".to_string(), None => "en".to_string(),
} };
log::debug!("Detected locale: {result} (source: {source})");
result
} }
/// Return the string table for the given locale, defaulting to English. /// Return the string table for the given locale, defaulting to English.
@ -259,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] #[test]
fn error_messages_contain_failed() { fn error_messages_contain_failed() {
let s = load_strings(Some("en")); let s = load_strings(Some("en"));

View File

@ -11,6 +11,7 @@ use gdk4 as gdk;
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4::{self as gtk, gio}; use gtk4::{self as gtk, gio};
use gtk4_layer_shell::LayerShell; use gtk4_layer_shell::LayerShell;
use std::path::PathBuf;
pub(crate) const GRESOURCE_PREFIX: &str = "/dev/moonarch/moonset"; 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( gtk::style_context_add_provider_for_display(
display, display,
&css_provider, &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); 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() { let display = match gdk::Display::default() {
Some(d) => d, Some(d) => d,
None => { None => {
@ -53,13 +54,14 @@ fn activate(app: &gtk::Application) {
load_css(&display); load_css(&display);
// Resolve wallpaper once, decode texture once, share across all windows // Decode texture once (if wallpaper available), share across all windows.
let config = config::load_config(None); // Blur is applied on the GPU via GskBlurNode at first widget realization,
let bg_path = config::resolve_background_path(&config); // then cached and reused by all subsequent windows.
let texture = panel::load_background_texture(&bg_path, config.background_blur); let texture = panel::load_background_texture(bg_path.as_deref());
let blur_cache = panel::new_blur_cache();
// Panel on focused output (no set_monitor → compositor picks focused) // Panel on focused output (no set_monitor → compositor picks focused)
let panel = panel::create_panel_window(&texture, 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); setup_layer_shell(&panel, true, gtk4_layer_shell::Layer::Overlay);
panel.present(); panel.present();
@ -67,7 +69,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(&texture, 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); setup_layer_shell(&wallpaper, false, gtk4_layer_shell::Layer::Top);
wallpaper.set_monitor(Some(&monitor)); wallpaper.set_monitor(Some(&monitor));
wallpaper.present(); wallpaper.present();
@ -75,19 +77,44 @@ 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}");
}
}
// Require MOONSET_DEBUG=1 to raise verbosity so mere presence (empty
// value in a session script) cannot escalate journal noise with path
// information an attacker could use.
let level = match std::env::var("MOONSET_DEBUG").ok().as_deref() {
Some("1") => log::LevelFilter::Debug,
_ => log::LevelFilter::Info,
};
log::set_max_level(level);
}
fn main() { fn main() {
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
gio::resources_register_include!("moonset.gresource").expect("Failed to register resources"); gio::resources_register_include!("moonset.gresource").expect("Failed to register resources");
// Load config and resolve wallpaper path before GTK app start —
// no GTK types needed, avoids blocking the main loop.
let config = config::load_config(None);
let bg_path = config::resolve_background_path(&config);
let blur_radius = config.background_blur;
let app = gtk::Application::builder() let app = gtk::Application::builder()
.application_id("dev.moonarch.moonset") .application_id("dev.moonarch.moonset")
.build(); .build();
app.connect_activate(activate); app.connect_activate(move |app| activate(app, &bg_path, blur_radius));
app.run(); app.run();
} }

View File

@ -6,10 +6,10 @@ use gdk_pixbuf::Pixbuf;
use glib::clone; use glib::clone;
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4::{self as gtk, gio}; use gtk4::{self as gtk, gio};
use image::imageops;
use std::cell::RefCell; use std::cell::RefCell;
use std::path::Path; use std::path::{Path, PathBuf};
use std::rc::Rc; use std::rc::Rc;
use std::time::Duration;
use crate::i18n::{load_strings, Strings}; use crate::i18n::{load_strings, Strings};
use crate::power::{self, PowerError}; use crate::power::{self, PowerError};
@ -27,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.
@ -40,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",
@ -49,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",
@ -58,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",
@ -67,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",
@ -76,63 +81,110 @@ 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. /// Load the wallpaper as a texture once, for sharing across all windows.
/// When `blur_radius` is `Some(sigma)` with sigma > 0, a Gaussian blur is applied. /// Returns None if no wallpaper path is configured (CSS background shows through).
pub fn load_background_texture(bg_path: &Path, blur_radius: Option<f32>) -> gdk::Texture { pub fn load_background_texture(bg_path: Option<&Path>) -> Option<gdk::Texture> {
let fallback = format!("{}/wallpaper.jpg", crate::GRESOURCE_PREFIX); let bg_path = bg_path?;
log::debug!("Background: {}", bg_path.display());
let texture = 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); let file = gio::File::for_path(bg_path);
gdk::Texture::from_file(&file).unwrap_or_else(|_| { match gdk::Texture::from_file(&file) {
gdk::Texture::from_resource(&fallback) Ok(texture) => Some(texture),
}) Err(e) => {
}; log::warn!("Failed to load wallpaper {}: {e}", bg_path.display());
None
match blur_radius { }
Some(sigma) if sigma > 0.0 => apply_blur(&texture, sigma),
_ => texture,
} }
} }
/// Apply Gaussian blur to a texture and return a blurred texture. // -- GPU blur via GskBlurNode -------------------------------------------------
fn apply_blur(texture: &gdk::Texture, sigma: f32) -> gdk::Texture {
let width = texture.width() as u32;
let height = texture.height() as u32;
let stride = width as usize * 4;
let mut pixel_data = vec![0u8; stride * height as usize];
texture.download(&mut pixel_data, stride);
let img = image::RgbaImage::from_raw(width, height, pixel_data) // SYNC: MAX_BLUR_DIMENSION, render_blurred_texture, and create_background_picture
.expect("pixel buffer size matches texture dimensions"); // are duplicated in moonlock/src/lockscreen.rs and moongreet/src/greeter.rs.
let blurred = imageops::blur(&image::DynamicImage::ImageRgba8(img), sigma); // Changes here must be mirrored to the other two projects.
let bytes = glib::Bytes::from(blurred.as_raw()); /// Maximum texture dimension before downscaling for blur.
let mem_texture = gdk::MemoryTexture::new( /// Keeps GPU work reasonable on 4K+ displays.
width as i32, const MAX_BLUR_DIMENSION: f32 = 1920.0;
height as i32,
gdk::MemoryFormat::B8g8r8a8Premultiplied, /// Render a blurred texture using the GPU via GskBlurNode.
&bytes, ///
stride, /// To avoid edge darkening (blur samples transparent pixels outside bounds),
); /// the texture is rendered with padding equal to 3x the blur sigma. The blur
mem_texture.upcast() /// is applied to the padded area, then cropped back to the original size.
///
/// Large textures (> MAX_BLUR_DIMENSION) are downscaled before blurring to
/// reduce GPU work. The sigma is scaled proportionally.
fn render_blurred_texture(
widget: &impl IsA<gtk::Widget>,
texture: &gdk::Texture,
sigma: f32,
) -> Option<gdk::Texture> {
let native = widget.native()?;
let renderer = native.renderer()?;
let orig_w = texture.width() as f32;
let orig_h = texture.height() as f32;
// Downscale large textures to reduce GPU blur work
let max_dim = orig_w.max(orig_h);
let scale = if max_dim > MAX_BLUR_DIMENSION {
MAX_BLUR_DIMENSION / max_dim
} else {
1.0
};
let w = (orig_w * scale).round();
let h = (orig_h * scale).round();
let scaled_sigma = sigma * scale;
// Padding must cover the blur kernel radius (typically ~3x sigma)
let pad = (scaled_sigma * 3.0).ceil();
let snapshot = gtk::Snapshot::new();
// Clip output to scaled texture size
snapshot.push_clip(&graphene_rs::Rect::new(pad, pad, w, h));
snapshot.push_blur(scaled_sigma as f64);
// Render texture with padding on all sides (edges repeat via oversized bounds)
snapshot.append_texture(texture, &graphene_rs::Rect::new(-pad, -pad, w + 2.0 * pad, h + 2.0 * pad));
snapshot.pop(); // blur
snapshot.pop(); // clip
let node = snapshot.to_node()?;
let viewport = graphene_rs::Rect::new(pad, pad, w, h);
Some(renderer.render_texture(&node, Some(&viewport)))
}
/// Fade out all windows and quit the app after the CSS transition completes.
fn fade_out_and_quit(app: &gtk::Application) {
for window in app.windows() {
window.remove_css_class("visible");
}
let app = app.clone();
glib::timeout_add_local_once(Duration::from_millis(250), move || {
app.quit();
});
}
/// Create a new shared blur cache for GPU-blurred wallpaper textures.
pub fn new_blur_cache() -> BlurCache {
Rc::new(RefCell::new(None))
} }
/// Create a wallpaper-only window for secondary monitors. /// Create a wallpaper-only window for secondary monitors.
pub fn create_wallpaper_window(texture: &gdk::Texture, 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() 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(texture); if let Some(texture) = texture {
let background = create_background_picture(texture, blur_radius, blur_cache);
window.set_child(Some(&background)); window.set_child(Some(&background));
}
// Fade-in on map // Fade-in on map
window.connect_map(|w| { window.connect_map(|w| {
@ -149,19 +201,25 @@ pub fn create_wallpaper_window(texture: &gdk::Texture, app: &gtk::Application) -
} }
/// Create the main panel window with action buttons and confirm flow. /// Create the main panel window with action buttons and confirm flow.
pub fn create_panel_window(texture: &gdk::Texture, 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() let window = gtk::ApplicationWindow::builder()
.application(app) .application(app)
.build(); .build();
window.add_css_class("panel"); window.add_css_class("panel");
let strings = load_strings(None); let strings = load_strings(None);
let user = users::get_current_user().unwrap_or_else(|| users::User { 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(), username: "user".to_string(),
display_name: "User".to_string(), display_name: "User".to_string(),
home: dirs::home_dir().unwrap_or_default(), home,
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));
@ -170,9 +228,11 @@ pub fn create_panel_window(texture: &gdk::Texture, app: &gtk::Application) -> gt
let overlay = gtk::Overlay::new(); let overlay = gtk::Overlay::new();
window.set_child(Some(&overlay)); window.set_child(Some(&overlay));
// Background wallpaper // Background wallpaper (if available, otherwise CSS background shows through)
let background = create_background_picture(texture); if let Some(texture) = texture {
let background = create_background_picture(texture, blur_radius, blur_cache);
overlay.set_child(Some(&background)); overlay.set_child(Some(&background));
}
// Click on background dismisses the menu // Click on background dismisses the menu
let click_controller = gtk::GestureClick::new(); let click_controller = gtk::GestureClick::new();
@ -180,10 +240,10 @@ pub fn create_panel_window(texture: &gdk::Texture, app: &gtk::Application) -> gt
#[weak] #[weak]
app, app,
move |_, _, _, _| { move |_, _, _, _| {
app.quit(); fade_out_and_quit(&app);
} }
)); ));
background.add_controller(click_controller); overlay.add_controller(click_controller);
// Centered content box // Centered content box
let content_box = gtk::Box::new(gtk::Orientation::Vertical, 0); let content_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
@ -237,6 +297,7 @@ pub fn create_panel_window(texture: &gdk::Texture, app: &gtk::Application) -> gt
&confirm_area, &confirm_area,
&confirm_box, &confirm_box,
&error_label, &error_label,
&button_box,
); );
button_box.append(&button); button_box.append(&button);
} }
@ -250,7 +311,7 @@ pub fn create_panel_window(texture: &gdk::Texture, app: &gtk::Application) -> gt
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
@ -275,12 +336,40 @@ pub fn create_panel_window(texture: &gdk::Texture, app: &gtk::Application) -> gt
window window
} }
/// Create a Picture widget for the wallpaper background from a shared texture. /// Shared cache for the GPU-blurred wallpaper texture.
fn create_background_picture(texture: &gdk::Texture) -> gtk::Picture { /// Computed once on first window realize, reused by all subsequent windows.
type BlurCache = Rc<RefCell<Option<gdk::Texture>>>;
/// Create a Picture widget for the wallpaper background, optionally with GPU blur.
/// When a blur_cache is provided, the blurred texture is computed once and shared.
fn create_background_picture(
texture: &gdk::Texture,
blur_radius: Option<f32>,
blur_cache: &BlurCache,
) -> gtk::Picture {
let background = gtk::Picture::for_paintable(texture); 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
} }
@ -292,6 +381,7 @@ fn create_action_button(
confirm_area: &gtk::Box, confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>, confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label, error_label: &gtk::Label,
button_box: &gtk::Box,
) -> gtk::Button { ) -> gtk::Button {
let button_content = gtk::Box::new(gtk::Orientation::Vertical, 4); let button_content = gtk::Box::new(gtk::Orientation::Vertical, 4);
button_content.set_halign(gtk::Align::Center); button_content.set_halign(gtk::Align::Center);
@ -321,6 +411,8 @@ fn create_action_button(
confirm_box, confirm_box,
#[weak] #[weak]
error_label, error_label,
#[weak]
button_box,
move |_| { move |_| {
on_action_clicked( on_action_clicked(
&action_def, &action_def,
@ -329,6 +421,7 @@ fn create_action_button(
&confirm_area, &confirm_area,
&confirm_box, &confirm_box,
&error_label, &error_label,
&button_box,
); );
} }
)); ));
@ -351,16 +444,17 @@ fn on_action_clicked(
confirm_area: &gtk::Box, confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>, confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label, error_label: &gtk::Label,
button_box: &gtk::Box,
) { ) {
dismiss_confirm(confirm_area, confirm_box); dismiss_confirm(confirm_area, confirm_box);
error_label.set_visible(false); error_label.set_visible(false);
if !action_def.needs_confirm { if !action_def.needs_confirm {
execute_action(action_def, strings, app, confirm_area, confirm_box, error_label); execute_action(action_def, strings, app, confirm_area, confirm_box, error_label, button_box);
return; 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. /// Show inline confirmation below the action buttons.
@ -371,6 +465,7 @@ fn show_confirm(
confirm_area: &gtk::Box, confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>, confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label, error_label: &gtk::Label,
button_box: &gtk::Box,
) { ) {
let new_box = gtk::Box::new(gtk::Orientation::Vertical, 8); let new_box = gtk::Box::new(gtk::Orientation::Vertical, 8);
new_box.set_halign(gtk::Align::Center); new_box.set_halign(gtk::Align::Center);
@ -398,6 +493,8 @@ fn show_confirm(
confirm_box, confirm_box,
#[weak] #[weak]
error_label, error_label,
#[weak]
button_box,
move |_| { move |_| {
execute_action( execute_action(
&action_def_clone, &action_def_clone,
@ -406,6 +503,7 @@ fn show_confirm(
&confirm_area, &confirm_area,
&confirm_box, &confirm_box,
&error_label, &error_label,
&button_box,
); );
} }
)); ));
@ -418,8 +516,13 @@ fn show_confirm(
confirm_area, confirm_area,
#[strong] #[strong]
confirm_box, confirm_box,
#[weak]
button_box,
move |_| { move |_| {
dismiss_confirm(&confirm_area, &confirm_box); dismiss_confirm(&confirm_area, &confirm_box);
if let Some(first) = button_box.first_child() {
first.grab_focus();
}
} }
)); ));
button_row.append(&no_btn); button_row.append(&no_btn);
@ -448,13 +551,20 @@ fn execute_action(
confirm_area: &gtk::Box, confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>, confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label, error_label: &gtk::Label,
button_box: &gtk::Box,
) { ) {
dismiss_confirm(confirm_area, confirm_box); dismiss_confirm(confirm_area, confirm_box);
log::debug!("Executing power action: {}", action_def.name);
let action_fn = action_def.action_fn; let action_fn = action_def.action_fn;
let action_name = action_def.name; let action_name = action_def.name;
let quit_after = action_def.quit_after;
let error_message = (action_def.error_attr)(strings).to_string(); let error_message = (action_def.error_attr)(strings).to_string();
// Desensitize buttons so a double-click or accidental keyboard repeat
// cannot fire the same action twice while it is in flight.
button_box.set_sensitive(false);
// Use glib::spawn_future_local + gio::spawn_blocking to avoid Send issues // Use glib::spawn_future_local + gio::spawn_blocking to avoid Send issues
// with GTK objects. The blocking closure runs on a thread pool, the result // with GTK objects. The blocking closure runs on a thread pool, the result
// is handled back on the main thread. // is handled back on the main thread.
@ -463,25 +573,30 @@ fn execute_action(
app, app,
#[weak] #[weak]
error_label, error_label,
#[weak]
button_box,
async move { async move {
let result = gio::spawn_blocking(move || action_fn()).await; let result = gio::spawn_blocking(action_fn).await;
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(); } else {
button_box.set_sensitive(true);
} }
} }
Ok(Err(e)) => { Ok(Err(e)) => {
log::error!("Power action '{}' failed: {}", action_name, e); log::error!("Power action '{}' failed: {}", action_name, e);
error_label.set_text(&error_message); error_label.set_text(&error_message);
error_label.set_visible(true); error_label.set_visible(true);
button_box.set_sensitive(true);
} }
Err(_) => { Err(_) => {
log::error!("Power action '{}' panicked", action_name); log::error!("Power action '{}' panicked", action_name);
error_label.set_text(&error_message); error_label.set_text(&error_message);
error_label.set_visible(true); error_label.set_visible(true);
button_box.set_sensitive(true);
} }
} }
} }
@ -494,6 +609,7 @@ fn load_avatar_async(image: &gtk::Image, window: &gtk::ApplicationWindow, user:
match avatar_path { match avatar_path {
Some(path) => { Some(path) => {
log::debug!("Avatar source: file {}", path.display());
// File-based avatar: load and scale in background thread // File-based avatar: load and scale in background thread
glib::spawn_future_local(clone!( glib::spawn_future_local(clone!(
#[weak] #[weak]
@ -501,7 +617,7 @@ fn load_avatar_async(image: &gtk::Image, window: &gtk::ApplicationWindow, user:
async move { async move {
let result = gio::spawn_blocking(move || { let result = gio::spawn_blocking(move || {
Pixbuf::from_file_at_scale( Pixbuf::from_file_at_scale(
path.to_str().unwrap_or(""), &path,
AVATAR_SIZE, AVATAR_SIZE,
AVATAR_SIZE, AVATAR_SIZE,
true, true,
@ -519,6 +635,7 @@ fn load_avatar_async(image: &gtk::Image, window: &gtk::ApplicationWindow, user:
)); ));
} }
None => { None => {
log::debug!("Avatar source: default SVG");
// Default SVG avatar: needs widget color, keep synchronous // Default SVG avatar: needs widget color, keep synchronous
set_default_avatar(image, window); set_default_avatar(image, window);
} }
@ -627,4 +744,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);
}
}
} }

View File

@ -4,7 +4,9 @@
use std::fmt; use std::fmt;
use std::io::Read; use std::io::Read;
use std::process::{Command, Stdio}; 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); const POWER_TIMEOUT: Duration = Duration::from_secs(30);
@ -30,10 +32,17 @@ impl fmt::Display for PowerError {
impl std::error::Error for PowerError {} impl std::error::Error for PowerError {}
/// Run a command with timeout and return a PowerError on failure. /// Run a command with timeout and return a PowerError on failure.
///
/// Uses blocking `child.wait()` with a separate timeout thread that sends
/// SIGKILL after POWER_TIMEOUT. This runs inside `gio::spawn_blocking`,
/// so blocking is expected.
fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(), PowerError> { fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(), PowerError> {
log::debug!("Power action: {action} ({program} {args:?})");
let mut child = Command::new(program) let mut child = Command::new(program)
.args(args) .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()) .stderr(Stdio::piped())
.spawn() .spawn()
.map_err(|e| PowerError::CommandFailed { .map_err(|e| PowerError::CommandFailed {
@ -41,43 +50,79 @@ fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(),
message: e.to_string(), message: e.to_string(),
})?; })?;
let deadline = Instant::now() + POWER_TIMEOUT; let child_pid = nix::unistd::Pid::from_raw(child.id() as i32);
loop { let done = Arc::new(AtomicBool::new(false));
match child.try_wait() { let timed_out = Arc::new(AtomicBool::new(false));
Ok(Some(status)) => { let done_clone = done.clone();
if !status.success() { 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(); let mut stderr_buf = String::new();
if let Some(mut stderr) = child.stderr.take() { if let Some(mut stderr) = child.stderr.take() {
let _ = stderr.read_to_string(&mut stderr_buf); let _ = stderr.read_to_string(&mut stderr_buf);
} }
return Err(PowerError::CommandFailed { Err(PowerError::CommandFailed {
action, action,
message: format!("exit code {}: {}", status, stderr_buf.trim()), 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(),
});
}
}
} }
} }
/// 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", "/usr/bin/moonlock", &[]) log::debug!("Power action: lock (spawning moonlock)");
Command::new("/usr/bin/moonlock")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::inherit())
.spawn()
.map_err(|e| PowerError::CommandFailed {
action: "lock",
message: e.to_string(),
})?;
// Child handle is dropped here — moonlock continues running independently.
Ok(())
} }
/// Quit the Niri compositor (logout). /// Quit the Niri compositor (logout).
@ -90,14 +135,14 @@ pub fn hibernate() -> Result<(), PowerError> {
run_command("hibernate", "/usr/bin/systemctl", &["hibernate"]) run_command("hibernate", "/usr/bin/systemctl", &["hibernate"])
} }
/// Reboot the system via loginctl. /// Reboot the system via systemctl.
pub fn reboot() -> Result<(), PowerError> { 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> { pub fn shutdown() -> Result<(), PowerError> {
run_command("shutdown", "/usr/bin/loginctl", &["poweroff"]) run_command("shutdown", "/usr/bin/systemctl", &["poweroff"])
} }
#[cfg(test)] #[cfg(test)]

View File

@ -12,7 +12,6 @@ pub struct User {
pub username: String, pub username: String,
pub display_name: String, pub display_name: String,
pub home: PathBuf, pub home: PathBuf,
pub uid: u32,
} }
/// Get the currently logged-in user's info from the system. /// Get the currently logged-in user's info from the system.
@ -37,7 +36,6 @@ pub fn get_current_user() -> Option<User> {
username: nix_user.name, username: nix_user.name,
display_name, display_name,
home: nix_user.dir, home: nix_user.dir,
uid: uid.as_raw(),
}) })
} }
@ -47,6 +45,7 @@ pub fn get_avatar_path(home: &Path, username: Option<&str>) -> Option<PathBuf> {
} }
/// Find avatar with configurable AccountsService dir (for testing). /// Find avatar with configurable AccountsService dir (for testing).
/// Rejects symlinks to prevent path traversal.
pub fn get_avatar_path_with( pub fn get_avatar_path_with(
home: &Path, home: &Path,
username: Option<&str>, username: Option<&str>,
@ -54,15 +53,25 @@ pub fn get_avatar_path_with(
) -> Option<PathBuf> { ) -> Option<PathBuf> {
// ~/.face takes priority // ~/.face takes priority
let face = home.join(".face"); let face = home.join(".face");
if face.exists() { 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); return Some(face);
} }
}
// AccountsService icon // AccountsService icon fallback
if let Some(name) = username { if let Some(name) = username
if accountsservice_dir.exists() { && accountsservice_dir.exists()
{
let icon = accountsservice_dir.join(name); let icon = accountsservice_dir.join(name);
if icon.exists() { 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); return Some(icon);
} }
} }
@ -125,6 +134,17 @@ mod tests {
assert_eq!(path, Some(face)); assert_eq!(path, Some(face));
} }
#[test]
fn rejects_symlink_avatar() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("secret");
fs::write(&target, "secret content").unwrap();
let face = dir.path().join(".face");
std::os::unix::fs::symlink(&target, &face).unwrap();
let path = get_avatar_path_with(dir.path(), None, Path::new("/nonexistent"));
assert!(path.is_none());
}
#[test] #[test]
fn returns_none_when_no_avatar() { fn returns_none_when_no_avatar() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();