21 Commits

Author SHA1 Message Date
nevaforget d030f1360a fix: restore keyboard focus on action buttons after dismiss (v0.8.2)
Update PKGBUILD version / update-pkgver (push) Successful in 2s
After cancelling a confirmation prompt, the focused widget was removed
from the tree without restoring focus to the action buttons. With
layer-shell exclusive keyboard mode, GTK does not recover focus
automatically — the UI became keyboard-unreachable.
2026-04-06 22:36:36 +02:00
nevaforget e97535e41b Remove unnecessary pacman git install from CI workflow
Git is already available in the runner image.
2026-04-02 08:28:08 +02:00
nevaforget b518572d0f Revert CI workaround: remove pacman install step
Update PKGBUILD version / update-pkgver (push) Failing after 0s
The act_runner now uses a custom Arch-based image with git
pre-installed, so per-workflow installs are no longer needed.
2026-04-01 16:17:48 +02:00
nevaforget b3ed7fb292 chore: update Cargo.lock for v0.8.1
Update PKGBUILD version / update-pkgver (push) Successful in 2s
2026-03-31 12:53:20 +02:00
nevaforget 358c228645 fix: audit fixes — release profile, GResource compression, lock stderr, sync markers (v0.8.1)
Update PKGBUILD version / update-pkgver (push) Successful in 1s
- Add [profile.release] with LTO, codegen-units=1, strip
- Add compressed="true" to GResource CSS/SVG entries
- Inherit moonlock stderr instead of /dev/null (errors visible in journal)
- Add SYNC comments to duplicated blur/background functions
2026-03-31 11:08:43 +02:00
nevaforget a4564f2b71 docs: add v0.8.0 changelog entry, fix build.rs comment
CHANGELOG was missing the v0.8.0 entry (symlink-safe avatars, blur
downscale + padding fix, config validation). build.rs comment still
referenced removed wallpaper.jpg.
2026-03-31 09:36:01 +02:00
nevaforget 8aca2bf331 fix: audit fixes — symlink-safe avatars, blur downscale + padding, config validation (v0.8.0)
Update PKGBUILD version / update-pkgver (push) Successful in 2s
- Replace canonicalize() with symlink_metadata + is_file + !is_symlink for avatar
  lookup (prevents symlink traversal to arbitrary files)
- Fix blur padding offset from (0,0) to (-pad,-pad) to prevent edge darkening
- Add MAX_BLUR_DIMENSION (1920px) downscale before GPU blur
- Validate blur per config source (invalid user value preserves system default)
- Wallpaper: use symlink_metadata + is_file + !is_symlink in resolve_background_path
2026-03-30 16:08:50 +02:00
nevaforget f01c6bd25d ci: also update .SRCINFO in pkgver workflow
Update PKGBUILD version / update-pkgver (push) Successful in 1s
paru reads .SRCINFO (not PKGBUILD) for version comparison during
sysupgrade. Without updating .SRCINFO, paru never detects upgrades
for PKGBUILD repository packages.
2026-03-30 13:49:16 +02:00
nevaforget 7cd1f8cb6d ci: replace actions/checkout with plain git clone (no node needed)
Update PKGBUILD version / update-pkgver (push) Successful in 2s
2026-03-29 23:02:58 +02:00
nevaforget c22bc5bca1 ci: use moonarch runner label instead of ubuntu-latest
Update PKGBUILD version / update-pkgver (push) Failing after 18s
2026-03-29 23:02:09 +02:00
nevaforget 069387761b ci: add workflow to auto-update pkgver in moonarch-pkgbuilds
Update PKGBUILD version / update-pkgver (push) Failing after 1m23s
2026-03-29 22:55:48 +02:00
nevaforget e59ed53d7a fix: use systemctl for reboot/shutdown — loginctl lacks these verbs (v0.7.3) 2026-03-29 18:59:00 +02:00
nevaforget 2ca572773e fix: elevate CSS priority to override GTK4 user theme (v0.7.2)
Colloid-Catppuccin theme loaded via ~/.config/gtk-4.0/gtk.css at
PRIORITY_USER (800) was overriding moonset's PRIORITY_APPLICATION (600),
causing action buttons to lose their circular border-radius.

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

- Replace busy-loop polling in run_command with child.wait() + timeout thread
- Canonicalize ~/.face and AccountsService avatar paths to prevent symlink abuse
- Add detect_locale_with() DI function for testable locale detection
- Move config I/O from activate() to main() to avoid blocking GTK main loop
- Validate background_blur range (0–200), reject invalid values with warning
- Remove embedded wallpaper from GResource — moonarch provides it via filesystem
  (binary size ~3.2MB → ~1.3MB)
2026-03-28 23:09:29 +01:00
nevaforget 71670eb263 feat: switch to systemd-journal-logger, add debug logging (v0.6.0)
Replace env_logger with systemd-journal-logger for consistent logging
across moonset/moonlock/moongreet. Add MOONSET_DEBUG env var and debug
statements across all modules. Also includes shared blur cache for
multi-monitor and detached moonlock spawn for lock action.
2026-03-28 22:58:25 +01:00
nevaforget 14affb1533 perf: replace CPU blur with GPU blur via GskBlurNode (v0.5.0)
Replace image crate + disk cache blur with GPU-side GskBlurNode,
symmetric with moonlock and moongreet. Removes ~15 transitive
dependencies and ~160 lines of caching code. Blur now happens once
on the GPU at widget realization — zero startup latency, no cache
management needed.
2026-03-28 22:35:18 +01:00
nevaforget 4d8e306b74 feat: add fade-out animation on dismiss for smooth visual exit
Without this, app.quit() destroys windows instantly, creating a jarring
pop-out. Now all windows fade out over 250ms (matching the fade-in)
before the app exits. Uses the same CSS opacity transition — just
removes the "visible" class and defers quit via glib timeout.
2026-03-28 21:50:03 +01:00
nevaforget 2e88a9b6c4 feat: activate fade-in animation for panel and wallpaper windows
The Rust code already adds a "visible" CSS class on map, but the
stylesheet had no corresponding opacity transition. Add 250ms ease-in
fade via GPU-accelerated CSS opacity to eliminate the visual pop-in.
2026-03-28 21:46:08 +01:00
nevaforget 412ed159a4 fix: address audit findings — blur channel mismatch, logout quit, config error logging
- Fix BGRA→RGBA channel swap in apply_blur so image::RgbaImage semantics
  match the actual pixel data from GDK texture download
- Logout now calls app.quit() like lock does, via new quit_after field on
  ActionDef (replaces fragile magic string comparison)
- Log TOML parse errors to stderr instead of silently ignoring
- Remove pointless zlib compression of JPEG wallpaper in GResource
- Add tests for quit_after behavior and config error handling
2026-03-28 21:39:34 +01:00
nevaforget 478caed8e0 perf: cache blurred wallpaper to disk to avoid re-blur on startup
First launch with blur blurs and saves to ~/.cache/moonset/. Subsequent
starts load the cached PNG directly (~9x faster). Cache invalidates
when wallpaper path, size, mtime, or sigma changes.
2026-03-28 21:22:48 +01:00
16 changed files with 657 additions and 456 deletions
+43
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
+82
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
+4 -3
View File
@@ -17,7 +17,7 @@ Lock, Logout, Hibernate, Reboot, Shutdown.
## Projektstruktur ## Projektstruktur
- `src/` — Rust-Quellcode (main.rs, power.rs, i18n.rs, config.rs, users.rs, panel.rs) - `src/` — Rust-Quellcode (main.rs, power.rs, i18n.rs, config.rs, users.rs, panel.rs)
- `resources/` — GResource-Assets (style.css, wallpaper.jpg komprimiert, default-avatar.svg) - `resources/` — GResource-Assets (style.css, default-avatar.svg)
- `config/` — Beispiel-Konfigurationsdateien - `config/` — Beispiel-Konfigurationsdateien
## Kommandos ## Kommandos
@@ -35,7 +35,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 +54,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
Generated
+13 -307
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.2"
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",
]
+9 -4
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "moonset" name = "moonset"
version = "0.4.0" version = "0.8.2"
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"
+32 -4
View File
@@ -2,12 +2,40 @@
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-03-31 Fourth audit: release profile, GResource compression, lock stderr, sync markers
- **Who**: Ragnar, 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**: Ragnar, Dom
- **Why**: All three Moon projects (moonset, moongreet, moonlock) embedded a 374kB fallback wallpaper in the binary via GResource. Moonarch already installs `/usr/share/moonarch/wallpaper.jpg` at system setup time, making the embedded fallback unnecessary dead weight (~2MB in binary size).
- **Tradeoffs**: If `/usr/share/moonarch/wallpaper.jpg` is missing and no user config exists, moonset shows a solid CSS background instead of a wallpaper. Acceptable — the power menu is functional without a wallpaper image.
- **How**: Removed `wallpaper.jpg` from GResource XML and resources directory. `resolve_background_path` returns `Option<PathBuf>`. All wallpaper-related functions handle `None` gracefully. Binary size dropped from ~3.2MB to ~1.3MB.
## 2026-03-28 Switch from env_logger to systemd-journal-logger
- **Who**: Ragnar, Dom
- **Why**: moonlock and moongreet already use systemd-journal-logger. moonset used env_logger which writes to stderr — not useful for a GUI app launched via keybind. Journal integration enables `journalctl -t moonset` and consistent troubleshooting across all three Moon projects.
- **Tradeoffs**: Requires systemd at runtime. Graceful fallback to eprintln if journal logger fails. Acceptable since Moonarch targets systemd-based Arch Linux.
- **How**: Replace `env_logger` dep with `systemd-journal-logger`, add `setup_logging()` with `MOONSET_DEBUG` env var for debug-level output. Same pattern as moonlock/moongreet.
## 2026-03-28 Replace action name dispatch with `quit_after` field
- **Who**: Hekate, Dom - **Who**: Hekate, Dom
- **Why**: Blurred wallpaper as background is a common UX pattern for overlay menus - **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 `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**: Adds a field to `ActionDef` that most actions set to `false`. Acceptable because it makes the contract explicit and testable.
- **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**: `ActionDef.quit_after: bool``true` for lock and logout, `false` for hibernate/reboot/shutdown.
## 2026-03-28 GPU blur via GskBlurNode replaces CPU blur
- **Who**: Ragnar, Dom
- **Why**: CPU-side Gaussian blur (`image` crate) blocked startup and added caching complexity. moonlock already migrated to GPU blur.
- **Tradeoffs**: GPU blur quality is slightly different (box-blur approximation vs true Gaussian), acceptable for wallpaper backgrounds. Removes `image` crate dependency entirely. No disk cache needed.
- **How**: `Snapshot::push_blur()` + `GskRenderer::render_texture()` on `connect_realize`. Blur happens once on the GPU when the widget gets its renderer. Symmetric with moonlock and moongreet.
## 2026-03-28 Use absolute paths for system binaries ## 2026-03-28 Use absolute paths for system binaries
+1 -1
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(
+2 -3
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>
+14 -2
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

+104 -21
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) {
if parsed.background_path.is_some() { Ok(parsed) => {
merged.background_path = parsed.background_path; log::debug!("Config loaded: {}", path.display());
if parsed.background_path.is_some() {
merged.background_path = parsed.background_path;
}
// Validate blur per source — invalid values preserve the previous default
if parsed.background_blur.is_some_and(|b| b.is_finite() && (0.0..=200.0).contains(&b)) {
merged.background_blur = parsed.background_blur;
} else if parsed.background_blur.is_some() {
log::warn!("Invalid background_blur in {}, ignoring", path.display());
}
} }
if parsed.background_blur.is_some() { Err(e) => {
merged.background_blur = parsed.background_blur; log::warn!("Failed to parse {}: {e}", path.display());
} }
} }
} }
@@ -47,29 +56,33 @@ 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))
} }
/// 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 to prevent path traversal
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 let Ok(meta) = path.symlink_metadata() {
return path; if meta.is_file() && !meta.file_type().is_symlink() {
log::debug!("Wallpaper source: config ({})", path.display());
return Some(path);
}
} }
} }
// Moonarch ecosystem default // Moonarch ecosystem default
if moonarch_wallpaper.is_file() { if moonarch_wallpaper.is_file() {
return moonarch_wallpaper.to_path_buf(); log::debug!("Wallpaper source: moonarch default ({})", moonarch_wallpaper.display());
return Some(moonarch_wallpaper.to_path_buf());
} }
// GResource fallback path (loaded from compiled resources at runtime) 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 +168,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 +179,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 +188,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));
} }
} }
+50 -6
View File
@@ -112,15 +112,25 @@ fn read_lang_from_conf(path: &Path) -> Option<String> {
/// Determine the system language from LANG env var or /etc/locale.conf. /// Determine the system language from LANG env var or /etc/locale.conf.
pub fn detect_locale() -> String { pub fn detect_locale() -> String {
let lang = env::var("LANG") detect_locale_with(env::var("LANG").ok().as_deref(), Path::new(DEFAULT_LOCALE_CONF))
.ok() }
.filter(|s| !s.is_empty())
.or_else(|| read_lang_from_conf(Path::new(DEFAULT_LOCALE_CONF)));
match lang { /// Determine locale with configurable inputs (for testing).
pub fn detect_locale_with(env_lang: Option<&str>, locale_conf_path: &Path) -> String {
let (raw, source) = if let Some(val) = env_lang.filter(|s| !s.is_empty()) {
(Some(val.to_string()), "LANG env")
} else if let Some(val) = read_lang_from_conf(locale_conf_path) {
(Some(val), "locale.conf")
} else {
(None, "default")
};
let result = match raw {
Some(l) => parse_lang_prefix(&l), 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 +269,40 @@ mod tests {
} }
} }
// -- detect_locale_with tests --
#[test]
fn detect_locale_uses_env_lang() {
let result = detect_locale_with(Some("de_DE.UTF-8"), Path::new("/nonexistent"));
assert_eq!(result, "de");
}
#[test]
fn detect_locale_falls_back_to_conf_file() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("locale.conf");
let mut f = fs::File::create(&conf).unwrap();
writeln!(f, "LANG=de_DE.UTF-8").unwrap();
let result = detect_locale_with(None, &conf);
assert_eq!(result, "de");
}
#[test]
fn detect_locale_ignores_empty_env_lang() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("locale.conf");
let mut f = fs::File::create(&conf).unwrap();
writeln!(f, "LANG=fr_FR.UTF-8").unwrap();
let result = detect_locale_with(Some(""), &conf);
assert_eq!(result, "fr");
}
#[test]
fn detect_locale_defaults_to_english() {
let result = detect_locale_with(None, Path::new("/nonexistent"));
assert_eq!(result, "en");
}
#[test] #[test]
fn error_messages_contain_failed() { fn error_messages_contain_failed() {
let s = load_strings(Some("en")); let s = load_strings(Some("en"));
+37 -12
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,42 @@ fn activate(app: &gtk::Application) {
} }
} }
fn setup_logging() {
match systemd_journal_logger::JournalLog::new() {
Ok(logger) => {
if let Err(e) = logger.install() {
eprintln!("Failed to install journal logger: {e}");
}
}
Err(e) => {
eprintln!("Failed to create journal logger: {e}");
}
}
let level = if std::env::var("MOONSET_DEBUG").is_ok() {
log::LevelFilter::Debug
} else {
log::LevelFilter::Info
};
log::set_max_level(level);
}
fn main() { 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();
} }
+168 -54
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;
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 file = gio::File::for_path(bg_path);
let resource_path = bg_path.to_str().unwrap_or(&fallback); match gdk::Texture::from_file(&file) {
gdk::Texture::from_resource(resource_path) Ok(texture) => Some(texture),
} else { Err(e) => {
let file = gio::File::for_path(bg_path); log::warn!("Failed to load wallpaper {}: {e}", bg_path.display());
gdk::Texture::from_file(&file).unwrap_or_else(|_| { None
gdk::Texture::from_resource(&fallback) }
})
};
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 {
window.set_child(Some(&background)); let background = create_background_picture(texture, blur_radius, blur_cache);
window.set_child(Some(&background));
}
// Fade-in on map // Fade-in on map
window.connect_map(|w| { window.connect_map(|w| {
@@ -149,7 +201,7 @@ 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();
@@ -162,6 +214,7 @@ pub fn create_panel_window(texture: &gdk::Texture, app: &gtk::Application) -> gt
home: dirs::home_dir().unwrap_or_default(), home: dirs::home_dir().unwrap_or_default(),
uid: u32::MAX, 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 +223,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 {
overlay.set_child(Some(&background)); let background = create_background_picture(texture, blur_radius, blur_cache);
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 +235,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 +292,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 +306,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 +331,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 +376,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 +406,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 +416,7 @@ fn create_action_button(
&confirm_area, &confirm_area,
&confirm_box, &confirm_box,
&error_label, &error_label,
&button_box,
); );
} }
)); ));
@@ -351,6 +439,7 @@ 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);
@@ -360,7 +449,7 @@ fn on_action_clicked(
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 +460,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);
@@ -418,8 +508,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);
@@ -450,9 +545,11 @@ fn execute_action(
error_label: &gtk::Label, error_label: &gtk::Label,
) { ) {
dismiss_confirm(confirm_area, confirm_box); dismiss_confirm(confirm_area, confirm_box);
log::debug!("Executing power action: {}", action_def.name);
let action_fn = action_def.action_fn; let action_fn = action_def.action_fn;
let action_name = action_def.name; let action_name = action_def.name;
let quit_after = action_def.quit_after;
let error_message = (action_def.error_attr)(strings).to_string(); let error_message = (action_def.error_attr)(strings).to_string();
// Use glib::spawn_future_local + gio::spawn_blocking to avoid Send issues // Use glib::spawn_future_local + gio::spawn_blocking to avoid Send issues
@@ -468,9 +565,8 @@ fn execute_action(
match result { match result {
Ok(Ok(())) => { Ok(Ok(())) => {
// Lock action: quit after successful execution if quit_after {
if action_name == "lock" { fade_out_and_quit(&app);
app.quit();
} }
} }
Ok(Err(e)) => { Ok(Err(e)) => {
@@ -494,6 +590,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 +598,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 +616,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 +725,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);
}
}
} }
+71 -34
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,7 +32,12 @@ 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(Stdio::piped())
@@ -41,43 +48,73 @@ 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 done_clone = done.clone();
Ok(Some(status)) => {
if !status.success() { let timeout_thread = std::thread::spawn(move || {
let mut stderr_buf = String::new(); // Sleep in short intervals so we can exit early when the child finishes
if let Some(mut stderr) = child.stderr.take() { let interval = Duration::from_millis(100);
let _ = stderr.read_to_string(&mut stderr_buf); let mut elapsed = Duration::ZERO;
} while elapsed < POWER_TIMEOUT {
return Err(PowerError::CommandFailed { std::thread::sleep(interval);
action, if done_clone.load(Ordering::Relaxed) {
message: format!("exit code {}: {}", status, stderr_buf.trim()), return;
});
}
return Ok(());
} }
Ok(None) => { elapsed += interval;
if Instant::now() >= deadline { }
let _ = child.kill(); // ESRCH if the process already exited — harmless
let _ = child.wait(); let _ = nix::sys::signal::kill(child_pid, nix::sys::signal::Signal::SIGKILL);
return Err(PowerError::Timeout { action }); });
}
std::thread::sleep(Duration::from_millis(100)); let status = child.wait().map_err(|e| PowerError::CommandFailed {
} action,
Err(e) => { message: e.to_string(),
return Err(PowerError::CommandFailed { })?;
action,
message: e.to_string(), done.store(true, Ordering::Relaxed);
}); let _ = timeout_thread.join();
if status.success() {
log::debug!("Power action {action} completed");
Ok(())
} else {
// Check if killed by our timeout (SIGKILL = signal 9)
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if status.signal() == Some(9) {
return Err(PowerError::Timeout { action });
} }
} }
let mut stderr_buf = String::new();
if let Some(mut stderr) = child.stderr.take() {
let _ = stderr.read_to_string(&mut stderr_buf);
}
Err(PowerError::CommandFailed {
action,
message: format!("exit code {}: {}", status, stderr_buf.trim()),
})
} }
} }
/// 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 +127,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)]
+27 -5
View File
@@ -47,6 +47,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,16 +55,26 @@ 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() {
return Some(face); if meta.file_type().is_symlink() {
log::warn!("Rejecting symlink avatar: {}", face.display());
} else if meta.is_file() {
log::debug!("Avatar: using ~/.face ({})", face.display());
return Some(face);
}
} }
// AccountsService icon // AccountsService icon fallback
if let Some(name) = username { if let Some(name) = username {
if accountsservice_dir.exists() { if 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() {
return Some(icon); 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);
}
} }
} }
} }
@@ -125,6 +136,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();