Compare commits
No commits in common. "5a6900e85a678abbc3d1f3be4854f4de9e8333bd" and "4d8e306b744cda1e47d5d7252003abba41a95d3b" have entirely different histories.
5a6900e85a
...
4d8e306b74
32
CHANGELOG.md
32
CHANGELOG.md
@ -3,38 +3,6 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [0.7.0] - 2026-03-28
|
||||
|
||||
### Added
|
||||
|
||||
- Blur validation: `background_blur` must be 0.0–200.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
|
||||
|
||||
@ -17,7 +17,7 @@ Lock, Logout, Hibernate, Reboot, Shutdown.
|
||||
## Projektstruktur
|
||||
|
||||
- `src/` — Rust-Quellcode (main.rs, power.rs, i18n.rs, config.rs, users.rs, panel.rs)
|
||||
- `resources/` — GResource-Assets (style.css, default-avatar.svg)
|
||||
- `resources/` — GResource-Assets (style.css, wallpaper.jpg komprimiert, default-avatar.svg)
|
||||
- `config/` — Beispiel-Konfigurationsdateien
|
||||
|
||||
## Kommandos
|
||||
@ -35,7 +35,7 @@ LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonset
|
||||
|
||||
## Architektur
|
||||
|
||||
- `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
|
||||
- `main.rs` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor, zentrale `GRESOURCE_PREFIX`-Konstante
|
||||
- `power.rs` — 5 Power-Action-Wrapper mit absoluten Pfaden und 30s Timeout (lock, logout, hibernate, reboot, shutdown)
|
||||
- `i18n.rs` — Locale-Erkennung und String-Tabellen (DE/EN)
|
||||
- `config.rs` — TOML-Config + Wallpaper-Fallback
|
||||
@ -54,6 +54,5 @@ Kurzfassung der wichtigsten Entscheidungen:
|
||||
- **System-Icons**: Adwaita/Catppuccin liefern alle benötigten symbolischen Icons
|
||||
- **Lock ohne Confirmation**: Lock ist sofort reversibel, braucht kein Confirm
|
||||
- **Absolute Pfade für Binaries**: `/usr/bin/systemctl` etc. statt relativer Pfade (Security)
|
||||
- **GResource-Bundle**: CSS und Default-Avatar sind in die Binary kompiliert (Wallpaper kommt vom Dateisystem)
|
||||
- **GResource-Bundle**: CSS, Wallpaper (komprimiert) und Default-Avatar sind in die Binary kompiliert
|
||||
- **Async Power Actions**: `glib::spawn_future_local` + `gio::spawn_blocking` mit 30s Timeout
|
||||
- **Journal-Logging**: `systemd-journal-logger` statt File-Logging — `journalctl -t moonset`, Debug-Level per `MOONSET_DEBUG` Env-Var
|
||||
|
||||
320
Cargo.lock
generated
320
Cargo.lock
generated
@ -2,6 +2,71 @@
|
||||
# It is not intended for manual editing.
|
||||
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]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
@ -20,6 +85,18 @@ version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "cairo-rs"
|
||||
version = "0.22.0"
|
||||
@ -65,6 +142,21 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
@ -86,6 +178,29 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.11.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"env_filter",
|
||||
"jiff",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@ -108,6 +223,15 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
@ -118,6 +242,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
@ -542,6 +676,21 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
@ -554,12 +703,42 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
|
||||
dependencies = [
|
||||
"jiff-static",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "khronos_api"
|
||||
version = "3.1.0"
|
||||
@ -614,26 +793,46 @@ dependencies = [
|
||||
"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]]
|
||||
name = "moonset"
|
||||
version = "0.7.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"env_logger",
|
||||
"gdk-pixbuf",
|
||||
"gdk4",
|
||||
"glib",
|
||||
"glib-build-tools",
|
||||
"graphene-rs",
|
||||
"gtk4",
|
||||
"gtk4-layer-shell",
|
||||
"image",
|
||||
"log",
|
||||
"nix",
|
||||
"serde",
|
||||
"systemd-journal-logger",
|
||||
"tempfile",
|
||||
"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]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
@ -646,12 +845,27 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@ -694,6 +908,34 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
@ -722,6 +964,12 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@ -748,6 +996,35 @@ dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@ -837,6 +1114,12 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@ -873,16 +1156,6 @@ dependencies = [
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "systemd-journal-logger"
|
||||
version = "2.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7266304d24ca5a4b230545fc558c80e18bd3e1d2eb1be149b6bcd04398d3e79c"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.3"
|
||||
@ -1035,6 +1308,12 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
@ -1231,3 +1510,18 @@ name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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",
|
||||
]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "moonset"
|
||||
version = "0.7.0"
|
||||
version = "0.4.1"
|
||||
edition = "2024"
|
||||
description = "Wayland session power menu with GTK4 and Layer Shell"
|
||||
license = "MIT"
|
||||
@ -14,10 +14,10 @@ gdk-pixbuf = "0.22"
|
||||
toml = "0.8"
|
||||
dirs = "6"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
nix = { version = "0.29", features = ["user", "signal"] }
|
||||
graphene-rs = { version = "0.22", package = "graphene-rs" }
|
||||
nix = { version = "0.29", features = ["user"] }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
||||
log = "0.4"
|
||||
systemd-journal-logger = "2.2"
|
||||
env_logger = "0.11"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
24
DECISIONS.md
24
DECISIONS.md
@ -2,20 +2,6 @@
|
||||
|
||||
Architectural and design decisions for Moonset, in reverse chronological order.
|
||||
|
||||
## 2026-03-28 – Remove wallpaper from GResource bundle
|
||||
|
||||
- **Who**: Ragnar, Dom
|
||||
- **Why**: All three Moon projects (moonset, moongreet, moonlock) embedded a 374kB fallback wallpaper in the binary via GResource. Moonarch already installs `/usr/share/moonarch/wallpaper.jpg` at system setup time, making the embedded fallback unnecessary dead weight (~2MB in binary size).
|
||||
- **Tradeoffs**: If `/usr/share/moonarch/wallpaper.jpg` is missing and no user config exists, moonset shows a solid CSS background instead of a wallpaper. Acceptable — the power menu is functional without a wallpaper image.
|
||||
- **How**: Removed `wallpaper.jpg` from GResource XML and resources directory. `resolve_background_path` returns `Option<PathBuf>`. All wallpaper-related functions handle `None` gracefully. Binary size dropped from ~3.2MB to ~1.3MB.
|
||||
|
||||
## 2026-03-28 – Switch from env_logger to systemd-journal-logger
|
||||
|
||||
- **Who**: Ragnar, Dom
|
||||
- **Why**: moonlock and moongreet already use systemd-journal-logger. moonset used env_logger which writes to stderr — not useful for a GUI app launched via keybind. Journal integration enables `journalctl -t moonset` and consistent troubleshooting across all three Moon projects.
|
||||
- **Tradeoffs**: Requires systemd at runtime. Graceful fallback to eprintln if journal logger fails. Acceptable since Moonarch targets systemd-based Arch Linux.
|
||||
- **How**: Replace `env_logger` dep with `systemd-journal-logger`, add `setup_logging()` with `MOONSET_DEBUG` env var for debug-level output. Same pattern as moonlock/moongreet.
|
||||
|
||||
## 2026-03-28 – Replace action name dispatch with `quit_after` field
|
||||
|
||||
- **Who**: Hekate, Dom
|
||||
@ -23,12 +9,12 @@ Architectural and design decisions for Moonset, in reverse chronological order.
|
||||
- **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
|
||||
## 2026-03-28 – Optional background blur via `image` crate
|
||||
|
||||
- **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.
|
||||
- **Who**: Hekate, Dom
|
||||
- **Why**: Blurred wallpaper as background is a common UX pattern for overlay menus
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## 2026-03-28 – Use absolute paths for system binaries
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
<gresources>
|
||||
<gresource prefix="/dev/moonarch/moonset">
|
||||
<file>style.css</file>
|
||||
<file>wallpaper.jpg</file>
|
||||
<file>default-avatar.svg</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
|
||||
BIN
resources/wallpaper.jpg
Normal file
BIN
resources/wallpaper.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 366 KiB |
@ -33,7 +33,6 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
match toml::from_str::<Config>(&content) {
|
||||
Ok(parsed) => {
|
||||
log::debug!("Config loaded: {}", path.display());
|
||||
if parsed.background_path.is_some() {
|
||||
merged.background_path = parsed.background_path;
|
||||
}
|
||||
@ -42,50 +41,40 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to parse {}: {e}", path.display());
|
||||
eprintln!("Warning: failed to parse {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate blur range
|
||||
if let Some(blur) = merged.background_blur {
|
||||
if !blur.is_finite() || blur < 0.0 || blur > 200.0 {
|
||||
log::warn!("Invalid background_blur value {blur}, ignoring");
|
||||
merged.background_blur = None;
|
||||
}
|
||||
}
|
||||
|
||||
merged
|
||||
}
|
||||
|
||||
/// Resolve the wallpaper path using the fallback hierarchy.
|
||||
///
|
||||
/// Priority: config background_path > Moonarch system default.
|
||||
/// Returns None if no wallpaper is available (CSS background shows through).
|
||||
pub fn resolve_background_path(config: &Config) -> Option<PathBuf> {
|
||||
/// Priority: config background_path > Moonarch system default > gresource fallback.
|
||||
pub fn resolve_background_path(config: &Config) -> PathBuf {
|
||||
resolve_background_path_with(config, Path::new(MOONARCH_WALLPAPER))
|
||||
}
|
||||
|
||||
/// Resolve with configurable moonarch wallpaper path (for testing).
|
||||
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> Option<PathBuf> {
|
||||
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> PathBuf {
|
||||
// User-configured path
|
||||
if let Some(ref bg) = config.background_path {
|
||||
let path = PathBuf::from(bg);
|
||||
if path.is_file() {
|
||||
log::debug!("Wallpaper source: config ({})", path.display());
|
||||
return Some(path);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
// Moonarch ecosystem default
|
||||
if moonarch_wallpaper.is_file() {
|
||||
log::debug!("Wallpaper source: moonarch default ({})", moonarch_wallpaper.display());
|
||||
return Some(moonarch_wallpaper.to_path_buf());
|
||||
return moonarch_wallpaper.to_path_buf();
|
||||
}
|
||||
|
||||
log::debug!("No wallpaper found, using CSS background");
|
||||
None
|
||||
// GResource fallback path (loaded from compiled resources at runtime)
|
||||
let prefix = crate::GRESOURCE_PREFIX;
|
||||
PathBuf::from(format!("{prefix}/wallpaper.jpg"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -171,7 +160,7 @@ mod tests {
|
||||
};
|
||||
assert_eq!(
|
||||
resolve_background_path_with(&config, Path::new("/nonexistent")),
|
||||
Some(wallpaper)
|
||||
wallpaper
|
||||
);
|
||||
}
|
||||
|
||||
@ -182,7 +171,8 @@ mod tests {
|
||||
..Config::default()
|
||||
};
|
||||
let result = resolve_background_path_with(&config, Path::new("/nonexistent"));
|
||||
assert_eq!(result, None);
|
||||
// Falls through to gresource fallback
|
||||
assert!(result.to_str().unwrap().contains("moonset"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -191,14 +181,14 @@ mod tests {
|
||||
let moonarch_wp = dir.path().join("wallpaper.jpg");
|
||||
fs::write(&moonarch_wp, "fake").unwrap();
|
||||
let config = Config::default();
|
||||
assert_eq!(resolve_background_path_with(&config, &moonarch_wp), Some(moonarch_wp));
|
||||
assert_eq!(resolve_background_path_with(&config, &moonarch_wp), moonarch_wp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_returns_none_when_no_wallpaper_available() {
|
||||
fn resolve_uses_gresource_fallback_as_last_resort() {
|
||||
let config = Config::default();
|
||||
let result = resolve_background_path_with(&config, Path::new("/nonexistent"));
|
||||
assert_eq!(result, None);
|
||||
assert!(result.to_str().unwrap().contains("wallpaper.jpg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -223,52 +213,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_rejects_negative_blur() {
|
||||
fn load_config_accepts_negative_blur() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conf = dir.path().join("negative.toml");
|
||||
fs::write(&conf, "background_blur = -5.0\n").unwrap();
|
||||
let paths = vec![conf];
|
||||
let config = load_config(Some(&paths));
|
||||
assert_eq!(config.background_blur, 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));
|
||||
assert_eq!(config.background_blur, Some(-5.0));
|
||||
}
|
||||
}
|
||||
|
||||
56
src/i18n.rs
56
src/i18n.rs
@ -112,25 +112,15 @@ fn read_lang_from_conf(path: &Path) -> Option<String> {
|
||||
|
||||
/// Determine the system language from LANG env var or /etc/locale.conf.
|
||||
pub fn detect_locale() -> String {
|
||||
detect_locale_with(env::var("LANG").ok().as_deref(), Path::new(DEFAULT_LOCALE_CONF))
|
||||
}
|
||||
let lang = env::var("LANG")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| read_lang_from_conf(Path::new(DEFAULT_LOCALE_CONF)));
|
||||
|
||||
/// Determine locale with configurable inputs (for testing).
|
||||
pub fn detect_locale_with(env_lang: Option<&str>, locale_conf_path: &Path) -> String {
|
||||
let (raw, source) = if let Some(val) = env_lang.filter(|s| !s.is_empty()) {
|
||||
(Some(val.to_string()), "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 {
|
||||
match lang {
|
||||
Some(l) => parse_lang_prefix(&l),
|
||||
None => "en".to_string(),
|
||||
};
|
||||
log::debug!("Detected locale: {result} (source: {source})");
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the string table for the given locale, defaulting to English.
|
||||
@ -269,40 +259,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- detect_locale_with tests --
|
||||
|
||||
#[test]
|
||||
fn detect_locale_uses_env_lang() {
|
||||
let result = detect_locale_with(Some("de_DE.UTF-8"), Path::new("/nonexistent"));
|
||||
assert_eq!(result, "de");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_locale_falls_back_to_conf_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conf = dir.path().join("locale.conf");
|
||||
let mut f = fs::File::create(&conf).unwrap();
|
||||
writeln!(f, "LANG=de_DE.UTF-8").unwrap();
|
||||
let result = detect_locale_with(None, &conf);
|
||||
assert_eq!(result, "de");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_locale_ignores_empty_env_lang() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conf = dir.path().join("locale.conf");
|
||||
let mut f = fs::File::create(&conf).unwrap();
|
||||
writeln!(f, "LANG=fr_FR.UTF-8").unwrap();
|
||||
let result = detect_locale_with(Some(""), &conf);
|
||||
assert_eq!(result, "fr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_locale_defaults_to_english() {
|
||||
let result = detect_locale_with(None, Path::new("/nonexistent"));
|
||||
assert_eq!(result, "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_messages_contain_failed() {
|
||||
let s = load_strings(Some("en"));
|
||||
|
||||
47
src/main.rs
47
src/main.rs
@ -11,7 +11,6 @@ use gdk4 as gdk;
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{self as gtk, gio};
|
||||
use gtk4_layer_shell::LayerShell;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(crate) const GRESOURCE_PREFIX: &str = "/dev/moonarch/moonset";
|
||||
|
||||
@ -43,7 +42,7 @@ fn setup_layer_shell(
|
||||
window.set_anchor(gtk4_layer_shell::Edge::Right, true);
|
||||
}
|
||||
|
||||
fn activate(app: >k::Application, bg_path: &Option<PathBuf>, blur_radius: Option<f32>) {
|
||||
fn activate(app: >k::Application) {
|
||||
let display = match gdk::Display::default() {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
@ -54,14 +53,13 @@ fn activate(app: >k::Application, bg_path: &Option<PathBuf>, blur_radius: Opti
|
||||
|
||||
load_css(&display);
|
||||
|
||||
// Decode texture once (if wallpaper available), share across all windows.
|
||||
// Blur is applied on the GPU via GskBlurNode at first widget realization,
|
||||
// then cached and reused by all subsequent windows.
|
||||
let texture = panel::load_background_texture(bg_path.as_deref());
|
||||
let blur_cache = panel::new_blur_cache();
|
||||
// Resolve wallpaper once, decode texture once, share across all windows
|
||||
let config = config::load_config(None);
|
||||
let bg_path = config::resolve_background_path(&config);
|
||||
let texture = panel::load_background_texture(&bg_path, config.background_blur);
|
||||
|
||||
// Panel on focused output (no set_monitor → compositor picks focused)
|
||||
let panel = panel::create_panel_window(texture.as_ref(), blur_radius, &blur_cache, app);
|
||||
let panel = panel::create_panel_window(&texture, app);
|
||||
setup_layer_shell(&panel, true, gtk4_layer_shell::Layer::Overlay);
|
||||
panel.present();
|
||||
|
||||
@ -69,7 +67,7 @@ fn activate(app: >k::Application, bg_path: &Option<PathBuf>, blur_radius: Opti
|
||||
let monitors = display.monitors();
|
||||
for i in 0..monitors.n_items() {
|
||||
if let Some(monitor) = monitors.item(i).and_then(|obj| obj.downcast::<gdk::Monitor>().ok()) {
|
||||
let wallpaper = panel::create_wallpaper_window(texture.as_ref(), blur_radius, &blur_cache, app);
|
||||
let wallpaper = panel::create_wallpaper_window(&texture, app);
|
||||
setup_layer_shell(&wallpaper, false, gtk4_layer_shell::Layer::Top);
|
||||
wallpaper.set_monitor(Some(&monitor));
|
||||
wallpaper.present();
|
||||
@ -77,42 +75,19 @@ fn activate(app: >k::Application, bg_path: &Option<PathBuf>, blur_radius: Opti
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
setup_logging();
|
||||
env_logger::Builder::from_default_env()
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.init();
|
||||
log::info!("Moonset starting");
|
||||
|
||||
// Register compiled GResources
|
||||
gio::resources_register_include!("moonset.gresource").expect("Failed to register resources");
|
||||
|
||||
// Load config and resolve wallpaper path before GTK app start —
|
||||
// no GTK types needed, avoids blocking the main loop.
|
||||
let config = config::load_config(None);
|
||||
let bg_path = config::resolve_background_path(&config);
|
||||
let blur_radius = config.background_blur;
|
||||
|
||||
let app = gtk::Application::builder()
|
||||
.application_id("dev.moonarch.moonset")
|
||||
.build();
|
||||
|
||||
app.connect_activate(move |app| activate(app, &bg_path, blur_radius));
|
||||
app.connect_activate(activate);
|
||||
app.run();
|
||||
}
|
||||
|
||||
288
src/panel.rs
288
src/panel.rs
@ -6,10 +6,14 @@ use gdk_pixbuf::Pixbuf;
|
||||
use glib::clone;
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{self as gtk, gio};
|
||||
use image::imageops;
|
||||
use std::cell::RefCell;
|
||||
use std::path::Path;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use crate::i18n::{load_strings, Strings};
|
||||
use crate::power::{self, PowerError};
|
||||
@ -87,39 +91,160 @@ pub fn action_definitions() -> Vec<ActionDef> {
|
||||
}
|
||||
|
||||
/// Load the wallpaper as a texture once, for sharing across all windows.
|
||||
/// Returns None if no wallpaper path is configured (CSS background shows through).
|
||||
pub fn load_background_texture(bg_path: Option<&Path>) -> Option<gdk::Texture> {
|
||||
let bg_path = bg_path?;
|
||||
log::debug!("Background: {}", bg_path.display());
|
||||
/// When `blur_radius` is `Some(sigma)` with sigma > 0, a Gaussian blur is applied.
|
||||
pub fn load_background_texture(bg_path: &Path, blur_radius: Option<f32>) -> gdk::Texture {
|
||||
let fallback = format!("{}/wallpaper.jpg", crate::GRESOURCE_PREFIX);
|
||||
|
||||
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);
|
||||
gdk::Texture::from_file(&file).unwrap_or_else(|_| {
|
||||
gdk::Texture::from_resource(&fallback)
|
||||
})
|
||||
};
|
||||
|
||||
match blur_radius {
|
||||
Some(sigma) if sigma > 0.0 => load_blurred_with_cache(bg_path, &texture, sigma),
|
||||
_ => texture,
|
||||
}
|
||||
}
|
||||
|
||||
// -- Blur cache ----------------------------------------------------------------
|
||||
|
||||
const CACHE_PNG: &str = "blur-cache.png";
|
||||
const CACHE_META: &str = "blur-cache.meta";
|
||||
|
||||
fn blur_cache_dir() -> Option<PathBuf> {
|
||||
dirs::cache_dir().map(|d| d.join("moonset"))
|
||||
}
|
||||
|
||||
/// Build the cache key string for the current wallpaper + sigma.
|
||||
fn build_cache_meta(bg_path: &Path, sigma: f32) -> Option<String> {
|
||||
if bg_path.starts_with("/dev/moonarch/") {
|
||||
let binary = std::env::current_exe().ok()?;
|
||||
let binary_mtime = fs::metadata(&binary)
|
||||
.ok()?
|
||||
.modified()
|
||||
.ok()?
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.ok()?
|
||||
.as_secs();
|
||||
Some(format!(
|
||||
"path={}\nbinary_mtime={}\nsigma={}\n",
|
||||
bg_path.display(), binary_mtime, sigma,
|
||||
))
|
||||
} else {
|
||||
let meta = fs::metadata(bg_path).ok()?;
|
||||
let mtime = meta
|
||||
.modified()
|
||||
.ok()?
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.ok()?
|
||||
.as_secs();
|
||||
Some(format!(
|
||||
"path={}\nsize={}\nmtime={}\nsigma={}\n",
|
||||
bg_path.display(), meta.len(), mtime, sigma,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to load a cached blurred texture if the cache key matches.
|
||||
fn load_cached_blur(cache_dir: &Path, expected_meta: &str) -> Option<gdk::Texture> {
|
||||
let stored_meta = fs::read_to_string(cache_dir.join(CACHE_META)).ok()?;
|
||||
if stored_meta != expected_meta {
|
||||
log::debug!("Blur cache meta mismatch, will re-blur");
|
||||
return None;
|
||||
}
|
||||
let file = gio::File::for_path(cache_dir.join(CACHE_PNG));
|
||||
match gdk::Texture::from_file(&file) {
|
||||
Ok(texture) => Some(texture),
|
||||
Ok(texture) => {
|
||||
log::debug!("Loaded blurred wallpaper from cache");
|
||||
Some(texture)
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to load wallpaper {}: {e}", bg_path.display());
|
||||
log::debug!("Failed to load cached blur PNG: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- GPU blur via GskBlurNode -------------------------------------------------
|
||||
/// Save a blurred texture to the cache directory.
|
||||
fn save_blur_cache(cache_dir: &Path, texture: &gdk::Texture, meta: &str) {
|
||||
if let Err(e) = save_blur_cache_inner(cache_dir, texture, meta) {
|
||||
log::debug!("Failed to save blur cache: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a blurred texture using the GPU via GskBlurNode.
|
||||
fn render_blurred_texture(
|
||||
widget: &impl IsA<gtk::Widget>,
|
||||
fn save_blur_cache_inner(
|
||||
cache_dir: &Path,
|
||||
texture: &gdk::Texture,
|
||||
sigma: f32,
|
||||
) -> Option<gdk::Texture> {
|
||||
let native = widget.native()?;
|
||||
let renderer = native.renderer()?;
|
||||
let snapshot = gtk::Snapshot::new();
|
||||
let bounds = graphene_rs::Rect::new(
|
||||
0.0, 0.0, texture.width() as f32, texture.height() as f32,
|
||||
meta: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
fs::create_dir_all(cache_dir)?;
|
||||
|
||||
let png_bytes = texture.save_to_png_bytes();
|
||||
|
||||
let mut f = fs::OpenOptions::new()
|
||||
.create(true).write(true).truncate(true).mode(0o600)
|
||||
.open(cache_dir.join(CACHE_PNG))?;
|
||||
f.write_all(&png_bytes)?;
|
||||
|
||||
// Meta last — incomplete cache is treated as a miss on next start
|
||||
let mut f = fs::OpenOptions::new()
|
||||
.create(true).write(true).truncate(true).mode(0o600)
|
||||
.open(cache_dir.join(CACHE_META))?;
|
||||
f.write_all(meta.as_bytes())?;
|
||||
|
||||
log::debug!("Saved blur cache to {}", cache_dir.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load blurred texture, using disk cache when available.
|
||||
fn load_blurred_with_cache(bg_path: &Path, texture: &gdk::Texture, sigma: f32) -> gdk::Texture {
|
||||
if let Some(cache_dir) = blur_cache_dir() {
|
||||
if let Some(meta) = build_cache_meta(bg_path, sigma) {
|
||||
if let Some(cached) = load_cached_blur(&cache_dir, &meta) {
|
||||
return cached;
|
||||
}
|
||||
let blurred = apply_blur(texture, sigma);
|
||||
save_blur_cache(&cache_dir, &blurred, &meta);
|
||||
return blurred;
|
||||
}
|
||||
}
|
||||
apply_blur(texture, sigma)
|
||||
}
|
||||
|
||||
// -- Blur implementation -------------------------------------------------------
|
||||
|
||||
/// Apply Gaussian blur to a texture and return a blurred texture.
|
||||
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];
|
||||
// download() yields GDK_MEMORY_DEFAULT = B8G8R8A8_PREMULTIPLIED (BGRA byte order).
|
||||
texture.download(&mut pixel_data, stride);
|
||||
|
||||
// Swap B↔R so image::RgbaImage channel semantics are correct.
|
||||
for pixel in pixel_data.chunks_exact_mut(4) {
|
||||
pixel.swap(0, 2);
|
||||
}
|
||||
|
||||
let img = image::RgbaImage::from_raw(width, height, pixel_data)
|
||||
.expect("pixel buffer size matches texture dimensions");
|
||||
let blurred = imageops::blur(&image::DynamicImage::ImageRgba8(img), sigma);
|
||||
|
||||
let bytes = glib::Bytes::from(blurred.as_raw());
|
||||
let mem_texture = gdk::MemoryTexture::new(
|
||||
width as i32,
|
||||
height as i32,
|
||||
gdk::MemoryFormat::R8g8b8a8Premultiplied,
|
||||
&bytes,
|
||||
stride,
|
||||
);
|
||||
snapshot.push_blur(sigma as f64);
|
||||
snapshot.append_texture(texture, &bounds);
|
||||
snapshot.pop();
|
||||
let node = snapshot.to_node()?;
|
||||
Some(renderer.render_texture(&node, None))
|
||||
mem_texture.upcast()
|
||||
}
|
||||
|
||||
/// Fade out all windows and quit the app after the CSS transition completes.
|
||||
@ -133,22 +258,15 @@ fn fade_out_and_quit(app: >k::Application) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Create a new shared blur cache for GPU-blurred wallpaper textures.
|
||||
pub fn new_blur_cache() -> BlurCache {
|
||||
Rc::new(RefCell::new(None))
|
||||
}
|
||||
|
||||
/// Create a wallpaper-only window for secondary monitors.
|
||||
pub fn create_wallpaper_window(texture: Option<&gdk::Texture>, blur_radius: Option<f32>, blur_cache: &BlurCache, app: >k::Application) -> gtk::ApplicationWindow {
|
||||
pub fn create_wallpaper_window(texture: &gdk::Texture, app: >k::Application) -> gtk::ApplicationWindow {
|
||||
let window = gtk::ApplicationWindow::builder()
|
||||
.application(app)
|
||||
.build();
|
||||
window.add_css_class("wallpaper");
|
||||
|
||||
if let Some(texture) = texture {
|
||||
let background = create_background_picture(texture, blur_radius, blur_cache);
|
||||
let background = create_background_picture(texture);
|
||||
window.set_child(Some(&background));
|
||||
}
|
||||
|
||||
// Fade-in on map
|
||||
window.connect_map(|w| {
|
||||
@ -165,7 +283,7 @@ pub fn create_wallpaper_window(texture: Option<&gdk::Texture>, blur_radius: Opti
|
||||
}
|
||||
|
||||
/// Create the main panel window with action buttons and confirm flow.
|
||||
pub fn create_panel_window(texture: Option<&gdk::Texture>, blur_radius: Option<f32>, blur_cache: &BlurCache, app: >k::Application) -> gtk::ApplicationWindow {
|
||||
pub fn create_panel_window(texture: &gdk::Texture, app: >k::Application) -> gtk::ApplicationWindow {
|
||||
let window = gtk::ApplicationWindow::builder()
|
||||
.application(app)
|
||||
.build();
|
||||
@ -178,7 +296,6 @@ pub fn create_panel_window(texture: Option<&gdk::Texture>, blur_radius: Option<f
|
||||
home: dirs::home_dir().unwrap_or_default(),
|
||||
uid: u32::MAX,
|
||||
});
|
||||
log::debug!("User: {} ({})", user.display_name, user.username);
|
||||
|
||||
// State for confirm box
|
||||
let confirm_box: Rc<RefCell<Option<gtk::Box>>> = Rc::new(RefCell::new(None));
|
||||
@ -187,11 +304,9 @@ pub fn create_panel_window(texture: Option<&gdk::Texture>, blur_radius: Option<f
|
||||
let overlay = gtk::Overlay::new();
|
||||
window.set_child(Some(&overlay));
|
||||
|
||||
// Background wallpaper (if available, otherwise CSS background shows through)
|
||||
if let Some(texture) = texture {
|
||||
let background = create_background_picture(texture, blur_radius, blur_cache);
|
||||
// Background wallpaper
|
||||
let background = create_background_picture(texture);
|
||||
overlay.set_child(Some(&background));
|
||||
}
|
||||
|
||||
// Click on background dismisses the menu
|
||||
let click_controller = gtk::GestureClick::new();
|
||||
@ -202,7 +317,7 @@ pub fn create_panel_window(texture: Option<&gdk::Texture>, blur_radius: Option<f
|
||||
fade_out_and_quit(&app);
|
||||
}
|
||||
));
|
||||
overlay.add_controller(click_controller);
|
||||
background.add_controller(click_controller);
|
||||
|
||||
// Centered content box
|
||||
let content_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
@ -294,40 +409,12 @@ pub fn create_panel_window(texture: Option<&gdk::Texture>, blur_radius: Option<f
|
||||
window
|
||||
}
|
||||
|
||||
/// Shared cache for the GPU-blurred wallpaper texture.
|
||||
/// Computed once on first window realize, reused by all subsequent windows.
|
||||
type BlurCache = Rc<RefCell<Option<gdk::Texture>>>;
|
||||
|
||||
/// Create a Picture widget for the wallpaper background, optionally with GPU blur.
|
||||
/// When a blur_cache is provided, the blurred texture is computed once and shared.
|
||||
fn create_background_picture(
|
||||
texture: &gdk::Texture,
|
||||
blur_radius: Option<f32>,
|
||||
blur_cache: &BlurCache,
|
||||
) -> gtk::Picture {
|
||||
/// Create a Picture widget for the wallpaper background from a shared texture.
|
||||
fn create_background_picture(texture: &gdk::Texture) -> gtk::Picture {
|
||||
let background = gtk::Picture::for_paintable(texture);
|
||||
background.set_content_fit(gtk::ContentFit::Cover);
|
||||
background.set_hexpand(true);
|
||||
background.set_vexpand(true);
|
||||
|
||||
if let Some(sigma) = blur_radius.filter(|s| *s > 0.0) {
|
||||
let texture = texture.clone();
|
||||
let cache = blur_cache.clone();
|
||||
background.connect_realize(move |picture| {
|
||||
// Use cached blur if available, otherwise compute and cache
|
||||
if let Some(ref cached) = *cache.borrow() {
|
||||
log::debug!("Blur cache hit");
|
||||
picture.set_paintable(Some(cached));
|
||||
return;
|
||||
}
|
||||
log::debug!("Blur cache miss, rendering GPU blur");
|
||||
if let Some(blurred) = render_blurred_texture(picture, &texture, sigma) {
|
||||
picture.set_paintable(Some(&blurred));
|
||||
*cache.borrow_mut() = Some(blurred);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
background
|
||||
}
|
||||
|
||||
@ -497,7 +584,6 @@ fn execute_action(
|
||||
error_label: >k::Label,
|
||||
) {
|
||||
dismiss_confirm(confirm_area, confirm_box);
|
||||
log::debug!("Executing power action: {}", action_def.name);
|
||||
|
||||
let action_fn = action_def.action_fn;
|
||||
let action_name = action_def.name;
|
||||
@ -542,7 +628,6 @@ fn load_avatar_async(image: >k::Image, window: >k::ApplicationWindow, user:
|
||||
|
||||
match avatar_path {
|
||||
Some(path) => {
|
||||
log::debug!("Avatar source: file {}", path.display());
|
||||
// File-based avatar: load and scale in background thread
|
||||
glib::spawn_future_local(clone!(
|
||||
#[weak]
|
||||
@ -550,7 +635,7 @@ fn load_avatar_async(image: >k::Image, window: >k::ApplicationWindow, user:
|
||||
async move {
|
||||
let result = gio::spawn_blocking(move || {
|
||||
Pixbuf::from_file_at_scale(
|
||||
&path,
|
||||
path.to_str().unwrap_or(""),
|
||||
AVATAR_SIZE,
|
||||
AVATAR_SIZE,
|
||||
true,
|
||||
@ -568,7 +653,6 @@ fn load_avatar_async(image: >k::Image, window: >k::ApplicationWindow, user:
|
||||
));
|
||||
}
|
||||
None => {
|
||||
log::debug!("Avatar source: default SVG");
|
||||
// Default SVG avatar: needs widget color, keep synchronous
|
||||
set_default_avatar(image, window);
|
||||
}
|
||||
@ -693,4 +777,60 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Blur cache tests --
|
||||
|
||||
#[test]
|
||||
fn build_cache_meta_for_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("wallpaper.jpg");
|
||||
fs::write(&file, b"fake image").unwrap();
|
||||
let meta = build_cache_meta(&file, 20.0);
|
||||
assert!(meta.is_some());
|
||||
let meta = meta.unwrap();
|
||||
assert!(meta.contains("path="));
|
||||
assert!(meta.contains("size=10"));
|
||||
assert!(meta.contains("sigma=20"));
|
||||
assert!(meta.contains("mtime="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_cache_meta_for_gresource() {
|
||||
let path = Path::new("/dev/moonarch/moonset/wallpaper.jpg");
|
||||
let meta = build_cache_meta(path, 15.0);
|
||||
assert!(meta.is_some());
|
||||
let meta = meta.unwrap();
|
||||
assert!(meta.contains("binary_mtime="));
|
||||
assert!(meta.contains("sigma=15"));
|
||||
assert!(!meta.contains("size="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_cache_meta_missing_file() {
|
||||
let meta = build_cache_meta(Path::new("/nonexistent/wallpaper.jpg"), 20.0);
|
||||
assert!(meta.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_meta_mismatch_returns_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
dir.path().join(CACHE_META),
|
||||
"path=/old.jpg\nsize=100\nmtime=1\nsigma=20\n",
|
||||
).unwrap();
|
||||
let result = load_cached_blur(
|
||||
dir.path(),
|
||||
"path=/new.jpg\nsize=200\nmtime=2\nsigma=20\n",
|
||||
);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_missing_meta_returns_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = load_cached_blur(
|
||||
dir.path(),
|
||||
"path=/any.jpg\nsize=1\nmtime=1\nsigma=20\n",
|
||||
);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
91
src/power.rs
91
src/power.rs
@ -4,9 +4,7 @@
|
||||
use std::fmt;
|
||||
use std::io::Read;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const POWER_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
@ -32,12 +30,7 @@ impl fmt::Display for PowerError {
|
||||
impl std::error::Error for PowerError {}
|
||||
|
||||
/// Run a command with timeout and return a PowerError on failure.
|
||||
///
|
||||
/// Uses blocking `child.wait()` with a separate timeout thread that sends
|
||||
/// SIGKILL after POWER_TIMEOUT. This runs inside `gio::spawn_blocking`,
|
||||
/// so blocking is expected.
|
||||
fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(), PowerError> {
|
||||
log::debug!("Power action: {action} ({program} {args:?})");
|
||||
let mut child = Command::new(program)
|
||||
.args(args)
|
||||
.stdout(Stdio::piped())
|
||||
@ -48,73 +41,43 @@ fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(),
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
let child_pid = nix::unistd::Pid::from_raw(child.id() as i32);
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
let done_clone = done.clone();
|
||||
|
||||
let timeout_thread = std::thread::spawn(move || {
|
||||
// Sleep in short intervals so we can exit early when the child finishes
|
||||
let interval = Duration::from_millis(100);
|
||||
let mut elapsed = Duration::ZERO;
|
||||
while elapsed < POWER_TIMEOUT {
|
||||
std::thread::sleep(interval);
|
||||
if done_clone.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
elapsed += interval;
|
||||
}
|
||||
// ESRCH if the process already exited — harmless
|
||||
let _ = nix::sys::signal::kill(child_pid, nix::sys::signal::Signal::SIGKILL);
|
||||
});
|
||||
|
||||
let status = child.wait().map_err(|e| PowerError::CommandFailed {
|
||||
action,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
done.store(true, Ordering::Relaxed);
|
||||
let _ = timeout_thread.join();
|
||||
|
||||
if status.success() {
|
||||
log::debug!("Power action {action} completed");
|
||||
Ok(())
|
||||
} else {
|
||||
// Check if killed by our timeout (SIGKILL = signal 9)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
if status.signal() == Some(9) {
|
||||
return Err(PowerError::Timeout { action });
|
||||
}
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + POWER_TIMEOUT;
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
if !status.success() {
|
||||
let mut stderr_buf = String::new();
|
||||
if let Some(mut stderr) = child.stderr.take() {
|
||||
let _ = stderr.read_to_string(&mut stderr_buf);
|
||||
}
|
||||
Err(PowerError::CommandFailed {
|
||||
return Err(PowerError::CommandFailed {
|
||||
action,
|
||||
message: format!("exit code {}: {}", status, stderr_buf.trim()),
|
||||
})
|
||||
});
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Ok(None) => {
|
||||
if Instant::now() >= deadline {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(PowerError::Timeout { action });
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(PowerError::CommandFailed {
|
||||
action,
|
||||
message: e.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
log::debug!("Power action: lock (spawning moonlock)");
|
||||
Command::new("/usr/bin/moonlock")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| PowerError::CommandFailed {
|
||||
action: "lock",
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
// Child handle is dropped here — moonlock continues running independently.
|
||||
Ok(())
|
||||
run_command("lock", "/usr/bin/moonlock", &[])
|
||||
}
|
||||
|
||||
/// Quit the Niri compositor (logout).
|
||||
|
||||
26
src/users.rs
26
src/users.rs
@ -52,27 +52,18 @@ pub fn get_avatar_path_with(
|
||||
username: Option<&str>,
|
||||
accountsservice_dir: &Path,
|
||||
) -> Option<PathBuf> {
|
||||
// ~/.face takes priority — canonicalize to resolve symlinks
|
||||
// ~/.face takes priority
|
||||
let face = home.join(".face");
|
||||
if face.exists() {
|
||||
if let Ok(canonical) = std::fs::canonicalize(&face) {
|
||||
log::debug!("Avatar: using ~/.face ({})", canonical.display());
|
||||
return Some(canonical);
|
||||
}
|
||||
// canonicalize failed (e.g. permissions) — skip rather than return unresolved symlink
|
||||
log::warn!("Avatar: ~/.face exists but canonicalize failed, skipping");
|
||||
return Some(face);
|
||||
}
|
||||
|
||||
// AccountsService icon — also canonicalize for consistency
|
||||
// AccountsService icon
|
||||
if let Some(name) = username {
|
||||
if accountsservice_dir.exists() {
|
||||
let icon = accountsservice_dir.join(name);
|
||||
if icon.exists() {
|
||||
if let Ok(canonical) = std::fs::canonicalize(&icon) {
|
||||
log::debug!("Avatar: using AccountsService icon ({})", canonical.display());
|
||||
return Some(canonical);
|
||||
}
|
||||
log::warn!("Avatar: AccountsService icon exists but canonicalize failed, skipping");
|
||||
return Some(icon);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -107,8 +98,7 @@ mod tests {
|
||||
let face = dir.path().join(".face");
|
||||
fs::write(&face, "fake image").unwrap();
|
||||
let path = get_avatar_path_with(dir.path(), None, Path::new("/nonexistent"));
|
||||
let expected = fs::canonicalize(&face).unwrap();
|
||||
assert_eq!(path, Some(expected));
|
||||
assert_eq!(path, Some(face));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -119,8 +109,7 @@ mod tests {
|
||||
let icon = icons_dir.join("testuser");
|
||||
fs::write(&icon, "fake image").unwrap();
|
||||
let path = get_avatar_path_with(dir.path(), Some("testuser"), &icons_dir);
|
||||
let expected = fs::canonicalize(&icon).unwrap();
|
||||
assert_eq!(path, Some(expected));
|
||||
assert_eq!(path, Some(icon));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -133,8 +122,7 @@ mod tests {
|
||||
let icon = icons_dir.join("testuser");
|
||||
fs::write(&icon, "fake image").unwrap();
|
||||
let path = get_avatar_path_with(dir.path(), Some("testuser"), &icons_dir);
|
||||
let expected = fs::canonicalize(&face).unwrap();
|
||||
assert_eq!(path, Some(expected));
|
||||
assert_eq!(path, Some(face));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user