Compare commits
No commits in common. "main" and "v0.1.0" have entirely different histories.
@ -1,43 +0,0 @@
|
|||||||
# 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
|
|
||||||
17
.gitignore
vendored
17
.gitignore
vendored
@ -1,7 +1,10 @@
|
|||||||
/target
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
# makepkg build artifacts
|
*$py.class
|
||||||
pkg/src/
|
*.egg-info/
|
||||||
pkg/pkg/
|
dist/
|
||||||
pkg/*.pkg.tar*
|
build/
|
||||||
pkg/moonset/
|
.venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.pyright/
|
||||||
|
*.egg
|
||||||
|
|||||||
126
CHANGELOG.md
126
CHANGELOG.md
@ -1,126 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
|
||||||
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.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
|
|
||||||
|
|
||||||
- Fade-in/fade-out animation (250ms ease-in) for panel and wallpaper windows via CSS opacity transition
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Fix pixel format mismatch in blur path — `texture.download()` yields BGRA but was passed to `RgbaImage` without channel swap, now explicitly converts B↔R
|
|
||||||
- Logout action now calls `app.quit()` to dismiss the menu immediately (previously only Lock did)
|
|
||||||
- Log TOML parse errors to stderr instead of silently falling back to defaults
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Replace magic string `"lock"` comparison with `quit_after` field on `ActionDef` for type-safe action dispatch
|
|
||||||
- Remove `compressed="true"` from JPEG wallpaper in GResource — JPEG is already compressed, zlib overhead hurts startup for negligible size savings
|
|
||||||
|
|
||||||
## [0.4.0] - 2026-03-28
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Optional background blur via `background_blur` config option (Gaussian blur, `image` crate)
|
|
||||||
- Disk cache for blurred wallpaper (`~/.cache/moonset/`) — avoids re-blurring on subsequent starts
|
|
||||||
|
|
||||||
## [0.1.1] - 2026-03-28
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Use absolute paths for all system binaries (`systemctl`, `loginctl`, `niri`, `moonlock`) to prevent PATH hijacking
|
|
||||||
- Implement `POWER_TIMEOUT` (30s) via `try_wait()` polling — previously declared but unused, leaving power actions able to block indefinitely
|
|
||||||
- Prevent panic in `load_background_texture` when GResource path contains non-UTF-8 bytes — now falls back to known wallpaper resource
|
|
||||||
- Fix fallback user UID from `0` (root) to `u32::MAX` as a safe sentinel value
|
|
||||||
- Fix CSS comment incorrectly describing circular buttons as "square card"
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Compress wallpaper in GResource bundle (`compressed="true"`) to reduce binary size
|
|
||||||
- Merge double `idle_add_local_once` into single idle cycle for faster keyboard focus on launch
|
|
||||||
- Centralize `GRESOURCE_PREFIX` as `pub(crate) const` in `main.rs` (was duplicated in `config.rs`, `users.rs`, and literal strings in `panel.rs`)
|
|
||||||
- Translate README.md and config comments from German to English
|
|
||||||
- Remove stale `journal.md` (one-time development notes, not actively maintained)
|
|
||||||
|
|
||||||
## [0.1.0] - 2026-03-27
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Rust rewrite of the Python power menu (gtk4-rs + gtk4-layer-shell)
|
|
||||||
- 5 power actions: Lock, Logout, Hibernate, Reboot, Shutdown
|
|
||||||
- Inline confirmation for destructive actions (all except Lock)
|
|
||||||
- Multi-monitor wallpaper support via shared `gdk::Texture`
|
|
||||||
- DE/EN localization with automatic locale detection
|
|
||||||
- TOML configuration for custom wallpaper path
|
|
||||||
- GResource bundle for CSS, wallpaper, and default avatar
|
|
||||||
- Async power actions via `glib::spawn_future_local` + `gio::spawn_blocking`
|
|
||||||
- Async avatar loading (file-based avatars decoded off UI thread)
|
|
||||||
- Cached icon loading at startup
|
|
||||||
- 45 unit tests
|
|
||||||
43
CLAUDE.md
43
CLAUDE.md
@ -1,57 +1,52 @@
|
|||||||
# Moonset
|
# Moonset
|
||||||
|
|
||||||
|
**Name**: Hekate (Göttin der Wegkreuzungen — passend zum Power-Menu, das den Weg der Session bestimmt)
|
||||||
|
|
||||||
## Projekt
|
## Projekt
|
||||||
|
|
||||||
Moonset ist ein Wayland Session Power Menu, gebaut mit Rust + gtk4-rs + gtk4-layer-shell.
|
Moonset ist ein Wayland Session Power Menu, gebaut mit Python + GTK4 + gtk4-layer-shell.
|
||||||
Teil des Moonarch-Ökosystems. Per Keybind aufrufbares Overlay mit 5 Aktionen:
|
Teil des Moonarch-Ökosystems. Per Keybind aufrufbares Overlay mit 5 Aktionen:
|
||||||
Lock, Logout, Hibernate, Reboot, Shutdown.
|
Lock, Logout, Hibernate, Reboot, Shutdown.
|
||||||
|
|
||||||
## Tech-Stack
|
## Tech-Stack
|
||||||
|
|
||||||
- Rust (Edition 2024), gtk4-rs 0.11, glib 0.22
|
- Python 3.11+, PyGObject (GTK 4.0)
|
||||||
- gtk4-layer-shell 0.8 für Wayland Layer Shell (OVERLAY Layer)
|
- gtk4-layer-shell für Wayland Layer Shell (OVERLAY Layer)
|
||||||
- `cargo test` für Unit-Tests
|
- pytest für Tests
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
- `src/` — Rust-Quellcode (main.rs, power.rs, i18n.rs, config.rs, users.rs, panel.rs)
|
- `src/moonset/` — Quellcode
|
||||||
- `resources/` — GResource-Assets (style.css, default-avatar.svg)
|
- `src/moonset/data/` — Package-Assets (Fallback-Wallpaper)
|
||||||
|
- `tests/` — pytest Tests
|
||||||
- `config/` — Beispiel-Konfigurationsdateien
|
- `config/` — Beispiel-Konfigurationsdateien
|
||||||
|
|
||||||
## Kommandos
|
## Kommandos
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Tests ausführen
|
# Tests ausführen
|
||||||
cargo test
|
uv run pytest tests/ -v
|
||||||
|
|
||||||
# Release-Build
|
# Typ-Checks
|
||||||
cargo build --release
|
uv run pyright src/
|
||||||
|
|
||||||
# Power-Menu starten (in Niri-Session)
|
# Power-Menu starten (in Niri-Session)
|
||||||
LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonset
|
uv run moonset
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architektur
|
## 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
|
- `power.py` — 5 Power-Action-Wrapper (lock, logout, hibernate, reboot, shutdown)
|
||||||
- `power.rs` — 5 Power-Action-Wrapper mit absoluten Pfaden und 30s Timeout (lock, logout, hibernate, reboot, shutdown)
|
- `i18n.py` — Locale-Erkennung und String-Tabellen (DE/EN)
|
||||||
- `i18n.rs` — Locale-Erkennung und String-Tabellen (DE/EN)
|
- `config.py` — TOML-Config + Wallpaper-Fallback
|
||||||
- `config.rs` — TOML-Config + Wallpaper-Fallback
|
- `panel.py` — GTK4 UI (Action-Buttons, Inline-Confirmation, WallpaperWindow)
|
||||||
- `panel.rs` — GTK4 UI (Action-Buttons, Inline-Confirmation, WallpaperWindow)
|
- `main.py` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor
|
||||||
- `users.rs` — User-Erkennung, Avatar-Loading (AccountsService, ~/.face, GResource-Fallback)
|
- `style.css` — Catppuccin Mocha Theme
|
||||||
- `resources/style.css` — GTK-Theme-Colors für Konsistenz mit dem aktiven Desktop-Theme
|
|
||||||
|
|
||||||
## Design Decisions
|
## Design Decisions
|
||||||
|
|
||||||
Siehe `DECISIONS.md` für das vollständige Entscheidungsprotokoll.
|
|
||||||
|
|
||||||
Kurzfassung der wichtigsten Entscheidungen:
|
|
||||||
- **OVERLAY statt TOP Layer**: Waybar liegt auf TOP, moonset muss darüber
|
- **OVERLAY statt TOP Layer**: Waybar liegt auf TOP, moonset muss darüber
|
||||||
- **Niri-spezifischer Logout** (`niri msg action quit`): Moonarch setzt fest auf Niri
|
- **Niri-spezifischer Logout** (`niri msg action quit`): Moonarch setzt fest auf Niri
|
||||||
- **Einmal-Start per Keybind**: Kein Daemon, GTK `application_id` verhindert Doppelstart
|
- **Einmal-Start per Keybind**: Kein Daemon, GTK `application_id` verhindert Doppelstart
|
||||||
- **System-Icons**: Adwaita/Catppuccin liefern alle benötigten symbolischen Icons
|
- **System-Icons**: Adwaita/Catppuccin liefern alle benötigten symbolischen Icons
|
||||||
- **Lock ohne Confirmation**: Lock ist sofort reversibel, braucht kein Confirm
|
- **Lock ohne Confirmation**: Lock ist sofort reversibel, braucht kein Confirm
|
||||||
- **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)
|
|
||||||
- **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
|
|
||||||
|
|||||||
1233
Cargo.lock
generated
1233
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
31
Cargo.toml
31
Cargo.toml
@ -1,31 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "moonset"
|
|
||||||
version = "0.8.5"
|
|
||||||
edition = "2024"
|
|
||||||
description = "Wayland session power menu with GTK4 and Layer Shell"
|
|
||||||
license = "MIT"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
gtk4 = { version = "0.11", features = ["v4_10"] }
|
|
||||||
gtk4-layer-shell = "0.8"
|
|
||||||
glib = "0.22"
|
|
||||||
gdk4 = "0.11"
|
|
||||||
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" }
|
|
||||||
log = "0.4"
|
|
||||||
systemd-journal-logger = "2.2"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tempfile = "3"
|
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
lto = "thin"
|
|
||||||
codegen-units = 1
|
|
||||||
strip = true
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
glib-build-tools = "0.22"
|
|
||||||
108
DECISIONS.md
108
DECISIONS.md
@ -1,108 +0,0 @@
|
|||||||
# Decisions
|
|
||||||
|
|
||||||
Architectural and design decisions for Moonset, in reverse chronological order.
|
|
||||||
|
|
||||||
## 2026-04-24 – Audit LOW fixes: dead uid field, home_dir warn, clippy sweep, debug value (v0.8.5)
|
|
||||||
|
|
||||||
- **Who**: ClaudeCode, Dom
|
|
||||||
- **Why**: Five LOW findings cleared in one pass. (1) `User::uid` was populated from `getuid()` but never read — a compiler `dead_code` warning for a field on the public API. (2) Falling back to a synthetic user when `get_current_user()` returned None used `uid: u32::MAX`, an undocumented sentinel that became moot once uid was removed. (3) `dirs::home_dir().unwrap_or_default()` silently yielded `PathBuf::new()` on failure; avatars would then look for `.face` in the current working directory. (4) `cargo clippy` flagged three suggestions (two collapsible `if`, one redundant closure) that had crept in. (5) `MOONSET_DEBUG` promoted log verbosity on mere presence, leaking path information into the journal.
|
|
||||||
- **Tradeoffs**: Dropping `uid` from `User` is a minor API break for any internal caller expecting the field — none existed. The synthetic fallback now surfaces `log::warn!` when home resolution fails, which should be rare outside of pathological sandbox environments.
|
|
||||||
- **How**: (1) Remove `pub uid: u32` from `User` and the `uid: uid.as_raw()` assignment in `get_current_user`. (2) Panel fallback drops the `uid` field entirely. (3) `dirs::home_dir().unwrap_or_else(|| { log::warn!(...); PathBuf::new() })`. (4) `cargo clippy --fix` for the two collapsible ifs, manual collapse of `if-let` + `&&` chain, redundant closure replaced with the function itself. (5) `MOONSET_DEBUG` now requires the literal value `"1"` to escalate to Debug.
|
|
||||||
|
|
||||||
## 2026-04-24 – Audit MEDIUM fixes: timeout guard, POSIX locale, button desensitize, wallpaper allowlist (v0.8.4)
|
|
||||||
|
|
||||||
- **Who**: ClaudeCode, Dom
|
|
||||||
- **Why**: Five MEDIUM findings: (1) `run_command`'s timeout thread leaked a 30 s gio::spawn_blocking slot if `child.wait()` errored, because `done.store(true)` ran after the `?`. (2) Timeout detection compared `status.signal() == Some(9)` — a hardcoded signal number that also misclassifies OOM-killer SIGKILL as our timeout. (3) `execute_action` never desensitized the button_box, so a double-click or accidental keyboard repeat fired the action twice. (4) `detect_locale` read only `LANG`, ignoring POSIX priority order (`LC_ALL` > `LC_MESSAGES` > `LANG`) — a common dual-language setup picked the wrong UI language. (5) The wallpaper path was passed to gdk-pixbuf without extension or size restriction, widening the image-parser attack surface and allowing unbounded decode latency.
|
|
||||||
- **Tradeoffs**: The extension allowlist (`jpg`, `jpeg`, `png`, `webp`) rejects exotic formats users might have used before. The 10 MB size cap rejects uncompressed/high-quality 4K wallpapers; acceptable for a power menu. Memory ordering on the `done` flag is now `Release`/`Acquire` instead of `Relaxed` — no runtime cost but correct across threads.
|
|
||||||
- **How**: (1) RAII `DoneGuard` struct sets `done.store(true, Release)` in its `Drop`, so the flag fires on every function exit path. A second `timed_out` AtomicBool distinguishes our SIGKILL from an external one. (2) Replace `Some(9)` with the `timed_out` flag check. (3) `execute_action` now takes `button_box: >k::Box`, calls `set_sensitive(false)` on entry and re-enables it on error paths; success paths that quit skip the re-enable. All call sites updated. (4) `detect_locale` reads `LC_ALL`, `LC_MESSAGES`, `LANG` in order, picking the first non-empty value before falling back to `/etc/locale.conf`. (5) `accept_wallpaper` helper applies extension allowlist + symlink rejection + `MAX_WALLPAPER_FILE_SIZE = 10 MB`, and is called for both config-path and Moonarch fallback.
|
|
||||||
|
|
||||||
## 2026-04-24 – Audit fix: avoid latent stdout pipe deadlock in run_command (v0.8.3)
|
|
||||||
|
|
||||||
- **Who**: ClaudeCode, Dom
|
|
||||||
- **Why**: Audit found that `run_command` piped the child's `stdout` but never drained it, then called blocking `child.wait()`. A child writing more than one OS pipe buffer (~64 KB on Linux) would block on `write()` while the parent blocked in `wait()` — classic pipe deadlock, broken only by the 30 s SIGKILL timeout. Current callers (`systemctl`, `niri msg`, `loginctl`) do not emit that much output, but the structure was fragile and would bite on any future command or changed behaviour.
|
|
||||||
- **Tradeoffs**: stdout is now fully discarded. If a future caller needs stdout, it will have to drain it concurrently with `wait()` (separate reader thread).
|
|
||||||
- **How**: Replace `.stdout(Stdio::piped())` with `.stdout(Stdio::null())` in `run_command`. `stderr` stays piped — it is drained after `wait()`, which is safe because `wait()` already reaped the child and no further writes can occur.
|
|
||||||
|
|
||||||
## 2026-03-31 – Fourth audit: release profile, GResource compression, lock stderr, sync markers
|
|
||||||
|
|
||||||
- **Who**: ClaudeCode, Dom
|
|
||||||
- **Why**: Fourth triple audit found missing release profile (LTO/strip), uncompressed GResource assets, moonlock stderr suppressed (errors invisible), and duplicated code without sync markers.
|
|
||||||
- **Tradeoffs**: moonlock stderr now inherited instead of null — errors appear in moonset's journal context. Acceptable for debugging, no security leak since moonlock logs to its own journal identifier.
|
|
||||||
- **How**: (1) `[profile.release]` with LTO, codegen-units=1, strip. (2) `compressed="true"` on GResource entries. (3) `Stdio::inherit()` for moonlock stderr in lock(). (4) SYNC comments on duplicated blur/background functions.
|
|
||||||
|
|
||||||
## 2026-03-28 – Remove wallpaper from GResource bundle
|
|
||||||
|
|
||||||
- **Who**: ClaudeCode, Dom
|
|
||||||
- **Why**: All three Moon projects (moonset, moongreet, moonlock) embedded a 374kB fallback wallpaper in the binary via GResource. Moonarch already installs `/usr/share/moonarch/wallpaper.jpg` at system setup time, making the embedded fallback unnecessary dead weight (~2MB in binary size).
|
|
||||||
- **Tradeoffs**: If `/usr/share/moonarch/wallpaper.jpg` is missing and no user config exists, moonset shows a solid CSS background instead of a wallpaper. Acceptable — the power menu is functional without a wallpaper image.
|
|
||||||
- **How**: Removed `wallpaper.jpg` from GResource XML and resources directory. `resolve_background_path` returns `Option<PathBuf>`. All wallpaper-related functions handle `None` gracefully. Binary size dropped from ~3.2MB to ~1.3MB.
|
|
||||||
|
|
||||||
## 2026-03-28 – Switch from env_logger to systemd-journal-logger
|
|
||||||
|
|
||||||
- **Who**: ClaudeCode, Dom
|
|
||||||
- **Why**: moonlock and moongreet already use systemd-journal-logger. moonset used env_logger which writes to stderr — not useful for a GUI app launched via keybind. Journal integration enables `journalctl -t moonset` and consistent troubleshooting across all three Moon projects.
|
|
||||||
- **Tradeoffs**: Requires systemd at runtime. Graceful fallback to eprintln if journal logger fails. Acceptable since Moonarch targets systemd-based Arch Linux.
|
|
||||||
- **How**: Replace `env_logger` dep with `systemd-journal-logger`, add `setup_logging()` with `MOONSET_DEBUG` env var for debug-level output. Same pattern as moonlock/moongreet.
|
|
||||||
|
|
||||||
## 2026-03-28 – Replace action name dispatch with `quit_after` field
|
|
||||||
|
|
||||||
- **Who**: ClaudeCode, Dom
|
|
||||||
- **Why**: Post-action behavior (quit the app or not) was controlled by comparing `action_name == "lock"` — a magic string duplicated from the action definition. Renaming an action would silently break the dispatch.
|
|
||||||
- **Tradeoffs**: Adds a field to `ActionDef` that most actions set to `false`. Acceptable because it makes the contract explicit and testable.
|
|
||||||
- **How**: `ActionDef.quit_after: bool` — `true` for lock and logout, `false` for hibernate/reboot/shutdown.
|
|
||||||
|
|
||||||
## 2026-03-28 – GPU blur via GskBlurNode replaces CPU blur
|
|
||||||
|
|
||||||
- **Who**: ClaudeCode, Dom
|
|
||||||
- **Why**: CPU-side Gaussian blur (`image` crate) blocked startup and added caching complexity. moonlock already migrated to GPU blur.
|
|
||||||
- **Tradeoffs**: GPU blur quality is slightly different (box-blur approximation vs true Gaussian), acceptable for wallpaper backgrounds. Removes `image` crate dependency entirely. No disk cache needed.
|
|
||||||
- **How**: `Snapshot::push_blur()` + `GskRenderer::render_texture()` on `connect_realize`. Blur happens once on the GPU when the widget gets its renderer. Symmetric with moonlock and moongreet.
|
|
||||||
|
|
||||||
## 2026-03-28 – Use absolute paths for system binaries
|
|
||||||
|
|
||||||
- **Who**: ClaudeCode, Dom
|
|
||||||
- **Why**: Security audit flagged PATH hijacking risk — relative binary names allow a malicious `$PATH` entry to intercept `systemctl`, `loginctl`, etc.
|
|
||||||
- **Tradeoffs**: Hardcoded paths reduce portability to non-Arch distros where binaries may live elsewhere (e.g. `/sbin/`). Acceptable because Moonarch targets Arch Linux exclusively.
|
|
||||||
- **How**: All five power action wrappers now use `/usr/bin/` prefixed paths.
|
|
||||||
|
|
||||||
## 2026-03-28 – Implement power action timeout via try_wait polling
|
|
||||||
|
|
||||||
- **Who**: ClaudeCode, Dom
|
|
||||||
- **Why**: `POWER_TIMEOUT` and `PowerError::Timeout` were declared but never wired up. A hanging `systemctl hibernate` (e.g. blocked NFS mount) would freeze the power menu indefinitely.
|
|
||||||
- **Tradeoffs**: Polling with `try_wait()` + 100ms sleep is slightly less efficient than a dedicated timeout crate, but avoids adding a dependency for a single use case.
|
|
||||||
- **How**: `run_command` now polls `child.try_wait()` against a 30s deadline, kills the child on timeout.
|
|
||||||
|
|
||||||
## 2026-03-28 – Centralize GRESOURCE_PREFIX
|
|
||||||
|
|
||||||
- **Who**: ClaudeCode, Dom
|
|
||||||
- **Why**: The string `/dev/moonarch/moonset` was duplicated in `config.rs`, `users.rs`, and as literal strings in `panel.rs` and `main.rs`. Changing the application ID would require edits in 4+ locations.
|
|
||||||
- **Tradeoffs**: Modules now depend on `crate::GRESOURCE_PREFIX` — tighter coupling to main.rs, but acceptable for an internal constant.
|
|
||||||
- **How**: Single `pub(crate) const GRESOURCE_PREFIX` in `main.rs`, referenced everywhere else.
|
|
||||||
|
|
||||||
## 2026-03-28 – Remove journal.md
|
|
||||||
|
|
||||||
- **Who**: ClaudeCode, Dom
|
|
||||||
- **Why**: One-time development notes from the Rust rewrite, never updated after initial session. Overlapped with memory system and git history.
|
|
||||||
- **Tradeoffs**: Historical context lost from the file, but the information is preserved in git history and the memory system.
|
|
||||||
- **How**: Deleted. Useful technical learnings migrated to persistent memory.
|
|
||||||
|
|
||||||
## 2026-03-27 – OVERLAY layer instead of TOP
|
|
||||||
|
|
||||||
- **Who**: ClaudeCode, Dom
|
|
||||||
- **Why**: Waybar occupies the TOP layer. The power menu must appear above it.
|
|
||||||
- **Tradeoffs**: OVERLAY is the highest layer — nothing can render above moonset while it's open. This is intentional for a session power menu.
|
|
||||||
- **How**: `setup_layer_shell` uses `gtk4_layer_shell::Layer::Overlay` for the panel window.
|
|
||||||
|
|
||||||
## 2026-03-27 – Lock without confirmation
|
|
||||||
|
|
||||||
- **Who**: ClaudeCode, Dom
|
|
||||||
- **Why**: Lock is immediately reversible (just unlock). All other actions (logout, hibernate, reboot, shutdown) are destructive or disruptive.
|
|
||||||
- **Tradeoffs**: One less click for the most common action. Risk of accidental lock is negligible since unlocking is trivial.
|
|
||||||
- **How**: `ActionDef.needs_confirm = false` for lock; all others require inline confirmation.
|
|
||||||
|
|
||||||
## 2026-03-27 – Niri-specific logout via `niri msg action quit`
|
|
||||||
|
|
||||||
- **Who**: ClaudeCode, Dom
|
|
||||||
- **Why**: Moonarch is built exclusively for the Niri compositor. Generic Wayland logout mechanisms don't exist — each compositor has its own.
|
|
||||||
- **Tradeoffs**: Hard dependency on Niri. If the compositor changes, `power::logout()` must be updated.
|
|
||||||
- **How**: `Command::new("/usr/bin/niri").args(["msg", "action", "quit"])`.
|
|
||||||
59
README.md
59
README.md
@ -1,69 +1,62 @@
|
|||||||
# Moonset
|
# Moonset
|
||||||
|
|
||||||
Wayland Session Power Menu for the Moonarch ecosystem.
|
Wayland Session Power Menu für das Moonarch-Ökosystem.
|
||||||
|
|
||||||
A fullscreen overlay triggered by keybind with 5 actions:
|
Per Keybind aufrufbares Fullscreen-Overlay mit 5 Aktionen:
|
||||||
**Lock** · **Logout** · **Hibernate** · **Reboot** · **Shutdown**
|
**Lock** · **Logout** · **Hibernate** · **Reboot** · **Shutdown**
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Rust + gtk4-rs + gtk4-layer-shell (OVERLAY layer — above Waybar)
|
- GTK4 + gtk4-layer-shell (OVERLAY Layer — über Waybar)
|
||||||
- Catppuccin Mocha theme
|
- Catppuccin Mocha Theme
|
||||||
- Multi-monitor support (wallpaper on secondary monitors)
|
- Multi-Monitor-Support (Wallpaper auf Sekundärmonitoren)
|
||||||
- Inline confirmation for destructive actions
|
- Inline-Confirmation für destruktive Aktionen
|
||||||
- Escape or background click to dismiss
|
- Escape oder Hintergrund-Klick zum Schließen
|
||||||
- DE/EN localization
|
- DE/EN Lokalisierung
|
||||||
- Configurable wallpaper (TOML)
|
- Konfigurierbare Wallpaper (TOML)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build --release
|
uv pip install .
|
||||||
install -Dm755 target/release/moonset /usr/bin/moonset
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Or via PKGBUILD:
|
## Verwendung
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd pkg && makepkg -si
|
# Direkt starten
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Launch directly
|
|
||||||
moonset
|
moonset
|
||||||
|
|
||||||
# Via Niri keybind (in ~/.config/niri/config.kdl)
|
# Per Niri-Keybind (in ~/.config/niri/config.kdl)
|
||||||
# binds {
|
# binds {
|
||||||
# Mod+Escape { spawn "moonset"; }
|
# Mod+Escape { spawn "moonset"; }
|
||||||
# }
|
# }
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Konfiguration
|
||||||
|
|
||||||
Config file: `~/.config/moonset/moonset.toml` or `/etc/moonset/moonset.toml`
|
Konfigurationsdatei: `~/.config/moonset/moonset.toml` oder `/etc/moonset/moonset.toml`
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
# Path to background image (optional)
|
# Pfad zum Hintergrundbild (optional)
|
||||||
background_path = "/usr/share/moonarch/wallpaper.jpg"
|
background_path = "/usr/share/moonarch/wallpaper.jpg"
|
||||||
```
|
```
|
||||||
|
|
||||||
Wallpaper fallback: config → `/usr/share/moonarch/wallpaper.jpg` → bundled package wallpaper
|
Wallpaper-Fallback: Konfiguration → `/usr/share/moonarch/wallpaper.jpg` → Package-Wallpaper
|
||||||
|
|
||||||
## Development
|
## Entwicklung
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Tests
|
# Tests
|
||||||
cargo test
|
uv run pytest tests/ -v
|
||||||
|
|
||||||
# Release build
|
# Type-Check
|
||||||
cargo build --release
|
uv run pyright src/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Part of the Moonarch ecosystem
|
## Teil des Moonarch-Ökosystems
|
||||||
|
|
||||||
- **moonarch** — Reproducible Arch Linux setup
|
- **moonarch** — Reproduzierbares Arch-Linux-Setup
|
||||||
- **moongreet** — greetd greeter for Wayland
|
- **moongreet** — greetd Greeter für Wayland
|
||||||
- **moonlock** — Wayland lockscreen
|
- **moonlock** — Wayland Lockscreen
|
||||||
- **moonset** — Session power menu
|
- **moonset** — Session Power Menu
|
||||||
|
|||||||
10
build.rs
10
build.rs
@ -1,10 +0,0 @@
|
|||||||
// ABOUTME: Build script for compiling GResource bundle.
|
|
||||||
// ABOUTME: Bundles style.css and default-avatar.svg into the binary.
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
glib_build_tools::compile_resources(
|
|
||||||
&["resources"],
|
|
||||||
"resources/resources.gresource.xml",
|
|
||||||
"moonset.gresource",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
# Moonset — Wayland Session Power Menu
|
# Moonset — Wayland Session Power Menu
|
||||||
# Config file: ~/.config/moonset/moonset.toml or /etc/moonset/moonset.toml
|
# Konfigurationsdatei: ~/.config/moonset/moonset.toml oder /etc/moonset/moonset.toml
|
||||||
|
|
||||||
# Path to background image (optional)
|
# Pfad zum Hintergrundbild (optional)
|
||||||
# Fallback order: config → /usr/share/moonarch/wallpaper.jpg → bundled package wallpaper
|
# Fallback-Reihenfolge: config → /usr/share/moonarch/wallpaper.jpg → Package-Wallpaper
|
||||||
# background_path = "/usr/share/moonarch/wallpaper.jpg"
|
# background_path = "/usr/share/moonarch/wallpaper.jpg"
|
||||||
|
|||||||
42
pkg/PKGBUILD
42
pkg/PKGBUILD
@ -1,42 +0,0 @@
|
|||||||
# ABOUTME: PKGBUILD for Moonset — Wayland session power menu.
|
|
||||||
# ABOUTME: Builds from git source with automatic version detection.
|
|
||||||
|
|
||||||
# Maintainer: Dominik Kressler
|
|
||||||
|
|
||||||
pkgname=moonset-git
|
|
||||||
pkgver=0.1.0.r8.g934a923
|
|
||||||
pkgrel=1
|
|
||||||
pkgdesc="A Wayland session power menu with GTK4 and Layer Shell"
|
|
||||||
arch=('x86_64')
|
|
||||||
url="https://gitea.moonarch.de/nevaforget/moonset"
|
|
||||||
license=('MIT')
|
|
||||||
depends=(
|
|
||||||
'gtk4'
|
|
||||||
'gtk4-layer-shell'
|
|
||||||
)
|
|
||||||
makedepends=(
|
|
||||||
'git'
|
|
||||||
'cargo'
|
|
||||||
)
|
|
||||||
provides=('moonset')
|
|
||||||
conflicts=('moonset')
|
|
||||||
source=("git+${url}.git")
|
|
||||||
sha256sums=('SKIP')
|
|
||||||
|
|
||||||
pkgver() {
|
|
||||||
cd "$srcdir/moonset"
|
|
||||||
git describe --long --tags | sed 's/^v//;s/-/.r/;s/-/./'
|
|
||||||
}
|
|
||||||
|
|
||||||
build() {
|
|
||||||
cd "$srcdir/moonset"
|
|
||||||
cargo build --release --locked
|
|
||||||
}
|
|
||||||
|
|
||||||
package() {
|
|
||||||
cd "$srcdir/moonset"
|
|
||||||
install -Dm755 target/release/moonset "$pkgdir/usr/bin/moonset"
|
|
||||||
|
|
||||||
# Example config
|
|
||||||
install -Dm644 config/moonset.toml "$pkgdir/etc/moonset/moonset.toml.example"
|
|
||||||
}
|
|
||||||
30
pyproject.toml
Normal file
30
pyproject.toml
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "moonset"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Wayland session power menu with GTK4 and Layer Shell"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
license = "MIT"
|
||||||
|
dependencies = [
|
||||||
|
"PyGObject>=3.46",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
moonset = "moonset.main:main"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/moonset"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
|
||||||
|
[tool.pyright]
|
||||||
|
pythonVersion = "3.11"
|
||||||
|
pythonPlatform = "Linux"
|
||||||
|
venvPath = "."
|
||||||
|
venv = ".venv"
|
||||||
|
typeCheckingMode = "standard"
|
||||||
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 512 512"><path fill="#PLACEHOLDER" d="M256 23c-16.076 0-32.375 3.73-48.178 10.24c-2.126 6.525-3.877 14.76-4.877 23.754c-1.31 11.79-1.73 24.706-1.87 36.819c33.864-3.704 75.986-3.704 109.85 0c-.14-12.113-.56-25.03-1.87-36.82c-1-8.992-2.75-17.228-4.877-23.753C288.375 26.73 272.076 23 256 23m100.564 19.332c9.315 7.054 18.107 14.878 26.282 23.234c1.53-6.65 4.69-12.696 9.03-17.695zm-170.03 1.49c-34.675 20.22-65.047 52.714-82.552 86.334c-33.08 63.536-39.69 156.956-.53 214.8C132.786 388.278 200.276 405 256 405s123.215-16.72 152.547-60.045c39.162-57.843 32.55-151.263-.53-214.8c-17.504-33.62-47.876-66.112-82.55-86.333c.578 3.65 1.057 7.388 1.478 11.184c1.522 13.694 1.912 28.197 2.014 41.267C347.664 99.427 362 104 368 110c32 32 75.537 134.695 16 224c-37.654 56.48-218.346 56.48-256 0c-59.537-89.305-16-192 16-224c6-6 20.335-10.573 39.04-13.727c.103-13.07.493-27.573 2.015-41.267c.42-3.796.9-7.534 1.478-11.184zM64 48c-8.837 0-16 7.163-16 16a16 16 0 0 0 7 13.227V145.5L73 132V77.21A16 16 0 0 0 80 64c0-8.837-7.163-16-16-16m358.81 3.68c-12.81 0-23 10.19-23 23s10.19 23 23 23s23-10.19 23-23s-10.19-23-23-23m25.272 55.205c-6.98 5.497-15.758 8.795-25.27 8.795c-.745 0-1.48-.027-2.214-.067a217 217 0 0 1 2.38 4.37l29.852 22.39zm-238.822 2.5c-17.257.09-37.256 3.757-53.233 16.12c-26.634 20.608-43.034 114.763-33.49 146.763c16.584-61.767 31.993-124.02 107.92-161.274a133.5 133.5 0 0 0-21.197-1.61zm-135.055 44.21L40.15 179.138l-14.48 72.408l38.18 45.814c-10.947-46.523-5.776-98.723 10.355-143.764zm363.59 0c16.13 45.042 21.302 97.242 10.355 143.764l38.18-45.815l-14.48-72.408zM106.645 375.93c-3.583 1.17-7.252 3.406-10.282 6.435c-4.136 4.136-6.68 9.43-7.164 14.104c.21.364.603 1.157 1.73 2.162c2.453 2.188 6.693 5.17 12.127 8.358c10.867 6.38 26.55 13.757 44.205 20.623c21.177 8.237 45.35 15.704 67.738 20.38v-27.61c-39.47-5.12-79.897-18.325-108.355-44.452zm298.71 0C376.897 402.055 336.47 415.26 297 420.38v27.61c22.387-4.676 46.56-12.143 67.738-20.38c17.655-6.865 33.338-14.243 44.205-20.622c5.434-3.19 9.674-6.17 12.127-8.36c1.127-1.004 1.52-1.797 1.73-2.16c-.482-4.675-3.027-9.97-7.163-14.105c-3.03-3.03-6.7-5.264-10.282-6.435zM77.322 410.602L18 450.15V494h37v-18h18v18h366v-18h18v18h37v-43.85l-59.322-39.548c-.537.488-1.08.97-1.623 1.457c-3.922 3.497-8.932 6.89-14.998 10.452c-12.133 7.12-28.45 14.743-46.795 21.877C334.572 458.656 290.25 471 256 471s-78.572-12.343-115.262-26.61c-18.345-7.135-34.662-14.757-46.795-21.878c-6.066-3.56-11.076-6.955-14.998-10.453c-.543-.487-1.086-.97-1.623-1.458zM233 422.184v28.992c8.236 1.162 16.012 1.824 23 1.824s14.764-.662 23-1.824v-28.992a325 325 0 0 1-46 0"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.6 KiB |
@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<gresources>
|
|
||||||
<gresource prefix="/dev/moonarch/moonset">
|
|
||||||
<file compressed="true">style.css</file>
|
|
||||||
<file compressed="true">default-avatar.svg</file>
|
|
||||||
</gresource>
|
|
||||||
</gresources>
|
|
||||||
@ -1,117 +0,0 @@
|
|||||||
/* ABOUTME: GTK4 CSS stylesheet for the Moonset power menu. */
|
|
||||||
/* ABOUTME: Uses GTK theme colors for consistency with the active desktop theme. */
|
|
||||||
|
|
||||||
/* Main panel window background */
|
|
||||||
window.panel {
|
|
||||||
background-color: @theme_bg_color;
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 250ms ease-in;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.panel.visible {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Wallpaper-only window for secondary monitors */
|
|
||||||
window.wallpaper {
|
|
||||||
background-color: @theme_bg_color;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 250ms ease-in;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.wallpaper.visible {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Round avatar image */
|
|
||||||
.avatar {
|
|
||||||
border-radius: 9999px;
|
|
||||||
min-width: 128px;
|
|
||||||
min-height: 128px;
|
|
||||||
background-color: @theme_selected_bg_color;
|
|
||||||
border: 3px solid alpha(white, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Username label */
|
|
||||||
.username-label {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: white;
|
|
||||||
margin-top: 12px;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Action button — circular card */
|
|
||||||
.action-button {
|
|
||||||
min-width: 120px;
|
|
||||||
min-height: 120px;
|
|
||||||
padding: 16px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background-color: alpha(@theme_base_color, 0.55);
|
|
||||||
color: @theme_fg_color;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-button:hover {
|
|
||||||
background-color: alpha(@theme_base_color, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Action icon inside button — request 48px from theme, scale up via CSS */
|
|
||||||
.action-icon {
|
|
||||||
color: @theme_fg_color;
|
|
||||||
-gtk-icon-size: 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Action label below icon */
|
|
||||||
.action-label {
|
|
||||||
font-size: 14px;
|
|
||||||
color: @theme_unfocused_fg_color;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Confirmation box below action buttons */
|
|
||||||
.confirm-box {
|
|
||||||
padding: 16px 24px;
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Confirmation prompt text */
|
|
||||||
.confirm-label {
|
|
||||||
font-size: 16px;
|
|
||||||
color: @theme_fg_color;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Confirm "Yes" button */
|
|
||||||
.confirm-yes {
|
|
||||||
padding: 8px 24px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background-color: @error_color;
|
|
||||||
color: @theme_bg_color;
|
|
||||||
border: none;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-yes:hover {
|
|
||||||
background-color: lighter(@error_color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Confirm "No/Cancel" button */
|
|
||||||
.confirm-no {
|
|
||||||
padding: 8px 24px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background-color: @theme_unfocused_bg_color;
|
|
||||||
color: @theme_fg_color;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-no:hover {
|
|
||||||
background-color: @theme_selected_bg_color;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Error message label */
|
|
||||||
.error-label {
|
|
||||||
color: @error_color;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
306
src/config.rs
306
src/config.rs
@ -1,306 +0,0 @@
|
|||||||
// ABOUTME: Configuration loading for the session power menu.
|
|
||||||
// ABOUTME: Reads moonset.toml for wallpaper settings with fallback hierarchy.
|
|
||||||
|
|
||||||
use serde::Deserialize;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
const MOONARCH_WALLPAPER: &str = "/usr/share/moonarch/wallpaper.jpg";
|
|
||||||
|
|
||||||
/// Default config search paths: system-wide, then user-specific.
|
|
||||||
fn default_config_paths() -> Vec<PathBuf> {
|
|
||||||
let mut paths = vec![PathBuf::from("/etc/moonset/moonset.toml")];
|
|
||||||
if let Some(config_dir) = dirs::config_dir() {
|
|
||||||
paths.push(config_dir.join("moonset").join("moonset.toml"));
|
|
||||||
}
|
|
||||||
paths
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Power menu configuration.
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize)]
|
|
||||||
pub struct Config {
|
|
||||||
pub background_path: Option<String>,
|
|
||||||
pub background_blur: Option<f32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load config from TOML files. Later paths override earlier ones.
|
|
||||||
pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
|
|
||||||
let default_paths = default_config_paths();
|
|
||||||
let paths = config_paths.unwrap_or(&default_paths);
|
|
||||||
|
|
||||||
let mut merged = Config::default();
|
|
||||||
for path in paths {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
// Validate blur per source — invalid values preserve the previous default
|
|
||||||
if parsed.background_blur.is_some_and(|b| b.is_finite() && (0.0..=200.0).contains(&b)) {
|
|
||||||
merged.background_blur = parsed.background_blur;
|
|
||||||
} else if parsed.background_blur.is_some() {
|
|
||||||
log::warn!("Invalid background_blur in {}, ignoring", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("Failed to parse {}: {e}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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> {
|
|
||||||
resolve_background_path_with(config, Path::new(MOONARCH_WALLPAPER))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wallpapers are passed to gdk-pixbuf's image loader; restrict to common image
|
|
||||||
/// extensions to reduce the parser-attack surface for user-controlled paths.
|
|
||||||
const ALLOWED_BG_EXT: &[&str] = &["jpg", "jpeg", "png", "webp"];
|
|
||||||
|
|
||||||
/// Bound wallpaper decode latency (10 MB covers typical 4K JPEGs at Q95).
|
|
||||||
const MAX_WALLPAPER_FILE_SIZE: u64 = 10 * 1024 * 1024;
|
|
||||||
|
|
||||||
fn is_allowed_wallpaper(path: &Path) -> bool {
|
|
||||||
match path.extension().and_then(|e| e.to_str()) {
|
|
||||||
Some(ext) => ALLOWED_BG_EXT.iter().any(|a| a.eq_ignore_ascii_case(ext)),
|
|
||||||
None => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn accept_wallpaper(path: &Path) -> bool {
|
|
||||||
if !is_allowed_wallpaper(path) {
|
|
||||||
log::warn!("Wallpaper rejected (extension not in allowlist): {}", path.display());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
match path.symlink_metadata() {
|
|
||||||
Ok(meta) if meta.file_type().is_symlink() => {
|
|
||||||
log::warn!("Wallpaper rejected (symlink): {}", path.display());
|
|
||||||
false
|
|
||||||
}
|
|
||||||
Ok(meta) if !meta.is_file() => false,
|
|
||||||
Ok(meta) if meta.len() > MAX_WALLPAPER_FILE_SIZE => {
|
|
||||||
log::warn!(
|
|
||||||
"Wallpaper rejected ({} bytes > {} limit): {}",
|
|
||||||
meta.len(), MAX_WALLPAPER_FILE_SIZE, path.display()
|
|
||||||
);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
Ok(_) => true,
|
|
||||||
Err(_) => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve with configurable moonarch wallpaper path (for testing).
|
|
||||||
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> Option<PathBuf> {
|
|
||||||
// User-configured path — reject symlinks, non-image extensions, and oversized files
|
|
||||||
if let Some(ref bg) = config.background_path {
|
|
||||||
let path = PathBuf::from(bg);
|
|
||||||
if accept_wallpaper(&path) {
|
|
||||||
log::debug!("Wallpaper source: config ({})", path.display());
|
|
||||||
return Some(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Moonarch ecosystem default — apply the same checks for consistency
|
|
||||||
if accept_wallpaper(moonarch_wallpaper) {
|
|
||||||
log::debug!("Wallpaper source: moonarch default ({})", moonarch_wallpaper.display());
|
|
||||||
return Some(moonarch_wallpaper.to_path_buf());
|
|
||||||
}
|
|
||||||
|
|
||||||
log::debug!("No wallpaper found, using CSS background");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_config_has_none_background() {
|
|
||||||
let config = Config::default();
|
|
||||||
assert!(config.background_path.is_none());
|
|
||||||
assert!(config.background_blur.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_config_returns_default_when_no_files_exist() {
|
|
||||||
let paths = vec![PathBuf::from("/nonexistent/moonset.toml")];
|
|
||||||
let config = load_config(Some(&paths));
|
|
||||||
assert!(config.background_path.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_config_reads_background_path() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let conf = dir.path().join("moonset.toml");
|
|
||||||
fs::write(&conf, "background_path = \"/custom/wallpaper.jpg\"\n").unwrap();
|
|
||||||
let paths = vec![conf];
|
|
||||||
let config = load_config(Some(&paths));
|
|
||||||
assert_eq!(config.background_path.as_deref(), Some("/custom/wallpaper.jpg"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_config_reads_background_blur() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let conf = dir.path().join("moonset.toml");
|
|
||||||
fs::write(&conf, "background_blur = 20.0\n").unwrap();
|
|
||||||
let paths = vec![conf];
|
|
||||||
let config = load_config(Some(&paths));
|
|
||||||
assert_eq!(config.background_blur, Some(20.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_config_blur_override() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let conf1 = dir.path().join("first.toml");
|
|
||||||
let conf2 = dir.path().join("second.toml");
|
|
||||||
fs::write(&conf1, "background_blur = 10.0\n").unwrap();
|
|
||||||
fs::write(&conf2, "background_blur = 25.0\n").unwrap();
|
|
||||||
let paths = vec![conf1, conf2];
|
|
||||||
let config = load_config(Some(&paths));
|
|
||||||
assert_eq!(config.background_blur, Some(25.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_config_later_paths_override_earlier() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let conf1 = dir.path().join("first.toml");
|
|
||||||
let conf2 = dir.path().join("second.toml");
|
|
||||||
fs::write(&conf1, "background_path = \"/first.jpg\"\n").unwrap();
|
|
||||||
fs::write(&conf2, "background_path = \"/second.jpg\"\n").unwrap();
|
|
||||||
let paths = vec![conf1, conf2];
|
|
||||||
let config = load_config(Some(&paths));
|
|
||||||
assert_eq!(config.background_path.as_deref(), Some("/second.jpg"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_config_skips_missing_files() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let conf = dir.path().join("exists.toml");
|
|
||||||
fs::write(&conf, "background_path = \"/exists.jpg\"\n").unwrap();
|
|
||||||
let paths = vec![PathBuf::from("/nonexistent.toml"), conf];
|
|
||||||
let config = load_config(Some(&paths));
|
|
||||||
assert_eq!(config.background_path.as_deref(), Some("/exists.jpg"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_uses_config_path_when_file_exists() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let wallpaper = dir.path().join("custom.jpg");
|
|
||||||
fs::write(&wallpaper, "fake").unwrap();
|
|
||||||
let config = Config {
|
|
||||||
background_path: Some(wallpaper.to_str().unwrap().to_string()),
|
|
||||||
..Config::default()
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
resolve_background_path_with(&config, Path::new("/nonexistent")),
|
|
||||||
Some(wallpaper)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_ignores_config_path_when_file_missing() {
|
|
||||||
let config = Config {
|
|
||||||
background_path: Some("/nonexistent/wallpaper.jpg".to_string()),
|
|
||||||
..Config::default()
|
|
||||||
};
|
|
||||||
let result = resolve_background_path_with(&config, Path::new("/nonexistent"));
|
|
||||||
assert_eq!(result, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_uses_moonarch_wallpaper_as_second_fallback() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_returns_none_when_no_wallpaper_available() {
|
|
||||||
let config = Config::default();
|
|
||||||
let result = resolve_background_path_with(&config, Path::new("/nonexistent"));
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
322
src/i18n.rs
322
src/i18n.rs
@ -1,322 +0,0 @@
|
|||||||
// ABOUTME: Locale detection and string lookup for the power menu UI.
|
|
||||||
// ABOUTME: Reads system locale (LANG or /etc/locale.conf) and provides DE or EN strings.
|
|
||||||
|
|
||||||
use std::env;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
const DEFAULT_LOCALE_CONF: &str = "/etc/locale.conf";
|
|
||||||
|
|
||||||
/// All user-visible strings for the power menu UI.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Strings {
|
|
||||||
// Button labels
|
|
||||||
pub lock_label: &'static str,
|
|
||||||
pub logout_label: &'static str,
|
|
||||||
pub hibernate_label: &'static str,
|
|
||||||
pub reboot_label: &'static str,
|
|
||||||
pub shutdown_label: &'static str,
|
|
||||||
|
|
||||||
// Confirmation prompts
|
|
||||||
pub logout_confirm: &'static str,
|
|
||||||
pub hibernate_confirm: &'static str,
|
|
||||||
pub reboot_confirm: &'static str,
|
|
||||||
pub shutdown_confirm: &'static str,
|
|
||||||
|
|
||||||
// Confirmation buttons
|
|
||||||
pub confirm_yes: &'static str,
|
|
||||||
pub confirm_no: &'static str,
|
|
||||||
|
|
||||||
// Error messages
|
|
||||||
pub lock_failed: &'static str,
|
|
||||||
pub logout_failed: &'static str,
|
|
||||||
pub hibernate_failed: &'static str,
|
|
||||||
pub reboot_failed: &'static str,
|
|
||||||
pub shutdown_failed: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
const STRINGS_DE: Strings = Strings {
|
|
||||||
lock_label: "Sperren",
|
|
||||||
logout_label: "Abmelden",
|
|
||||||
hibernate_label: "Ruhezustand",
|
|
||||||
reboot_label: "Neustart",
|
|
||||||
shutdown_label: "Herunterfahren",
|
|
||||||
logout_confirm: "Wirklich abmelden?",
|
|
||||||
hibernate_confirm: "Wirklich in den Ruhezustand?",
|
|
||||||
reboot_confirm: "Wirklich neu starten?",
|
|
||||||
shutdown_confirm: "Wirklich herunterfahren?",
|
|
||||||
confirm_yes: "Ja",
|
|
||||||
confirm_no: "Abbrechen",
|
|
||||||
lock_failed: "Sperren fehlgeschlagen",
|
|
||||||
logout_failed: "Abmelden fehlgeschlagen",
|
|
||||||
hibernate_failed: "Ruhezustand fehlgeschlagen",
|
|
||||||
reboot_failed: "Neustart fehlgeschlagen",
|
|
||||||
shutdown_failed: "Herunterfahren fehlgeschlagen",
|
|
||||||
};
|
|
||||||
|
|
||||||
const STRINGS_EN: Strings = Strings {
|
|
||||||
lock_label: "Lock",
|
|
||||||
logout_label: "Log out",
|
|
||||||
hibernate_label: "Hibernate",
|
|
||||||
reboot_label: "Reboot",
|
|
||||||
shutdown_label: "Shut down",
|
|
||||||
logout_confirm: "Really log out?",
|
|
||||||
hibernate_confirm: "Really hibernate?",
|
|
||||||
reboot_confirm: "Really reboot?",
|
|
||||||
shutdown_confirm: "Really shut down?",
|
|
||||||
confirm_yes: "Yes",
|
|
||||||
confirm_no: "Cancel",
|
|
||||||
lock_failed: "Lock failed",
|
|
||||||
logout_failed: "Log out failed",
|
|
||||||
hibernate_failed: "Hibernate failed",
|
|
||||||
reboot_failed: "Reboot failed",
|
|
||||||
shutdown_failed: "Shutdown failed",
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Extract the language prefix from a LANG value like "de_DE.UTF-8" → "de".
|
|
||||||
/// Returns "en" for empty, "C", or "POSIX" values.
|
|
||||||
fn parse_lang_prefix(lang: &str) -> String {
|
|
||||||
if lang.is_empty() || lang == "C" || lang == "POSIX" {
|
|
||||||
return "en".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
let prefix = lang
|
|
||||||
.split('_')
|
|
||||||
.next()
|
|
||||||
.unwrap_or(lang)
|
|
||||||
.split('.')
|
|
||||||
.next()
|
|
||||||
.unwrap_or(lang)
|
|
||||||
.to_lowercase();
|
|
||||||
|
|
||||||
if prefix.chars().all(|c| c.is_ascii_alphabetic()) && !prefix.is_empty() {
|
|
||||||
prefix
|
|
||||||
} else {
|
|
||||||
"en".to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read the LANG= value from a locale.conf file.
|
|
||||||
fn read_lang_from_conf(path: &Path) -> Option<String> {
|
|
||||||
let content = fs::read_to_string(path).ok()?;
|
|
||||||
for line in content.lines() {
|
|
||||||
if let Some(value) = line.strip_prefix("LANG=") {
|
|
||||||
let value = value.trim();
|
|
||||||
if !value.is_empty() {
|
|
||||||
return Some(value.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Determine the system language from POSIX locale env vars or /etc/locale.conf.
|
|
||||||
/// Checks LC_ALL, LC_MESSAGES, LANG in POSIX priority order (LC_ALL overrides
|
|
||||||
/// everything; LC_MESSAGES overrides LANG for text categories).
|
|
||||||
pub fn detect_locale() -> String {
|
|
||||||
let env_val = env::var("LC_ALL")
|
|
||||||
.ok()
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.or_else(|| env::var("LC_MESSAGES").ok().filter(|s| !s.is_empty()))
|
|
||||||
.or_else(|| env::var("LANG").ok().filter(|s| !s.is_empty()));
|
|
||||||
detect_locale_with(env_val.as_deref(), Path::new(DEFAULT_LOCALE_CONF))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Determine locale with configurable inputs (for testing).
|
|
||||||
pub fn detect_locale_with(env_lang: Option<&str>, locale_conf_path: &Path) -> String {
|
|
||||||
let (raw, source) = if let Some(val) = env_lang.filter(|s| !s.is_empty()) {
|
|
||||||
(Some(val.to_string()), "env")
|
|
||||||
} else if let Some(val) = read_lang_from_conf(locale_conf_path) {
|
|
||||||
(Some(val), "locale.conf")
|
|
||||||
} else {
|
|
||||||
(None, "default")
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = match raw {
|
|
||||||
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.
|
|
||||||
pub fn load_strings(locale: Option<&str>) -> &'static Strings {
|
|
||||||
let locale = match locale {
|
|
||||||
Some(l) => l.to_string(),
|
|
||||||
None => detect_locale(),
|
|
||||||
};
|
|
||||||
|
|
||||||
match locale.as_str() {
|
|
||||||
"de" => &STRINGS_DE,
|
|
||||||
_ => &STRINGS_EN,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::io::Write;
|
|
||||||
|
|
||||||
// -- parse_lang_prefix tests (no env manipulation needed) --
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_german_locale() {
|
|
||||||
assert_eq!(parse_lang_prefix("de_DE.UTF-8"), "de");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_english_locale() {
|
|
||||||
assert_eq!(parse_lang_prefix("en_US.UTF-8"), "en");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_c_falls_back_to_english() {
|
|
||||||
assert_eq!(parse_lang_prefix("C"), "en");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_posix_falls_back_to_english() {
|
|
||||||
assert_eq!(parse_lang_prefix("POSIX"), "en");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_empty_falls_back_to_english() {
|
|
||||||
assert_eq!(parse_lang_prefix(""), "en");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_unsupported_returns_prefix() {
|
|
||||||
assert_eq!(parse_lang_prefix("fr_FR.UTF-8"), "fr");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_bare_language_code() {
|
|
||||||
assert_eq!(parse_lang_prefix("de"), "de");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- read_lang_from_conf tests --
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_conf_extracts_lang() {
|
|
||||||
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();
|
|
||||||
assert_eq!(read_lang_from_conf(&conf), Some("de_DE.UTF-8".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_conf_returns_none_for_missing_file() {
|
|
||||||
assert_eq!(read_lang_from_conf(Path::new("/nonexistent/locale.conf")), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_conf_returns_none_for_empty_lang() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let conf = dir.path().join("locale.conf");
|
|
||||||
let mut f = fs::File::create(&conf).unwrap();
|
|
||||||
writeln!(f, "LANG=").unwrap();
|
|
||||||
assert_eq!(read_lang_from_conf(&conf), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_conf_skips_non_lang_lines() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let conf = dir.path().join("locale.conf");
|
|
||||||
let mut f = fs::File::create(&conf).unwrap();
|
|
||||||
writeln!(f, "LC_ALL=en_US.UTF-8").unwrap();
|
|
||||||
writeln!(f, "LANG=de_DE.UTF-8").unwrap();
|
|
||||||
assert_eq!(read_lang_from_conf(&conf), Some("de_DE.UTF-8".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- load_strings tests --
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_strings_german() {
|
|
||||||
let strings = load_strings(Some("de"));
|
|
||||||
assert_eq!(strings.lock_label, "Sperren");
|
|
||||||
assert_eq!(strings.confirm_yes, "Ja");
|
|
||||||
assert_eq!(strings.confirm_no, "Abbrechen");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_strings_english() {
|
|
||||||
let strings = load_strings(Some("en"));
|
|
||||||
assert_eq!(strings.lock_label, "Lock");
|
|
||||||
assert_eq!(strings.confirm_yes, "Yes");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_strings_unknown_falls_back_to_english() {
|
|
||||||
let strings = load_strings(Some("fr"));
|
|
||||||
assert_eq!(strings.lock_label, "Lock");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn all_string_fields_nonempty() {
|
|
||||||
for locale in &["de", "en"] {
|
|
||||||
let s = load_strings(Some(locale));
|
|
||||||
assert!(!s.lock_label.is_empty(), "{locale}: lock_label empty");
|
|
||||||
assert!(!s.logout_label.is_empty(), "{locale}: logout_label empty");
|
|
||||||
assert!(!s.hibernate_label.is_empty(), "{locale}: hibernate_label empty");
|
|
||||||
assert!(!s.reboot_label.is_empty(), "{locale}: reboot_label empty");
|
|
||||||
assert!(!s.shutdown_label.is_empty(), "{locale}: shutdown_label empty");
|
|
||||||
assert!(!s.logout_confirm.is_empty(), "{locale}: logout_confirm empty");
|
|
||||||
assert!(!s.hibernate_confirm.is_empty(), "{locale}: hibernate_confirm empty");
|
|
||||||
assert!(!s.reboot_confirm.is_empty(), "{locale}: reboot_confirm empty");
|
|
||||||
assert!(!s.shutdown_confirm.is_empty(), "{locale}: shutdown_confirm empty");
|
|
||||||
assert!(!s.confirm_yes.is_empty(), "{locale}: confirm_yes empty");
|
|
||||||
assert!(!s.confirm_no.is_empty(), "{locale}: confirm_no empty");
|
|
||||||
assert!(!s.lock_failed.is_empty(), "{locale}: lock_failed empty");
|
|
||||||
assert!(!s.logout_failed.is_empty(), "{locale}: logout_failed empty");
|
|
||||||
assert!(!s.hibernate_failed.is_empty(), "{locale}: hibernate_failed empty");
|
|
||||||
assert!(!s.reboot_failed.is_empty(), "{locale}: reboot_failed empty");
|
|
||||||
assert!(!s.shutdown_failed.is_empty(), "{locale}: shutdown_failed empty");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- 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"));
|
|
||||||
assert!(s.lock_failed.to_lowercase().contains("failed"));
|
|
||||||
assert!(s.logout_failed.to_lowercase().contains("failed"));
|
|
||||||
assert!(s.hibernate_failed.to_lowercase().contains("failed"));
|
|
||||||
assert!(s.reboot_failed.to_lowercase().contains("failed"));
|
|
||||||
assert!(s.shutdown_failed.to_lowercase().contains("failed"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
120
src/main.rs
120
src/main.rs
@ -1,120 +0,0 @@
|
|||||||
// ABOUTME: Entry point for Moonset — Wayland session power menu.
|
|
||||||
// ABOUTME: Sets up GTK Application, Layer Shell, CSS, and multi-monitor windows.
|
|
||||||
|
|
||||||
mod config;
|
|
||||||
mod i18n;
|
|
||||||
mod panel;
|
|
||||||
mod power;
|
|
||||||
mod users;
|
|
||||||
|
|
||||||
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";
|
|
||||||
|
|
||||||
fn load_css(display: &gdk::Display) {
|
|
||||||
let css_provider = gtk::CssProvider::new();
|
|
||||||
css_provider.load_from_resource(&format!("{GRESOURCE_PREFIX}/style.css"));
|
|
||||||
gtk::style_context_add_provider_for_display(
|
|
||||||
display,
|
|
||||||
&css_provider,
|
|
||||||
gtk::STYLE_PROVIDER_PRIORITY_USER,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn setup_layer_shell(
|
|
||||||
window: >k::ApplicationWindow,
|
|
||||||
keyboard: bool,
|
|
||||||
layer: gtk4_layer_shell::Layer,
|
|
||||||
) {
|
|
||||||
window.init_layer_shell();
|
|
||||||
window.set_layer(layer);
|
|
||||||
window.set_exclusive_zone(-1);
|
|
||||||
if keyboard {
|
|
||||||
window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::Exclusive);
|
|
||||||
}
|
|
||||||
// Anchor to all edges for fullscreen
|
|
||||||
window.set_anchor(gtk4_layer_shell::Edge::Top, true);
|
|
||||||
window.set_anchor(gtk4_layer_shell::Edge::Bottom, true);
|
|
||||||
window.set_anchor(gtk4_layer_shell::Edge::Left, true);
|
|
||||||
window.set_anchor(gtk4_layer_shell::Edge::Right, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn activate(app: >k::Application, bg_path: &Option<PathBuf>, blur_radius: Option<f32>) {
|
|
||||||
let display = match gdk::Display::default() {
|
|
||||||
Some(d) => d,
|
|
||||||
None => {
|
|
||||||
log::error!("No display available — cannot start power menu UI");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
// Panel on focused output (no set_monitor → compositor picks focused)
|
|
||||||
let panel = panel::create_panel_window(texture.as_ref(), blur_radius, &blur_cache, app);
|
|
||||||
setup_layer_shell(&panel, true, gtk4_layer_shell::Layer::Overlay);
|
|
||||||
panel.present();
|
|
||||||
|
|
||||||
// Wallpaper on all monitors
|
|
||||||
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);
|
|
||||||
setup_layer_shell(&wallpaper, false, gtk4_layer_shell::Layer::Top);
|
|
||||||
wallpaper.set_monitor(Some(&monitor));
|
|
||||||
wallpaper.present();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn setup_logging() {
|
|
||||||
match systemd_journal_logger::JournalLog::new() {
|
|
||||||
Ok(logger) => {
|
|
||||||
if let Err(e) = logger.install() {
|
|
||||||
eprintln!("Failed to install journal logger: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to create journal logger: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Require MOONSET_DEBUG=1 to raise verbosity so mere presence (empty
|
|
||||||
// value in a session script) cannot escalate journal noise with path
|
|
||||||
// information an attacker could use.
|
|
||||||
let level = match std::env::var("MOONSET_DEBUG").ok().as_deref() {
|
|
||||||
Some("1") => log::LevelFilter::Debug,
|
|
||||||
_ => log::LevelFilter::Info,
|
|
||||||
};
|
|
||||||
log::set_max_level(level);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
setup_logging();
|
|
||||||
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.run();
|
|
||||||
}
|
|
||||||
2
src/moonset/__init__.py
Normal file
2
src/moonset/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# ABOUTME: Moonset package — a Wayland session power menu with GTK4.
|
||||||
|
# ABOUTME: Part of the Moonarch ecosystem.
|
||||||
60
src/moonset/config.py
Normal file
60
src/moonset/config.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# ABOUTME: Configuration loading for the session power menu.
|
||||||
|
# ABOUTME: Reads moonset.toml for wallpaper settings with fallback hierarchy.
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from importlib.resources import files
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
MOONARCH_WALLPAPER = Path("/usr/share/moonarch/wallpaper.jpg")
|
||||||
|
PACKAGE_WALLPAPER = Path(str(files("moonset") / "data" / "wallpaper.jpg"))
|
||||||
|
|
||||||
|
DEFAULT_CONFIG_PATHS = [
|
||||||
|
Path("/etc/moonset/moonset.toml"),
|
||||||
|
Path.home() / ".config" / "moonset" / "moonset.toml",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Config:
|
||||||
|
"""Power menu configuration."""
|
||||||
|
|
||||||
|
background_path: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(
|
||||||
|
config_paths: list[Path] | None = None,
|
||||||
|
) -> Config:
|
||||||
|
"""Load config from TOML file. Later paths override earlier ones."""
|
||||||
|
if config_paths is None:
|
||||||
|
config_paths = DEFAULT_CONFIG_PATHS
|
||||||
|
|
||||||
|
merged: dict = {}
|
||||||
|
for path in config_paths:
|
||||||
|
if path.exists():
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
merged.update(data)
|
||||||
|
|
||||||
|
return Config(
|
||||||
|
background_path=merged.get("background_path"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_background_path(config: Config) -> Path:
|
||||||
|
"""Resolve the wallpaper path using the fallback hierarchy.
|
||||||
|
|
||||||
|
Priority: config background_path > Moonarch system default > package fallback.
|
||||||
|
"""
|
||||||
|
# User-configured path
|
||||||
|
if config.background_path:
|
||||||
|
path = Path(config.background_path)
|
||||||
|
if path.is_file():
|
||||||
|
return path
|
||||||
|
|
||||||
|
# Moonarch ecosystem default
|
||||||
|
if MOONARCH_WALLPAPER.is_file():
|
||||||
|
return MOONARCH_WALLPAPER
|
||||||
|
|
||||||
|
# Package fallback (always present)
|
||||||
|
return PACKAGE_WALLPAPER
|
||||||
BIN
src/moonset/data/wallpaper.jpg
Normal file
BIN
src/moonset/data/wallpaper.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 366 KiB |
108
src/moonset/i18n.py
Normal file
108
src/moonset/i18n.py
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# ABOUTME: Locale detection and string lookup for the power menu UI.
|
||||||
|
# ABOUTME: Reads system locale (LANG or /etc/locale.conf) and provides DE or EN strings.
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DEFAULT_LOCALE_CONF = Path("/etc/locale.conf")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Strings:
|
||||||
|
"""All user-visible strings for the power menu UI."""
|
||||||
|
|
||||||
|
# Button labels
|
||||||
|
lock_label: str
|
||||||
|
logout_label: str
|
||||||
|
hibernate_label: str
|
||||||
|
reboot_label: str
|
||||||
|
shutdown_label: str
|
||||||
|
|
||||||
|
# Confirmation prompts
|
||||||
|
logout_confirm: str
|
||||||
|
hibernate_confirm: str
|
||||||
|
reboot_confirm: str
|
||||||
|
shutdown_confirm: str
|
||||||
|
|
||||||
|
# Confirmation buttons
|
||||||
|
confirm_yes: str
|
||||||
|
confirm_no: str
|
||||||
|
|
||||||
|
# Error messages
|
||||||
|
lock_failed: str
|
||||||
|
logout_failed: str
|
||||||
|
hibernate_failed: str
|
||||||
|
reboot_failed: str
|
||||||
|
shutdown_failed: str
|
||||||
|
|
||||||
|
|
||||||
|
_STRINGS_DE = Strings(
|
||||||
|
lock_label="Sperren",
|
||||||
|
logout_label="Abmelden",
|
||||||
|
hibernate_label="Ruhezustand",
|
||||||
|
reboot_label="Neustart",
|
||||||
|
shutdown_label="Herunterfahren",
|
||||||
|
logout_confirm="Wirklich abmelden?",
|
||||||
|
hibernate_confirm="Wirklich in den Ruhezustand?",
|
||||||
|
reboot_confirm="Wirklich neu starten?",
|
||||||
|
shutdown_confirm="Wirklich herunterfahren?",
|
||||||
|
confirm_yes="Ja",
|
||||||
|
confirm_no="Abbrechen",
|
||||||
|
lock_failed="Sperren fehlgeschlagen",
|
||||||
|
logout_failed="Abmelden fehlgeschlagen",
|
||||||
|
hibernate_failed="Ruhezustand fehlgeschlagen",
|
||||||
|
reboot_failed="Neustart fehlgeschlagen",
|
||||||
|
shutdown_failed="Herunterfahren fehlgeschlagen",
|
||||||
|
)
|
||||||
|
|
||||||
|
_STRINGS_EN = Strings(
|
||||||
|
lock_label="Lock",
|
||||||
|
logout_label="Log out",
|
||||||
|
hibernate_label="Hibernate",
|
||||||
|
reboot_label="Reboot",
|
||||||
|
shutdown_label="Shut down",
|
||||||
|
logout_confirm="Really log out?",
|
||||||
|
hibernate_confirm="Really hibernate?",
|
||||||
|
reboot_confirm="Really reboot?",
|
||||||
|
shutdown_confirm="Really shut down?",
|
||||||
|
confirm_yes="Yes",
|
||||||
|
confirm_no="Cancel",
|
||||||
|
lock_failed="Lock failed",
|
||||||
|
logout_failed="Log out failed",
|
||||||
|
hibernate_failed="Hibernate failed",
|
||||||
|
reboot_failed="Reboot failed",
|
||||||
|
shutdown_failed="Shutdown failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOCALE_MAP: dict[str, Strings] = {
|
||||||
|
"de": _STRINGS_DE,
|
||||||
|
"en": _STRINGS_EN,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def detect_locale(locale_conf_path: Path = DEFAULT_LOCALE_CONF) -> str:
|
||||||
|
"""Determine the system language from LANG env var or /etc/locale.conf."""
|
||||||
|
lang = os.environ.get("LANG")
|
||||||
|
|
||||||
|
if not lang and locale_conf_path.exists():
|
||||||
|
for line in locale_conf_path.read_text().splitlines():
|
||||||
|
if line.startswith("LANG="):
|
||||||
|
lang = line.split("=", 1)[1].strip()
|
||||||
|
break
|
||||||
|
|
||||||
|
if not lang or lang in ("C", "POSIX"):
|
||||||
|
return "en"
|
||||||
|
|
||||||
|
# Extract language prefix: "de_DE.UTF-8" → "de"
|
||||||
|
lang = lang.split("_")[0].split(".")[0].lower()
|
||||||
|
if not lang.isalpha():
|
||||||
|
return "en"
|
||||||
|
return lang
|
||||||
|
|
||||||
|
|
||||||
|
def load_strings(locale: str | None = None) -> Strings:
|
||||||
|
"""Return the string table for the given locale, defaulting to English."""
|
||||||
|
if locale is None:
|
||||||
|
locale = detect_locale()
|
||||||
|
return _LOCALE_MAP.get(locale, _STRINGS_EN)
|
||||||
134
src/moonset/main.py
Normal file
134
src/moonset/main.py
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
# ABOUTME: Entry point for Moonset — sets up GTK Application and Layer Shell.
|
||||||
|
# ABOUTME: Handles multi-monitor setup: power menu on primary, wallpaper on secondary monitors.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from importlib.resources import files
|
||||||
|
|
||||||
|
import gi
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
gi.require_version("Gdk", "4.0")
|
||||||
|
from gi.repository import Gtk, Gdk
|
||||||
|
|
||||||
|
from moonset.config import load_config, resolve_background_path
|
||||||
|
from moonset.panel import PanelWindow, WallpaperWindow
|
||||||
|
|
||||||
|
# gtk4-layer-shell is optional for development/testing
|
||||||
|
try:
|
||||||
|
gi.require_version("Gtk4LayerShell", "1.0")
|
||||||
|
from gi.repository import Gtk4LayerShell
|
||||||
|
HAS_LAYER_SHELL = True
|
||||||
|
except (ValueError, ImportError):
|
||||||
|
HAS_LAYER_SHELL = False
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_logging() -> None:
|
||||||
|
"""Configure logging to stderr."""
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
"%(asctime)s %(levelname)s %(name)s: %(message)s"
|
||||||
|
)
|
||||||
|
|
||||||
|
stderr_handler = logging.StreamHandler(sys.stderr)
|
||||||
|
stderr_handler.setLevel(logging.INFO)
|
||||||
|
stderr_handler.setFormatter(formatter)
|
||||||
|
root.addHandler(stderr_handler)
|
||||||
|
|
||||||
|
|
||||||
|
class MoonsetApp(Gtk.Application):
|
||||||
|
"""GTK Application for the Moonset power menu."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(application_id="dev.moonarch.moonset")
|
||||||
|
self._secondary_windows: list[WallpaperWindow] = []
|
||||||
|
|
||||||
|
def do_activate(self) -> None:
|
||||||
|
"""Create and present power menu windows on all monitors."""
|
||||||
|
display = Gdk.Display.get_default()
|
||||||
|
if display is None:
|
||||||
|
logger.error("No display available — cannot start power menu UI")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._load_css(display)
|
||||||
|
|
||||||
|
# Resolve wallpaper once, share across all windows
|
||||||
|
config = load_config()
|
||||||
|
bg_path = resolve_background_path(config)
|
||||||
|
|
||||||
|
monitors = display.get_monitors()
|
||||||
|
primary_monitor = None
|
||||||
|
|
||||||
|
# Find primary monitor — fall back to first available
|
||||||
|
for i in range(monitors.get_n_items()):
|
||||||
|
monitor = monitors.get_item(i)
|
||||||
|
if hasattr(monitor, "is_primary") and monitor.is_primary():
|
||||||
|
primary_monitor = monitor
|
||||||
|
break
|
||||||
|
if primary_monitor is None and monitors.get_n_items() > 0:
|
||||||
|
primary_monitor = monitors.get_item(0)
|
||||||
|
|
||||||
|
# Main power menu window on primary monitor
|
||||||
|
panel = PanelWindow(bg_path=bg_path, application=self)
|
||||||
|
if HAS_LAYER_SHELL:
|
||||||
|
self._setup_layer_shell(panel, keyboard=True)
|
||||||
|
if primary_monitor is not None:
|
||||||
|
Gtk4LayerShell.set_monitor(panel, primary_monitor)
|
||||||
|
panel.present()
|
||||||
|
|
||||||
|
# Wallpaper-only windows on secondary monitors
|
||||||
|
for i in range(monitors.get_n_items()):
|
||||||
|
monitor = monitors.get_item(i)
|
||||||
|
if monitor == primary_monitor:
|
||||||
|
continue
|
||||||
|
wallpaper_win = WallpaperWindow(bg_path=bg_path, application=self)
|
||||||
|
if HAS_LAYER_SHELL:
|
||||||
|
self._setup_layer_shell(wallpaper_win, keyboard=False)
|
||||||
|
Gtk4LayerShell.set_monitor(wallpaper_win, monitor)
|
||||||
|
wallpaper_win.present()
|
||||||
|
self._secondary_windows.append(wallpaper_win)
|
||||||
|
|
||||||
|
def _load_css(self, display: Gdk.Display) -> None:
|
||||||
|
"""Load the CSS stylesheet for the power menu."""
|
||||||
|
css_provider = Gtk.CssProvider()
|
||||||
|
css_path = files("moonset") / "style.css"
|
||||||
|
css_provider.load_from_path(str(css_path))
|
||||||
|
Gtk.StyleContext.add_provider_for_display(
|
||||||
|
display,
|
||||||
|
css_provider,
|
||||||
|
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _setup_layer_shell(
|
||||||
|
self, window: Gtk.Window, keyboard: bool = False
|
||||||
|
) -> None:
|
||||||
|
"""Configure gtk4-layer-shell for fullscreen OVERLAY display."""
|
||||||
|
Gtk4LayerShell.init_for_window(window)
|
||||||
|
Gtk4LayerShell.set_layer(window, Gtk4LayerShell.Layer.OVERLAY)
|
||||||
|
if keyboard:
|
||||||
|
Gtk4LayerShell.set_keyboard_mode(
|
||||||
|
window, Gtk4LayerShell.KeyboardMode.EXCLUSIVE
|
||||||
|
)
|
||||||
|
# Anchor to all edges for fullscreen
|
||||||
|
for edge in [
|
||||||
|
Gtk4LayerShell.Edge.TOP,
|
||||||
|
Gtk4LayerShell.Edge.BOTTOM,
|
||||||
|
Gtk4LayerShell.Edge.LEFT,
|
||||||
|
Gtk4LayerShell.Edge.RIGHT,
|
||||||
|
]:
|
||||||
|
Gtk4LayerShell.set_anchor(window, edge, True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run the Moonset application."""
|
||||||
|
_setup_logging()
|
||||||
|
logger.info("Moonset starting")
|
||||||
|
app = MoonsetApp()
|
||||||
|
app.run(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
292
src/moonset/panel.py
Normal file
292
src/moonset/panel.py
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
# ABOUTME: UI module for the power menu — action buttons, confirmation flow, wallpaper windows.
|
||||||
|
# ABOUTME: Defines PanelWindow (primary monitor) and WallpaperWindow (secondary monitors).
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import gi
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
gi.require_version("Gdk", "4.0")
|
||||||
|
from gi.repository import Gtk, Gdk, GLib
|
||||||
|
|
||||||
|
from moonset.i18n import Strings, load_strings
|
||||||
|
from moonset import power
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ActionDef:
|
||||||
|
"""Definition for a single power action button."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
icon_name: str
|
||||||
|
needs_confirm: bool
|
||||||
|
action_fn: Callable[[], None]
|
||||||
|
label_attr: str
|
||||||
|
error_attr: str
|
||||||
|
confirm_attr: str | None
|
||||||
|
|
||||||
|
def get_label(self, strings: Strings) -> str:
|
||||||
|
return getattr(strings, self.label_attr)
|
||||||
|
|
||||||
|
def get_error_message(self, strings: Strings) -> str:
|
||||||
|
return getattr(strings, self.error_attr)
|
||||||
|
|
||||||
|
def get_confirm_prompt(self, strings: Strings) -> str | None:
|
||||||
|
if self.confirm_attr is None:
|
||||||
|
return None
|
||||||
|
return getattr(strings, self.confirm_attr)
|
||||||
|
|
||||||
|
|
||||||
|
ACTION_DEFINITIONS: list[ActionDef] = [
|
||||||
|
ActionDef(
|
||||||
|
name="lock",
|
||||||
|
icon_name="system-lock-screen-symbolic",
|
||||||
|
needs_confirm=False,
|
||||||
|
action_fn=power.lock,
|
||||||
|
label_attr="lock_label",
|
||||||
|
error_attr="lock_failed",
|
||||||
|
confirm_attr=None,
|
||||||
|
),
|
||||||
|
ActionDef(
|
||||||
|
name="logout",
|
||||||
|
icon_name="system-log-out-symbolic",
|
||||||
|
needs_confirm=True,
|
||||||
|
action_fn=power.logout,
|
||||||
|
label_attr="logout_label",
|
||||||
|
error_attr="logout_failed",
|
||||||
|
confirm_attr="logout_confirm",
|
||||||
|
),
|
||||||
|
ActionDef(
|
||||||
|
name="hibernate",
|
||||||
|
icon_name="system-hibernate-symbolic",
|
||||||
|
needs_confirm=True,
|
||||||
|
action_fn=power.hibernate,
|
||||||
|
label_attr="hibernate_label",
|
||||||
|
error_attr="hibernate_failed",
|
||||||
|
confirm_attr="hibernate_confirm",
|
||||||
|
),
|
||||||
|
ActionDef(
|
||||||
|
name="reboot",
|
||||||
|
icon_name="system-reboot-symbolic",
|
||||||
|
needs_confirm=True,
|
||||||
|
action_fn=power.reboot,
|
||||||
|
label_attr="reboot_label",
|
||||||
|
error_attr="reboot_failed",
|
||||||
|
confirm_attr="reboot_confirm",
|
||||||
|
),
|
||||||
|
ActionDef(
|
||||||
|
name="shutdown",
|
||||||
|
icon_name="system-shutdown-symbolic",
|
||||||
|
needs_confirm=True,
|
||||||
|
action_fn=power.shutdown,
|
||||||
|
label_attr="shutdown_label",
|
||||||
|
error_attr="shutdown_failed",
|
||||||
|
confirm_attr="shutdown_confirm",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class WallpaperWindow(Gtk.ApplicationWindow):
|
||||||
|
"""Fullscreen wallpaper-only window for secondary monitors."""
|
||||||
|
|
||||||
|
def __init__(self, bg_path: Path, application: Gtk.Application) -> None:
|
||||||
|
super().__init__(application=application)
|
||||||
|
self.add_css_class("wallpaper")
|
||||||
|
|
||||||
|
background = Gtk.Picture.new_for_filename(str(bg_path))
|
||||||
|
background.set_content_fit(Gtk.ContentFit.COVER)
|
||||||
|
background.set_hexpand(True)
|
||||||
|
background.set_vexpand(True)
|
||||||
|
self.set_child(background)
|
||||||
|
|
||||||
|
|
||||||
|
class PanelWindow(Gtk.ApplicationWindow):
|
||||||
|
"""Fullscreen power menu window for the primary monitor."""
|
||||||
|
|
||||||
|
def __init__(self, bg_path: Path, application: Gtk.Application) -> None:
|
||||||
|
super().__init__(application=application)
|
||||||
|
self.add_css_class("panel")
|
||||||
|
|
||||||
|
self._strings = load_strings()
|
||||||
|
self._app = application
|
||||||
|
self._confirm_box: Gtk.Box | None = None
|
||||||
|
|
||||||
|
self._build_ui(bg_path)
|
||||||
|
self._setup_keyboard()
|
||||||
|
|
||||||
|
def _build_ui(self, bg_path: Path) -> None:
|
||||||
|
"""Build the panel layout with wallpaper background and action buttons."""
|
||||||
|
# Main overlay for background + centered content
|
||||||
|
overlay = Gtk.Overlay()
|
||||||
|
self.set_child(overlay)
|
||||||
|
|
||||||
|
# Background wallpaper
|
||||||
|
background = Gtk.Picture.new_for_filename(str(bg_path))
|
||||||
|
background.set_content_fit(Gtk.ContentFit.COVER)
|
||||||
|
background.set_hexpand(True)
|
||||||
|
background.set_vexpand(True)
|
||||||
|
overlay.set_child(background)
|
||||||
|
|
||||||
|
# Click on background dismisses the menu
|
||||||
|
click_controller = Gtk.GestureClick()
|
||||||
|
click_controller.connect("released", self._on_background_click)
|
||||||
|
background.add_controller(click_controller)
|
||||||
|
|
||||||
|
# Centered content box
|
||||||
|
content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||||
|
content_box.set_halign(Gtk.Align.CENTER)
|
||||||
|
content_box.set_valign(Gtk.Align.CENTER)
|
||||||
|
overlay.add_overlay(content_box)
|
||||||
|
|
||||||
|
# Action buttons row
|
||||||
|
self._button_box = Gtk.Box(
|
||||||
|
orientation=Gtk.Orientation.HORIZONTAL, spacing=24
|
||||||
|
)
|
||||||
|
self._button_box.set_halign(Gtk.Align.CENTER)
|
||||||
|
content_box.append(self._button_box)
|
||||||
|
|
||||||
|
for action_def in ACTION_DEFINITIONS:
|
||||||
|
button = self._create_action_button(action_def)
|
||||||
|
self._button_box.append(button)
|
||||||
|
|
||||||
|
# Confirmation area (below buttons)
|
||||||
|
self._confirm_area = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||||
|
self._confirm_area.set_halign(Gtk.Align.CENTER)
|
||||||
|
self._confirm_area.set_margin_top(24)
|
||||||
|
content_box.append(self._confirm_area)
|
||||||
|
|
||||||
|
# Error label
|
||||||
|
self._error_label = Gtk.Label()
|
||||||
|
self._error_label.add_css_class("error-label")
|
||||||
|
self._error_label.set_visible(False)
|
||||||
|
self._error_label.set_margin_top(16)
|
||||||
|
content_box.append(self._error_label)
|
||||||
|
|
||||||
|
def _create_action_button(self, action_def: ActionDef) -> Gtk.Button:
|
||||||
|
"""Create a single action button with icon and label."""
|
||||||
|
button_content = Gtk.Box(
|
||||||
|
orientation=Gtk.Orientation.VERTICAL, spacing=8
|
||||||
|
)
|
||||||
|
button_content.set_halign(Gtk.Align.CENTER)
|
||||||
|
|
||||||
|
icon = Gtk.Image.new_from_icon_name(action_def.icon_name)
|
||||||
|
icon.add_css_class("action-icon")
|
||||||
|
icon.set_pixel_size(64)
|
||||||
|
button_content.append(icon)
|
||||||
|
|
||||||
|
label = Gtk.Label(label=action_def.get_label(self._strings))
|
||||||
|
label.add_css_class("action-label")
|
||||||
|
button_content.append(label)
|
||||||
|
|
||||||
|
button = Gtk.Button()
|
||||||
|
button.set_child(button_content)
|
||||||
|
button.add_css_class("action-button")
|
||||||
|
button.connect("clicked", lambda _, ad=action_def: self._on_action_clicked(ad))
|
||||||
|
return button
|
||||||
|
|
||||||
|
def _setup_keyboard(self) -> None:
|
||||||
|
"""Set up keyboard event handling — Escape dismisses."""
|
||||||
|
controller = Gtk.EventControllerKey()
|
||||||
|
controller.connect("key-pressed", self._on_key_pressed)
|
||||||
|
self.add_controller(controller)
|
||||||
|
|
||||||
|
def _on_key_pressed(
|
||||||
|
self,
|
||||||
|
controller: Gtk.EventControllerKey,
|
||||||
|
keyval: int,
|
||||||
|
keycode: int,
|
||||||
|
state: Gdk.ModifierType,
|
||||||
|
) -> bool:
|
||||||
|
"""Handle key presses — Escape dismisses the power menu."""
|
||||||
|
if keyval == Gdk.KEY_Escape:
|
||||||
|
self._app.quit()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _on_background_click(
|
||||||
|
self,
|
||||||
|
gesture: Gtk.GestureClick,
|
||||||
|
n_press: int,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
) -> None:
|
||||||
|
"""Dismiss the power menu when background is clicked."""
|
||||||
|
self._app.quit()
|
||||||
|
|
||||||
|
def _on_action_clicked(self, action_def: ActionDef) -> None:
|
||||||
|
"""Handle an action button click."""
|
||||||
|
self._dismiss_confirm()
|
||||||
|
self._error_label.set_visible(False)
|
||||||
|
|
||||||
|
if not action_def.needs_confirm:
|
||||||
|
self._execute_action(action_def)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._show_confirm(action_def)
|
||||||
|
|
||||||
|
def _show_confirm(self, action_def: ActionDef) -> None:
|
||||||
|
"""Show inline confirmation below the action buttons."""
|
||||||
|
self._confirm_box = Gtk.Box(
|
||||||
|
orientation=Gtk.Orientation.VERTICAL, spacing=8
|
||||||
|
)
|
||||||
|
self._confirm_box.set_halign(Gtk.Align.CENTER)
|
||||||
|
self._confirm_box.add_css_class("confirm-box")
|
||||||
|
|
||||||
|
prompt = action_def.get_confirm_prompt(self._strings)
|
||||||
|
confirm_label = Gtk.Label(label=prompt)
|
||||||
|
confirm_label.add_css_class("confirm-label")
|
||||||
|
self._confirm_box.append(confirm_label)
|
||||||
|
|
||||||
|
button_box = Gtk.Box(
|
||||||
|
orientation=Gtk.Orientation.HORIZONTAL, spacing=8
|
||||||
|
)
|
||||||
|
button_box.set_halign(Gtk.Align.CENTER)
|
||||||
|
|
||||||
|
yes_btn = Gtk.Button(label=self._strings.confirm_yes)
|
||||||
|
yes_btn.add_css_class("confirm-yes")
|
||||||
|
yes_btn.connect(
|
||||||
|
"clicked", lambda _: self._execute_action(action_def)
|
||||||
|
)
|
||||||
|
button_box.append(yes_btn)
|
||||||
|
|
||||||
|
no_btn = Gtk.Button(label=self._strings.confirm_no)
|
||||||
|
no_btn.add_css_class("confirm-no")
|
||||||
|
no_btn.connect("clicked", lambda _: self._dismiss_confirm())
|
||||||
|
button_box.append(no_btn)
|
||||||
|
|
||||||
|
self._confirm_box.append(button_box)
|
||||||
|
self._confirm_area.append(self._confirm_box)
|
||||||
|
|
||||||
|
def _dismiss_confirm(self) -> None:
|
||||||
|
"""Remove the confirmation prompt."""
|
||||||
|
if self._confirm_box is not None:
|
||||||
|
self._confirm_area.remove(self._confirm_box)
|
||||||
|
self._confirm_box = None
|
||||||
|
|
||||||
|
def _execute_action(self, action_def: ActionDef) -> None:
|
||||||
|
"""Execute a power action in a background thread."""
|
||||||
|
self._dismiss_confirm()
|
||||||
|
|
||||||
|
def _run() -> None:
|
||||||
|
try:
|
||||||
|
action_def.action_fn()
|
||||||
|
# Lock action: quit after successful execution
|
||||||
|
if action_def.name == "lock":
|
||||||
|
GLib.idle_add(self._app.quit)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Power action '%s' failed", action_def.name)
|
||||||
|
error_msg = action_def.get_error_message(self._strings)
|
||||||
|
GLib.idle_add(self._show_error, error_msg)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=_run, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def _show_error(self, message: str) -> None:
|
||||||
|
"""Display an error message."""
|
||||||
|
self._error_label.set_text(message)
|
||||||
|
self._error_label.set_visible(True)
|
||||||
34
src/moonset/power.py
Normal file
34
src/moonset/power.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# ABOUTME: Power actions — lock, logout, hibernate, reboot, shutdown.
|
||||||
|
# ABOUTME: Simple wrappers around system commands for the session power menu.
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
POWER_TIMEOUT = 30
|
||||||
|
|
||||||
|
|
||||||
|
def lock() -> None:
|
||||||
|
"""Lock the current session via loginctl."""
|
||||||
|
subprocess.run(["loginctl", "lock-session"], check=True, timeout=POWER_TIMEOUT)
|
||||||
|
|
||||||
|
|
||||||
|
def logout() -> None:
|
||||||
|
"""Quit the Niri compositor (logout)."""
|
||||||
|
subprocess.run(
|
||||||
|
["niri", "msg", "action", "quit"], check=True, timeout=POWER_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def hibernate() -> None:
|
||||||
|
"""Hibernate the system via systemctl."""
|
||||||
|
subprocess.run(["systemctl", "hibernate"], check=True, timeout=POWER_TIMEOUT)
|
||||||
|
|
||||||
|
|
||||||
|
def reboot() -> None:
|
||||||
|
"""Reboot the system via loginctl."""
|
||||||
|
subprocess.run(["loginctl", "reboot"], check=True, timeout=POWER_TIMEOUT)
|
||||||
|
|
||||||
|
|
||||||
|
def shutdown() -> None:
|
||||||
|
"""Shut down the system via loginctl."""
|
||||||
|
subprocess.run(["loginctl", "poweroff"], check=True, timeout=POWER_TIMEOUT)
|
||||||
87
src/moonset/style.css
Normal file
87
src/moonset/style.css
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
/* ABOUTME: GTK4 CSS stylesheet for the Moonset power menu. */
|
||||||
|
/* ABOUTME: Catppuccin Mocha theme with action buttons and confirmation styling. */
|
||||||
|
|
||||||
|
/* Main panel window background */
|
||||||
|
window.panel {
|
||||||
|
background-color: #1a1a2e;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wallpaper-only window for secondary monitors */
|
||||||
|
window.wallpaper {
|
||||||
|
background-color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action button — 120x120px card */
|
||||||
|
.action-button {
|
||||||
|
min-width: 120px;
|
||||||
|
min-height: 120px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background-color: alpha(white, 0.08);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover {
|
||||||
|
background-color: alpha(white, 0.20);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action icon inside button */
|
||||||
|
.action-icon {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action label below icon */
|
||||||
|
.action-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Confirmation box below action buttons */
|
||||||
|
.confirm-box {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: alpha(white, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Confirmation prompt text */
|
||||||
|
.confirm-label {
|
||||||
|
font-size: 16px;
|
||||||
|
color: white;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Confirm "Yes" button */
|
||||||
|
.confirm-yes {
|
||||||
|
padding: 8px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #ff6b6b;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-yes:hover {
|
||||||
|
background-color: #ff8787;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Confirm "No/Cancel" button */
|
||||||
|
.confirm-no {
|
||||||
|
padding: 8px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: alpha(white, 0.12);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-no:hover {
|
||||||
|
background-color: alpha(white, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error message label */
|
||||||
|
.error-label {
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
763
src/panel.rs
763
src/panel.rs
@ -1,763 +0,0 @@
|
|||||||
// ABOUTME: UI module for the power menu — action buttons, confirmation flow, wallpaper windows.
|
|
||||||
// ABOUTME: Defines PanelWindow (primary monitor) and WallpaperWindow (secondary monitors).
|
|
||||||
|
|
||||||
use gdk4 as gdk;
|
|
||||||
use gdk_pixbuf::Pixbuf;
|
|
||||||
use glib::clone;
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{self as gtk, gio};
|
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::rc::Rc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use crate::i18n::{load_strings, Strings};
|
|
||||||
use crate::power::{self, PowerError};
|
|
||||||
use crate::users;
|
|
||||||
|
|
||||||
const AVATAR_SIZE: i32 = 128;
|
|
||||||
|
|
||||||
/// Definition for a single power action button.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct ActionDef {
|
|
||||||
pub name: &'static str,
|
|
||||||
pub icon_name: &'static str,
|
|
||||||
pub needs_confirm: bool,
|
|
||||||
pub action_fn: fn() -> Result<(), PowerError>,
|
|
||||||
pub label_attr: fn(&Strings) -> &'static str,
|
|
||||||
pub error_attr: fn(&Strings) -> &'static str,
|
|
||||||
pub confirm_attr: Option<fn(&Strings) -> &'static str>,
|
|
||||||
pub quit_after: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All 5 power action definitions.
|
|
||||||
pub fn action_definitions() -> Vec<ActionDef> {
|
|
||||||
vec![
|
|
||||||
ActionDef {
|
|
||||||
name: "lock",
|
|
||||||
icon_name: "system-lock-screen-symbolic",
|
|
||||||
needs_confirm: false,
|
|
||||||
action_fn: power::lock,
|
|
||||||
label_attr: |s| s.lock_label,
|
|
||||||
error_attr: |s| s.lock_failed,
|
|
||||||
confirm_attr: None,
|
|
||||||
quit_after: true,
|
|
||||||
},
|
|
||||||
ActionDef {
|
|
||||||
name: "logout",
|
|
||||||
icon_name: "system-log-out-symbolic",
|
|
||||||
needs_confirm: true,
|
|
||||||
action_fn: power::logout,
|
|
||||||
label_attr: |s| s.logout_label,
|
|
||||||
error_attr: |s| s.logout_failed,
|
|
||||||
confirm_attr: Some(|s| s.logout_confirm),
|
|
||||||
quit_after: true,
|
|
||||||
},
|
|
||||||
ActionDef {
|
|
||||||
name: "hibernate",
|
|
||||||
icon_name: "system-hibernate-symbolic",
|
|
||||||
needs_confirm: true,
|
|
||||||
action_fn: power::hibernate,
|
|
||||||
label_attr: |s| s.hibernate_label,
|
|
||||||
error_attr: |s| s.hibernate_failed,
|
|
||||||
confirm_attr: Some(|s| s.hibernate_confirm),
|
|
||||||
quit_after: false,
|
|
||||||
},
|
|
||||||
ActionDef {
|
|
||||||
name: "reboot",
|
|
||||||
icon_name: "system-reboot-symbolic",
|
|
||||||
needs_confirm: true,
|
|
||||||
action_fn: power::reboot,
|
|
||||||
label_attr: |s| s.reboot_label,
|
|
||||||
error_attr: |s| s.reboot_failed,
|
|
||||||
confirm_attr: Some(|s| s.reboot_confirm),
|
|
||||||
quit_after: false,
|
|
||||||
},
|
|
||||||
ActionDef {
|
|
||||||
name: "shutdown",
|
|
||||||
icon_name: "system-shutdown-symbolic",
|
|
||||||
needs_confirm: true,
|
|
||||||
action_fn: power::shutdown,
|
|
||||||
label_attr: |s| s.shutdown_label,
|
|
||||||
error_attr: |s| s.shutdown_failed,
|
|
||||||
confirm_attr: Some(|s| s.shutdown_confirm),
|
|
||||||
quit_after: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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());
|
|
||||||
let file = gio::File::for_path(bg_path);
|
|
||||||
match gdk::Texture::from_file(&file) {
|
|
||||||
Ok(texture) => Some(texture),
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("Failed to load wallpaper {}: {e}", bg_path.display());
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- GPU blur via GskBlurNode -------------------------------------------------
|
|
||||||
|
|
||||||
// SYNC: MAX_BLUR_DIMENSION, render_blurred_texture, and create_background_picture
|
|
||||||
// are duplicated in moonlock/src/lockscreen.rs and moongreet/src/greeter.rs.
|
|
||||||
// Changes here must be mirrored to the other two projects.
|
|
||||||
|
|
||||||
/// Maximum texture dimension before downscaling for blur.
|
|
||||||
/// Keeps GPU work reasonable on 4K+ displays.
|
|
||||||
const MAX_BLUR_DIMENSION: f32 = 1920.0;
|
|
||||||
|
|
||||||
/// Render a blurred texture using the GPU via GskBlurNode.
|
|
||||||
///
|
|
||||||
/// To avoid edge darkening (blur samples transparent pixels outside bounds),
|
|
||||||
/// the texture is rendered with padding equal to 3x the blur sigma. The blur
|
|
||||||
/// is applied to the padded area, then cropped back to the original size.
|
|
||||||
///
|
|
||||||
/// Large textures (> MAX_BLUR_DIMENSION) are downscaled before blurring to
|
|
||||||
/// reduce GPU work. The sigma is scaled proportionally.
|
|
||||||
fn render_blurred_texture(
|
|
||||||
widget: &impl IsA<gtk::Widget>,
|
|
||||||
texture: &gdk::Texture,
|
|
||||||
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: >k::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.
|
|
||||||
pub fn create_wallpaper_window(texture: Option<&gdk::Texture>, blur_radius: Option<f32>, blur_cache: &BlurCache, 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);
|
|
||||||
window.set_child(Some(&background));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fade-in on map
|
|
||||||
window.connect_map(|w| {
|
|
||||||
glib::idle_add_local_once(clone!(
|
|
||||||
#[weak]
|
|
||||||
w,
|
|
||||||
move || {
|
|
||||||
w.add_css_class("visible");
|
|
||||||
}
|
|
||||||
));
|
|
||||||
});
|
|
||||||
|
|
||||||
window
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 {
|
|
||||||
let window = gtk::ApplicationWindow::builder()
|
|
||||||
.application(app)
|
|
||||||
.build();
|
|
||||||
window.add_css_class("panel");
|
|
||||||
|
|
||||||
let strings = load_strings(None);
|
|
||||||
let user = users::get_current_user().unwrap_or_else(|| {
|
|
||||||
let home = dirs::home_dir().unwrap_or_else(|| {
|
|
||||||
log::warn!("Could not resolve HOME — using an empty path");
|
|
||||||
PathBuf::new()
|
|
||||||
});
|
|
||||||
users::User {
|
|
||||||
username: "user".to_string(),
|
|
||||||
display_name: "User".to_string(),
|
|
||||||
home,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
log::debug!("User: {} ({})", user.display_name, user.username);
|
|
||||||
|
|
||||||
// State for confirm box
|
|
||||||
let confirm_box: Rc<RefCell<Option<gtk::Box>>> = Rc::new(RefCell::new(None));
|
|
||||||
|
|
||||||
// Main overlay for background + centered content
|
|
||||||
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);
|
|
||||||
overlay.set_child(Some(&background));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Click on background dismisses the menu
|
|
||||||
let click_controller = gtk::GestureClick::new();
|
|
||||||
click_controller.connect_released(clone!(
|
|
||||||
#[weak]
|
|
||||||
app,
|
|
||||||
move |_, _, _, _| {
|
|
||||||
fade_out_and_quit(&app);
|
|
||||||
}
|
|
||||||
));
|
|
||||||
overlay.add_controller(click_controller);
|
|
||||||
|
|
||||||
// Centered content box
|
|
||||||
let content_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
|
||||||
content_box.set_halign(gtk::Align::Center);
|
|
||||||
content_box.set_valign(gtk::Align::Center);
|
|
||||||
overlay.add_overlay(&content_box);
|
|
||||||
|
|
||||||
// Avatar
|
|
||||||
let avatar_frame = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
|
||||||
avatar_frame.set_size_request(AVATAR_SIZE, AVATAR_SIZE);
|
|
||||||
avatar_frame.set_halign(gtk::Align::Center);
|
|
||||||
avatar_frame.set_overflow(gtk::Overflow::Hidden);
|
|
||||||
avatar_frame.add_css_class("avatar");
|
|
||||||
let avatar_image = gtk::Image::new();
|
|
||||||
avatar_image.set_pixel_size(AVATAR_SIZE);
|
|
||||||
avatar_frame.append(&avatar_image);
|
|
||||||
content_box.append(&avatar_frame);
|
|
||||||
|
|
||||||
// Load avatar (file-based avatars load asynchronously)
|
|
||||||
load_avatar_async(&avatar_image, &window, &user);
|
|
||||||
|
|
||||||
// Username label
|
|
||||||
let username_label = gtk::Label::new(Some(&user.display_name));
|
|
||||||
username_label.add_css_class("username-label");
|
|
||||||
content_box.append(&username_label);
|
|
||||||
|
|
||||||
// Action buttons row
|
|
||||||
let button_box = gtk::Box::new(gtk::Orientation::Horizontal, 24);
|
|
||||||
button_box.set_halign(gtk::Align::Center);
|
|
||||||
content_box.append(&button_box);
|
|
||||||
|
|
||||||
// Confirmation area (below buttons)
|
|
||||||
let confirm_area = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
|
||||||
confirm_area.set_halign(gtk::Align::Center);
|
|
||||||
confirm_area.set_margin_top(24);
|
|
||||||
content_box.append(&confirm_area);
|
|
||||||
|
|
||||||
// Error label
|
|
||||||
let error_label = gtk::Label::new(None);
|
|
||||||
error_label.add_css_class("error-label");
|
|
||||||
error_label.set_visible(false);
|
|
||||||
error_label.set_margin_top(16);
|
|
||||||
content_box.append(&error_label);
|
|
||||||
|
|
||||||
// Create action buttons
|
|
||||||
for action_def in action_definitions() {
|
|
||||||
let button = create_action_button(
|
|
||||||
&action_def,
|
|
||||||
strings,
|
|
||||||
app,
|
|
||||||
&confirm_area,
|
|
||||||
&confirm_box,
|
|
||||||
&error_label,
|
|
||||||
&button_box,
|
|
||||||
);
|
|
||||||
button_box.append(&button);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keyboard handling — Escape dismisses
|
|
||||||
let key_controller = gtk::EventControllerKey::new();
|
|
||||||
key_controller.connect_key_pressed(clone!(
|
|
||||||
#[weak]
|
|
||||||
app,
|
|
||||||
#[upgrade_or]
|
|
||||||
glib::Propagation::Proceed,
|
|
||||||
move |_, keyval, _, _| {
|
|
||||||
if keyval == gdk::Key::Escape {
|
|
||||||
fade_out_and_quit(&app);
|
|
||||||
glib::Propagation::Stop
|
|
||||||
} else {
|
|
||||||
glib::Propagation::Proceed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
));
|
|
||||||
window.add_controller(key_controller);
|
|
||||||
|
|
||||||
// Focus first button + fade-in on map
|
|
||||||
let button_box_clone = button_box.clone();
|
|
||||||
window.connect_map(move |w| {
|
|
||||||
let w = w.clone();
|
|
||||||
let bb = button_box_clone.clone();
|
|
||||||
glib::idle_add_local_once(move || {
|
|
||||||
w.add_css_class("visible");
|
|
||||||
if let Some(first) = bb.first_child() {
|
|
||||||
first.grab_focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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 {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a single action button with icon and label.
|
|
||||||
fn create_action_button(
|
|
||||||
action_def: &ActionDef,
|
|
||||||
strings: &'static Strings,
|
|
||||||
app: >k::Application,
|
|
||||||
confirm_area: >k::Box,
|
|
||||||
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
|
|
||||||
error_label: >k::Label,
|
|
||||||
button_box: >k::Box,
|
|
||||||
) -> gtk::Button {
|
|
||||||
let button_content = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
|
||||||
button_content.set_halign(gtk::Align::Center);
|
|
||||||
button_content.set_valign(gtk::Align::Center);
|
|
||||||
|
|
||||||
// Look up the 22px icon variant, render at 64px (matches moonlock)
|
|
||||||
let icon = load_scaled_icon(action_def.icon_name);
|
|
||||||
icon.add_css_class("action-icon");
|
|
||||||
button_content.append(&icon);
|
|
||||||
|
|
||||||
let label_text = (action_def.label_attr)(strings);
|
|
||||||
let label = gtk::Label::new(Some(label_text));
|
|
||||||
label.add_css_class("action-label");
|
|
||||||
button_content.append(&label);
|
|
||||||
|
|
||||||
let button = gtk::Button::new();
|
|
||||||
button.set_child(Some(&button_content));
|
|
||||||
button.add_css_class("action-button");
|
|
||||||
|
|
||||||
let action_def = action_def.clone();
|
|
||||||
button.connect_clicked(clone!(
|
|
||||||
#[weak]
|
|
||||||
app,
|
|
||||||
#[weak]
|
|
||||||
confirm_area,
|
|
||||||
#[strong]
|
|
||||||
confirm_box,
|
|
||||||
#[weak]
|
|
||||||
error_label,
|
|
||||||
#[weak]
|
|
||||||
button_box,
|
|
||||||
move |_| {
|
|
||||||
on_action_clicked(
|
|
||||||
&action_def,
|
|
||||||
strings,
|
|
||||||
&app,
|
|
||||||
&confirm_area,
|
|
||||||
&confirm_box,
|
|
||||||
&error_label,
|
|
||||||
&button_box,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
));
|
|
||||||
|
|
||||||
button
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load a symbolic icon using native GTK4 rendering at the target size.
|
|
||||||
fn load_scaled_icon(icon_name: &str) -> gtk::Image {
|
|
||||||
let icon = gtk::Image::from_icon_name(icon_name);
|
|
||||||
icon.set_pixel_size(64);
|
|
||||||
icon
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle an action button click.
|
|
||||||
fn on_action_clicked(
|
|
||||||
action_def: &ActionDef,
|
|
||||||
strings: &'static Strings,
|
|
||||||
app: >k::Application,
|
|
||||||
confirm_area: >k::Box,
|
|
||||||
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
|
|
||||||
error_label: >k::Label,
|
|
||||||
button_box: >k::Box,
|
|
||||||
) {
|
|
||||||
dismiss_confirm(confirm_area, confirm_box);
|
|
||||||
error_label.set_visible(false);
|
|
||||||
|
|
||||||
if !action_def.needs_confirm {
|
|
||||||
execute_action(action_def, strings, app, confirm_area, confirm_box, error_label, button_box);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
show_confirm(action_def, strings, app, confirm_area, confirm_box, error_label, button_box);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Show inline confirmation below the action buttons.
|
|
||||||
fn show_confirm(
|
|
||||||
action_def: &ActionDef,
|
|
||||||
strings: &'static Strings,
|
|
||||||
app: >k::Application,
|
|
||||||
confirm_area: >k::Box,
|
|
||||||
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
|
|
||||||
error_label: >k::Label,
|
|
||||||
button_box: >k::Box,
|
|
||||||
) {
|
|
||||||
let new_box = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
|
||||||
new_box.set_halign(gtk::Align::Center);
|
|
||||||
new_box.add_css_class("confirm-box");
|
|
||||||
|
|
||||||
if let Some(prompt_fn) = action_def.confirm_attr {
|
|
||||||
let prompt_text = prompt_fn(strings);
|
|
||||||
let confirm_label = gtk::Label::new(Some(prompt_text));
|
|
||||||
confirm_label.add_css_class("confirm-label");
|
|
||||||
new_box.append(&confirm_label);
|
|
||||||
}
|
|
||||||
|
|
||||||
let button_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
||||||
button_row.set_halign(gtk::Align::Center);
|
|
||||||
|
|
||||||
let yes_btn = gtk::Button::with_label(strings.confirm_yes);
|
|
||||||
yes_btn.add_css_class("confirm-yes");
|
|
||||||
let action_def_clone = action_def.clone();
|
|
||||||
yes_btn.connect_clicked(clone!(
|
|
||||||
#[weak]
|
|
||||||
app,
|
|
||||||
#[weak]
|
|
||||||
confirm_area,
|
|
||||||
#[strong]
|
|
||||||
confirm_box,
|
|
||||||
#[weak]
|
|
||||||
error_label,
|
|
||||||
#[weak]
|
|
||||||
button_box,
|
|
||||||
move |_| {
|
|
||||||
execute_action(
|
|
||||||
&action_def_clone,
|
|
||||||
strings,
|
|
||||||
&app,
|
|
||||||
&confirm_area,
|
|
||||||
&confirm_box,
|
|
||||||
&error_label,
|
|
||||||
&button_box,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
));
|
|
||||||
button_row.append(&yes_btn);
|
|
||||||
|
|
||||||
let no_btn = gtk::Button::with_label(strings.confirm_no);
|
|
||||||
no_btn.add_css_class("confirm-no");
|
|
||||||
no_btn.connect_clicked(clone!(
|
|
||||||
#[weak]
|
|
||||||
confirm_area,
|
|
||||||
#[strong]
|
|
||||||
confirm_box,
|
|
||||||
#[weak]
|
|
||||||
button_box,
|
|
||||||
move |_| {
|
|
||||||
dismiss_confirm(&confirm_area, &confirm_box);
|
|
||||||
if let Some(first) = button_box.first_child() {
|
|
||||||
first.grab_focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
));
|
|
||||||
button_row.append(&no_btn);
|
|
||||||
|
|
||||||
new_box.append(&button_row);
|
|
||||||
confirm_area.append(&new_box);
|
|
||||||
|
|
||||||
*confirm_box.borrow_mut() = Some(new_box);
|
|
||||||
|
|
||||||
// Focus the "No" button — safe default for keyboard navigation
|
|
||||||
no_btn.grab_focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove the confirmation prompt.
|
|
||||||
fn dismiss_confirm(confirm_area: >k::Box, confirm_box: &Rc<RefCell<Option<gtk::Box>>>) {
|
|
||||||
if let Some(box_widget) = confirm_box.borrow_mut().take() {
|
|
||||||
confirm_area.remove(&box_widget);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute a power action in a background thread.
|
|
||||||
fn execute_action(
|
|
||||||
action_def: &ActionDef,
|
|
||||||
strings: &'static Strings,
|
|
||||||
app: >k::Application,
|
|
||||||
confirm_area: >k::Box,
|
|
||||||
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
|
|
||||||
error_label: >k::Label,
|
|
||||||
button_box: >k::Box,
|
|
||||||
) {
|
|
||||||
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;
|
|
||||||
let quit_after = action_def.quit_after;
|
|
||||||
let error_message = (action_def.error_attr)(strings).to_string();
|
|
||||||
|
|
||||||
// Desensitize buttons so a double-click or accidental keyboard repeat
|
|
||||||
// cannot fire the same action twice while it is in flight.
|
|
||||||
button_box.set_sensitive(false);
|
|
||||||
|
|
||||||
// Use glib::spawn_future_local + gio::spawn_blocking to avoid Send issues
|
|
||||||
// with GTK objects. The blocking closure runs on a thread pool, the result
|
|
||||||
// is handled back on the main thread.
|
|
||||||
glib::spawn_future_local(clone!(
|
|
||||||
#[weak]
|
|
||||||
app,
|
|
||||||
#[weak]
|
|
||||||
error_label,
|
|
||||||
#[weak]
|
|
||||||
button_box,
|
|
||||||
async move {
|
|
||||||
let result = gio::spawn_blocking(action_fn).await;
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(Ok(())) => {
|
|
||||||
if quit_after {
|
|
||||||
fade_out_and_quit(&app);
|
|
||||||
} else {
|
|
||||||
button_box.set_sensitive(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
log::error!("Power action '{}' failed: {}", action_name, e);
|
|
||||||
error_label.set_text(&error_message);
|
|
||||||
error_label.set_visible(true);
|
|
||||||
button_box.set_sensitive(true);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
log::error!("Power action '{}' panicked", action_name);
|
|
||||||
error_label.set_text(&error_message);
|
|
||||||
error_label.set_visible(true);
|
|
||||||
button_box.set_sensitive(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load the avatar asynchronously. File-based avatars are decoded off the UI thread.
|
|
||||||
fn load_avatar_async(image: >k::Image, window: >k::ApplicationWindow, user: &users::User) {
|
|
||||||
let avatar_path = users::get_avatar_path(&user.home, Some(&user.username));
|
|
||||||
|
|
||||||
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]
|
|
||||||
image,
|
|
||||||
async move {
|
|
||||||
let result = gio::spawn_blocking(move || {
|
|
||||||
Pixbuf::from_file_at_scale(
|
|
||||||
&path,
|
|
||||||
AVATAR_SIZE,
|
|
||||||
AVATAR_SIZE,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.ok()
|
|
||||||
.map(|pb| gdk::Texture::for_pixbuf(&pb))
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(Some(texture)) => image.set_paintable(Some(&texture)),
|
|
||||||
_ => image.set_icon_name(Some("avatar-default-symbolic")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
log::debug!("Avatar source: default SVG");
|
|
||||||
// Default SVG avatar: needs widget color, keep synchronous
|
|
||||||
set_default_avatar(image, window);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load the default avatar SVG from GResources, tinted with the foreground color.
|
|
||||||
fn set_default_avatar(image: >k::Image, window: >k::ApplicationWindow) {
|
|
||||||
let resource_path = users::get_default_avatar_path();
|
|
||||||
|
|
||||||
// Try loading from GResource
|
|
||||||
if let Ok(bytes) = gio::resources_lookup_data(&resource_path, gio::ResourceLookupFlags::NONE) {
|
|
||||||
let svg_text = String::from_utf8_lossy(&bytes);
|
|
||||||
|
|
||||||
// Get foreground color from widget's style context
|
|
||||||
let rgba = window.color();
|
|
||||||
let fg_color = format!(
|
|
||||||
"#{:02x}{:02x}{:02x}",
|
|
||||||
(rgba.red() * 255.0) as u8,
|
|
||||||
(rgba.green() * 255.0) as u8,
|
|
||||||
(rgba.blue() * 255.0) as u8,
|
|
||||||
);
|
|
||||||
let tinted = svg_text.replace("#PLACEHOLDER", &fg_color);
|
|
||||||
let svg_bytes = tinted.as_bytes();
|
|
||||||
|
|
||||||
if let Ok(loader) = gdk_pixbuf::PixbufLoader::with_type("svg") {
|
|
||||||
loader.set_size(AVATAR_SIZE, AVATAR_SIZE);
|
|
||||||
if loader.write(svg_bytes).is_ok() {
|
|
||||||
let _ = loader.close();
|
|
||||||
if let Some(pixbuf) = loader.pixbuf() {
|
|
||||||
let texture = gdk::Texture::for_pixbuf(&pixbuf);
|
|
||||||
image.set_paintable(Some(&texture));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback
|
|
||||||
image.set_icon_name(Some("avatar-default-symbolic"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn action_definitions_count() {
|
|
||||||
let defs = action_definitions();
|
|
||||||
assert_eq!(defs.len(), 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn action_definitions_names() {
|
|
||||||
let defs = action_definitions();
|
|
||||||
let names: Vec<&str> = defs.iter().map(|d| d.name).collect();
|
|
||||||
assert_eq!(names, vec!["lock", "logout", "hibernate", "reboot", "shutdown"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn action_definitions_icons() {
|
|
||||||
let defs = action_definitions();
|
|
||||||
assert_eq!(defs[0].icon_name, "system-lock-screen-symbolic");
|
|
||||||
assert_eq!(defs[1].icon_name, "system-log-out-symbolic");
|
|
||||||
assert_eq!(defs[2].icon_name, "system-hibernate-symbolic");
|
|
||||||
assert_eq!(defs[3].icon_name, "system-reboot-symbolic");
|
|
||||||
assert_eq!(defs[4].icon_name, "system-shutdown-symbolic");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lock_does_not_need_confirm() {
|
|
||||||
let defs = action_definitions();
|
|
||||||
assert!(!defs[0].needs_confirm);
|
|
||||||
assert!(defs[0].confirm_attr.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn other_actions_need_confirm() {
|
|
||||||
let defs = action_definitions();
|
|
||||||
for def in &defs[1..] {
|
|
||||||
assert!(def.needs_confirm, "{} should need confirm", def.name);
|
|
||||||
assert!(def.confirm_attr.is_some(), "{} should have confirm_attr", def.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn action_labels_from_strings() {
|
|
||||||
let strings = load_strings(Some("en"));
|
|
||||||
let defs = action_definitions();
|
|
||||||
assert_eq!((defs[0].label_attr)(strings), "Lock");
|
|
||||||
assert_eq!((defs[4].label_attr)(strings), "Shut down");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn action_error_messages_from_strings() {
|
|
||||||
let strings = load_strings(Some("en"));
|
|
||||||
let defs = action_definitions();
|
|
||||||
assert_eq!((defs[0].error_attr)(strings), "Lock failed");
|
|
||||||
assert_eq!((defs[4].error_attr)(strings), "Shutdown failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn action_confirm_prompts_from_strings() {
|
|
||||||
let strings = load_strings(Some("de"));
|
|
||||||
let defs = action_definitions();
|
|
||||||
let confirm_fn = defs[1].confirm_attr.unwrap();
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
195
src/power.rs
195
src/power.rs
@ -1,195 +0,0 @@
|
|||||||
// ABOUTME: Power actions — lock, logout, hibernate, reboot, shutdown.
|
|
||||||
// ABOUTME: Wrappers around system commands for the session power menu.
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
const POWER_TIMEOUT: Duration = Duration::from_secs(30);
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum PowerError {
|
|
||||||
CommandFailed { action: &'static str, message: String },
|
|
||||||
Timeout { action: &'static str },
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for PowerError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
PowerError::CommandFailed { action, message } => {
|
|
||||||
write!(f, "{action} failed: {message}")
|
|
||||||
}
|
|
||||||
PowerError::Timeout { action } => {
|
|
||||||
write!(f, "{action} timed out")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 is discarded — piping without draining would deadlock if a
|
|
||||||
// command ever wrote more than one OS pipe buffer before wait() returned.
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
.map_err(|e| PowerError::CommandFailed {
|
|
||||||
action,
|
|
||||||
message: e.to_string(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let child_pid = nix::unistd::Pid::from_raw(child.id() as i32);
|
|
||||||
let done = Arc::new(AtomicBool::new(false));
|
|
||||||
let timed_out = Arc::new(AtomicBool::new(false));
|
|
||||||
let done_clone = done.clone();
|
|
||||||
let timed_out_clone = timed_out.clone();
|
|
||||||
|
|
||||||
let _timeout_thread = std::thread::spawn(move || {
|
|
||||||
let interval = Duration::from_millis(100);
|
|
||||||
let mut elapsed = Duration::ZERO;
|
|
||||||
while elapsed < POWER_TIMEOUT {
|
|
||||||
std::thread::sleep(interval);
|
|
||||||
if done_clone.load(Ordering::Acquire) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
elapsed += interval;
|
|
||||||
}
|
|
||||||
// Record that we fired the kill so we don't misclassify an external
|
|
||||||
// SIGKILL (OOM killer, kill -9) as our timeout.
|
|
||||||
timed_out_clone.store(true, Ordering::Release);
|
|
||||||
// ESRCH if the process already exited — harmless
|
|
||||||
let _ = nix::sys::signal::kill(child_pid, nix::sys::signal::Signal::SIGKILL);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Drop guard ensures the timeout thread sees done=true even if child.wait()
|
|
||||||
// errors out — otherwise the thread sleeps its full 30 s holding a slot in
|
|
||||||
// the gio::spawn_blocking pool.
|
|
||||||
struct DoneGuard(Arc<AtomicBool>);
|
|
||||||
impl Drop for DoneGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.0.store(true, Ordering::Release);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _done_guard = DoneGuard(done);
|
|
||||||
|
|
||||||
let status = child.wait().map_err(|e| PowerError::CommandFailed {
|
|
||||||
action,
|
|
||||||
message: e.to_string(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if status.success() {
|
|
||||||
log::debug!("Power action {action} completed");
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
if timed_out.load(Ordering::Acquire) {
|
|
||||||
return Err(PowerError::Timeout { action });
|
|
||||||
}
|
|
||||||
let mut stderr_buf = String::new();
|
|
||||||
if let Some(mut stderr) = child.stderr.take() {
|
|
||||||
let _ = stderr.read_to_string(&mut stderr_buf);
|
|
||||||
}
|
|
||||||
Err(PowerError::CommandFailed {
|
|
||||||
action,
|
|
||||||
message: format!("exit code {}: {}", status, stderr_buf.trim()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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::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).
|
|
||||||
pub fn logout() -> Result<(), PowerError> {
|
|
||||||
run_command("logout", "/usr/bin/niri", &["msg", "action", "quit"])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hibernate the system via systemctl.
|
|
||||||
pub fn hibernate() -> Result<(), PowerError> {
|
|
||||||
run_command("hibernate", "/usr/bin/systemctl", &["hibernate"])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reboot the system via systemctl.
|
|
||||||
pub fn reboot() -> Result<(), PowerError> {
|
|
||||||
run_command("reboot", "/usr/bin/systemctl", &["reboot"])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shut down the system via systemctl.
|
|
||||||
pub fn shutdown() -> Result<(), PowerError> {
|
|
||||||
run_command("shutdown", "/usr/bin/systemctl", &["poweroff"])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn power_error_command_failed_display() {
|
|
||||||
let err = PowerError::CommandFailed {
|
|
||||||
action: "lock",
|
|
||||||
message: "No such file or directory".to_string(),
|
|
||||||
};
|
|
||||||
assert_eq!(err.to_string(), "lock failed: No such file or directory");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn power_error_timeout_display() {
|
|
||||||
let err = PowerError::Timeout { action: "shutdown" };
|
|
||||||
assert_eq!(err.to_string(), "shutdown timed out");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn run_command_returns_error_for_missing_binary() {
|
|
||||||
let result = run_command("test", "nonexistent-binary-xyz", &[]);
|
|
||||||
assert!(result.is_err());
|
|
||||||
let err = result.unwrap_err();
|
|
||||||
assert!(matches!(err, PowerError::CommandFailed { action: "test", .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn run_command_returns_error_on_nonzero_exit() {
|
|
||||||
let result = run_command("test", "false", &[]);
|
|
||||||
assert!(result.is_err());
|
|
||||||
let err = result.unwrap_err();
|
|
||||||
assert!(matches!(err, PowerError::CommandFailed { action: "test", .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn run_command_succeeds_for_true() {
|
|
||||||
let result = run_command("test", "true", &[]);
|
|
||||||
assert!(result.is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn run_command_passes_args() {
|
|
||||||
// "echo" with args should succeed
|
|
||||||
let result = run_command("test", "echo", &["hello", "world"]);
|
|
||||||
assert!(result.is_ok());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
161
src/users.rs
161
src/users.rs
@ -1,161 +0,0 @@
|
|||||||
// ABOUTME: Current user detection and avatar loading for the power menu.
|
|
||||||
// ABOUTME: Retrieves user info from the system (pwd, AccountsService, ~/.face).
|
|
||||||
|
|
||||||
use nix::unistd::{getuid, User as NixUser};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
const DEFAULT_ACCOUNTSSERVICE_DIR: &str = "/var/lib/AccountsService/icons";
|
|
||||||
|
|
||||||
/// Represents the current user for the power menu.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct User {
|
|
||||||
pub username: String,
|
|
||||||
pub display_name: String,
|
|
||||||
pub home: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the currently logged-in user's info from the system.
|
|
||||||
pub fn get_current_user() -> Option<User> {
|
|
||||||
let uid = getuid();
|
|
||||||
let nix_user = NixUser::from_uid(uid).ok()??;
|
|
||||||
|
|
||||||
let gecos = nix_user.gecos.to_str().unwrap_or("").to_string();
|
|
||||||
// GECOS field may contain comma-separated values; first field is the full name
|
|
||||||
let display_name = if !gecos.is_empty() {
|
|
||||||
let first = gecos.split(',').next().unwrap_or("");
|
|
||||||
if first.is_empty() {
|
|
||||||
nix_user.name.clone()
|
|
||||||
} else {
|
|
||||||
first.to_string()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
nix_user.name.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
Some(User {
|
|
||||||
username: nix_user.name,
|
|
||||||
display_name,
|
|
||||||
home: nix_user.dir,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find the user's avatar image, checking ~/.face then AccountsService.
|
|
||||||
pub fn get_avatar_path(home: &Path, username: Option<&str>) -> Option<PathBuf> {
|
|
||||||
get_avatar_path_with(home, username, Path::new(DEFAULT_ACCOUNTSSERVICE_DIR))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find avatar with configurable AccountsService dir (for testing).
|
|
||||||
/// Rejects symlinks to prevent path traversal.
|
|
||||||
pub fn get_avatar_path_with(
|
|
||||||
home: &Path,
|
|
||||||
username: Option<&str>,
|
|
||||||
accountsservice_dir: &Path,
|
|
||||||
) -> Option<PathBuf> {
|
|
||||||
// ~/.face takes priority
|
|
||||||
let face = home.join(".face");
|
|
||||||
if let Ok(meta) = face.symlink_metadata() {
|
|
||||||
if meta.file_type().is_symlink() {
|
|
||||||
log::warn!("Rejecting symlink avatar: {}", face.display());
|
|
||||||
} else if meta.is_file() {
|
|
||||||
log::debug!("Avatar: using ~/.face ({})", face.display());
|
|
||||||
return Some(face);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AccountsService icon fallback
|
|
||||||
if let Some(name) = username
|
|
||||||
&& accountsservice_dir.exists()
|
|
||||||
{
|
|
||||||
let icon = accountsservice_dir.join(name);
|
|
||||||
if let Ok(meta) = icon.symlink_metadata() {
|
|
||||||
if meta.file_type().is_symlink() {
|
|
||||||
log::warn!("Rejecting symlink avatar: {}", icon.display());
|
|
||||||
} else if meta.is_file() {
|
|
||||||
log::debug!("Avatar: using AccountsService icon ({})", icon.display());
|
|
||||||
return Some(icon);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the GResource path to the default avatar SVG.
|
|
||||||
pub fn get_default_avatar_path() -> String {
|
|
||||||
let prefix = crate::GRESOURCE_PREFIX;
|
|
||||||
format!("{prefix}/default-avatar.svg")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn get_current_user_returns_some() {
|
|
||||||
let user = get_current_user();
|
|
||||||
assert!(user.is_some());
|
|
||||||
let user = user.unwrap();
|
|
||||||
assert!(!user.username.is_empty());
|
|
||||||
assert!(!user.display_name.is_empty());
|
|
||||||
assert!(user.home.exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn returns_face_file_if_exists() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let face = dir.path().join(".face");
|
|
||||||
fs::write(&face, "fake image").unwrap();
|
|
||||||
let path = get_avatar_path_with(dir.path(), None, Path::new("/nonexistent"));
|
|
||||||
assert_eq!(path, Some(face));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn returns_accountsservice_icon_if_exists() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let icons_dir = dir.path().join("icons");
|
|
||||||
fs::create_dir(&icons_dir).unwrap();
|
|
||||||
let icon = icons_dir.join("testuser");
|
|
||||||
fs::write(&icon, "fake image").unwrap();
|
|
||||||
let path = get_avatar_path_with(dir.path(), Some("testuser"), &icons_dir);
|
|
||||||
assert_eq!(path, Some(icon));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn face_file_takes_priority_over_accountsservice() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let face = dir.path().join(".face");
|
|
||||||
fs::write(&face, "fake image").unwrap();
|
|
||||||
let icons_dir = dir.path().join("icons");
|
|
||||||
fs::create_dir(&icons_dir).unwrap();
|
|
||||||
let icon = icons_dir.join("testuser");
|
|
||||||
fs::write(&icon, "fake image").unwrap();
|
|
||||||
let path = get_avatar_path_with(dir.path(), Some("testuser"), &icons_dir);
|
|
||||||
assert_eq!(path, Some(face));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_symlink_avatar() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let target = dir.path().join("secret");
|
|
||||||
fs::write(&target, "secret content").unwrap();
|
|
||||||
let face = dir.path().join(".face");
|
|
||||||
std::os::unix::fs::symlink(&target, &face).unwrap();
|
|
||||||
let path = get_avatar_path_with(dir.path(), None, Path::new("/nonexistent"));
|
|
||||||
assert!(path.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn returns_none_when_no_avatar() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let path = get_avatar_path_with(dir.path(), None, Path::new("/nonexistent"));
|
|
||||||
assert!(path.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_avatar_path_is_gresource() {
|
|
||||||
let path = get_default_avatar_path();
|
|
||||||
assert!(path.contains("default-avatar.svg"));
|
|
||||||
assert!(path.starts_with("/dev/moonarch/moonset"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
71
tests/test_config.py
Normal file
71
tests/test_config.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
# ABOUTME: Tests for configuration loading and wallpaper path resolution.
|
||||||
|
# ABOUTME: Verifies TOML parsing, fallback hierarchy, and default values.
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from moonset.config import Config, load_config, resolve_background_path
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadConfig:
|
||||||
|
"""Tests for TOML config loading."""
|
||||||
|
|
||||||
|
def test_returns_default_config_when_no_files_exist(self) -> None:
|
||||||
|
config = load_config(config_paths=[Path("/nonexistent")])
|
||||||
|
assert config.background_path is None
|
||||||
|
|
||||||
|
def test_reads_background_path_from_toml(self, tmp_path: Path) -> None:
|
||||||
|
conf = tmp_path / "moonset.toml"
|
||||||
|
conf.write_text('background_path = "/custom/wallpaper.jpg"\n')
|
||||||
|
config = load_config(config_paths=[conf])
|
||||||
|
assert config.background_path == "/custom/wallpaper.jpg"
|
||||||
|
|
||||||
|
def test_later_paths_override_earlier(self, tmp_path: Path) -> None:
|
||||||
|
conf1 = tmp_path / "first.toml"
|
||||||
|
conf1.write_text('background_path = "/first.jpg"\n')
|
||||||
|
conf2 = tmp_path / "second.toml"
|
||||||
|
conf2.write_text('background_path = "/second.jpg"\n')
|
||||||
|
config = load_config(config_paths=[conf1, conf2])
|
||||||
|
assert config.background_path == "/second.jpg"
|
||||||
|
|
||||||
|
def test_skips_missing_config_files(self, tmp_path: Path) -> None:
|
||||||
|
conf = tmp_path / "exists.toml"
|
||||||
|
conf.write_text('background_path = "/exists.jpg"\n')
|
||||||
|
config = load_config(config_paths=[Path("/nonexistent"), conf])
|
||||||
|
assert config.background_path == "/exists.jpg"
|
||||||
|
|
||||||
|
def test_default_config_has_none_background(self) -> None:
|
||||||
|
config = Config()
|
||||||
|
assert config.background_path is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveBackgroundPath:
|
||||||
|
"""Tests for wallpaper path resolution fallback hierarchy."""
|
||||||
|
|
||||||
|
def test_uses_config_path_when_file_exists(self, tmp_path: Path) -> None:
|
||||||
|
wallpaper = tmp_path / "custom.jpg"
|
||||||
|
wallpaper.touch()
|
||||||
|
config = Config(background_path=str(wallpaper))
|
||||||
|
assert resolve_background_path(config) == wallpaper
|
||||||
|
|
||||||
|
def test_ignores_config_path_when_file_missing(self, tmp_path: Path) -> None:
|
||||||
|
config = Config(background_path="/nonexistent/wallpaper.jpg")
|
||||||
|
# Falls through to system or package fallback
|
||||||
|
result = resolve_background_path(config)
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
def test_uses_moonarch_wallpaper_as_second_fallback(self, tmp_path: Path) -> None:
|
||||||
|
moonarch_wp = tmp_path / "wallpaper.jpg"
|
||||||
|
moonarch_wp.touch()
|
||||||
|
config = Config(background_path=None)
|
||||||
|
with patch("moonset.config.MOONARCH_WALLPAPER", moonarch_wp):
|
||||||
|
assert resolve_background_path(config) == moonarch_wp
|
||||||
|
|
||||||
|
def test_uses_package_fallback_as_last_resort(self) -> None:
|
||||||
|
config = Config(background_path=None)
|
||||||
|
with patch("moonset.config.MOONARCH_WALLPAPER", Path("/nonexistent")):
|
||||||
|
result = resolve_background_path(config)
|
||||||
|
# Package fallback should always exist
|
||||||
|
assert result is not None
|
||||||
85
tests/test_i18n.py
Normal file
85
tests/test_i18n.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
# ABOUTME: Tests for locale detection and string lookup.
|
||||||
|
# ABOUTME: Verifies DE/EN string tables and locale fallback behavior.
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from moonset.i18n import Strings, detect_locale, load_strings
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetectLocale:
|
||||||
|
"""Tests for locale detection from environment and config files."""
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"LANG": "de_DE.UTF-8"})
|
||||||
|
def test_detects_german_from_env(self) -> None:
|
||||||
|
assert detect_locale() == "de"
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"LANG": "en_US.UTF-8"})
|
||||||
|
def test_detects_english_from_env(self) -> None:
|
||||||
|
assert detect_locale() == "en"
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"LANG": ""})
|
||||||
|
def test_reads_locale_conf_when_env_empty(self, tmp_path: Path) -> None:
|
||||||
|
conf = tmp_path / "locale.conf"
|
||||||
|
conf.write_text("LANG=de_DE.UTF-8\n")
|
||||||
|
assert detect_locale(locale_conf_path=conf) == "de"
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {}, clear=True)
|
||||||
|
def test_reads_locale_conf_when_env_unset(self, tmp_path: Path) -> None:
|
||||||
|
conf = tmp_path / "locale.conf"
|
||||||
|
conf.write_text("LANG=en_GB.UTF-8\n")
|
||||||
|
assert detect_locale(locale_conf_path=conf) == "en"
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"LANG": "C"})
|
||||||
|
def test_c_locale_falls_back_to_english(self) -> None:
|
||||||
|
assert detect_locale() == "en"
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"LANG": "POSIX"})
|
||||||
|
def test_posix_locale_falls_back_to_english(self) -> None:
|
||||||
|
assert detect_locale() == "en"
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {}, clear=True)
|
||||||
|
def test_missing_conf_falls_back_to_english(self) -> None:
|
||||||
|
assert detect_locale(locale_conf_path=Path("/nonexistent")) == "en"
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"LANG": "fr_FR.UTF-8"})
|
||||||
|
def test_detects_unsupported_locale(self) -> None:
|
||||||
|
assert detect_locale() == "fr"
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadStrings:
|
||||||
|
"""Tests for string table loading."""
|
||||||
|
|
||||||
|
def test_loads_german_strings(self) -> None:
|
||||||
|
strings = load_strings("de")
|
||||||
|
assert isinstance(strings, Strings)
|
||||||
|
assert strings.lock_label == "Sperren"
|
||||||
|
|
||||||
|
def test_loads_english_strings(self) -> None:
|
||||||
|
strings = load_strings("en")
|
||||||
|
assert isinstance(strings, Strings)
|
||||||
|
assert strings.lock_label == "Lock"
|
||||||
|
|
||||||
|
def test_unknown_locale_falls_back_to_english(self) -> None:
|
||||||
|
strings = load_strings("fr")
|
||||||
|
assert strings.lock_label == "Lock"
|
||||||
|
|
||||||
|
def test_all_string_fields_are_nonempty(self) -> None:
|
||||||
|
for locale in ("de", "en"):
|
||||||
|
strings = load_strings(locale)
|
||||||
|
for field_name in Strings.__dataclass_fields__:
|
||||||
|
value = getattr(strings, field_name)
|
||||||
|
assert value, f"{locale}: {field_name} is empty"
|
||||||
|
|
||||||
|
def test_confirm_yes_no_present(self) -> None:
|
||||||
|
strings = load_strings("de")
|
||||||
|
assert strings.confirm_yes == "Ja"
|
||||||
|
assert strings.confirm_no == "Abbrechen"
|
||||||
|
|
||||||
|
def test_error_messages_present(self) -> None:
|
||||||
|
strings = load_strings("en")
|
||||||
|
assert "failed" in strings.lock_failed.lower()
|
||||||
|
assert "failed" in strings.logout_failed.lower()
|
||||||
|
assert "failed" in strings.hibernate_failed.lower()
|
||||||
|
assert "failed" in strings.reboot_failed.lower()
|
||||||
|
assert "failed" in strings.shutdown_failed.lower()
|
||||||
74
tests/test_integration.py
Normal file
74
tests/test_integration.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
# ABOUTME: Integration tests for the moonset power menu.
|
||||||
|
# ABOUTME: Verifies that all modules work together correctly.
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from moonset.config import Config, load_config, resolve_background_path
|
||||||
|
from moonset.i18n import Strings, load_strings
|
||||||
|
from moonset.panel import ACTION_DEFINITIONS, ActionDef
|
||||||
|
from moonset.power import POWER_TIMEOUT
|
||||||
|
|
||||||
|
|
||||||
|
class TestModuleIntegration:
|
||||||
|
"""Tests that verify modules work together."""
|
||||||
|
|
||||||
|
def test_action_defs_reference_valid_power_functions(self) -> None:
|
||||||
|
"""Each ActionDef references a function from power.py."""
|
||||||
|
from moonset import power
|
||||||
|
power_functions = {
|
||||||
|
power.lock, power.logout, power.hibernate,
|
||||||
|
power.reboot, power.shutdown,
|
||||||
|
}
|
||||||
|
for action_def in ACTION_DEFINITIONS:
|
||||||
|
assert action_def.action_fn in power_functions, (
|
||||||
|
f"{action_def.name} references unknown power function"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_action_defs_match_i18n_fields_de(self) -> None:
|
||||||
|
"""All label/error/confirm attrs in ActionDefs exist in DE strings."""
|
||||||
|
strings = load_strings("de")
|
||||||
|
for action_def in ACTION_DEFINITIONS:
|
||||||
|
assert hasattr(strings, action_def.label_attr)
|
||||||
|
assert hasattr(strings, action_def.error_attr)
|
||||||
|
if action_def.confirm_attr:
|
||||||
|
assert hasattr(strings, action_def.confirm_attr)
|
||||||
|
|
||||||
|
def test_action_defs_match_i18n_fields_en(self) -> None:
|
||||||
|
"""All label/error/confirm attrs in ActionDefs exist in EN strings."""
|
||||||
|
strings = load_strings("en")
|
||||||
|
for action_def in ACTION_DEFINITIONS:
|
||||||
|
assert hasattr(strings, action_def.label_attr)
|
||||||
|
assert hasattr(strings, action_def.error_attr)
|
||||||
|
if action_def.confirm_attr:
|
||||||
|
assert hasattr(strings, action_def.confirm_attr)
|
||||||
|
|
||||||
|
def test_config_defaults_produce_valid_background_path(self) -> None:
|
||||||
|
"""Default config resolves to an existing wallpaper file."""
|
||||||
|
config = Config()
|
||||||
|
path = resolve_background_path(config)
|
||||||
|
assert path.suffix in (".jpg", ".png", ".webp")
|
||||||
|
|
||||||
|
def test_full_config_to_strings_flow(self, tmp_path: Path) -> None:
|
||||||
|
"""Config loading and string loading work independently."""
|
||||||
|
conf = tmp_path / "moonset.toml"
|
||||||
|
conf.write_text('background_path = "/custom/path.jpg"\n')
|
||||||
|
config = load_config(config_paths=[conf])
|
||||||
|
assert config.background_path == "/custom/path.jpg"
|
||||||
|
|
||||||
|
strings = load_strings("de")
|
||||||
|
assert strings.lock_label == "Sperren"
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"LANG": "de_DE.UTF-8"})
|
||||||
|
def test_german_locale_produces_german_labels(self) -> None:
|
||||||
|
"""Full flow: German locale → German button labels."""
|
||||||
|
strings = load_strings()
|
||||||
|
for action_def in ACTION_DEFINITIONS:
|
||||||
|
label = action_def.get_label(strings)
|
||||||
|
assert label
|
||||||
|
# German labels should not be the English ones
|
||||||
|
en_strings = load_strings("en")
|
||||||
|
en_label = action_def.get_label(en_strings)
|
||||||
|
assert label != en_label, (
|
||||||
|
f"{action_def.name}: DE and EN labels are identical"
|
||||||
|
)
|
||||||
68
tests/test_panel.py
Normal file
68
tests/test_panel.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# ABOUTME: Tests for the power menu panel UI module.
|
||||||
|
# ABOUTME: Verifies action button creation, confirmation flow, and dismiss behavior.
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch, PropertyMock
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from moonset.i18n import load_strings
|
||||||
|
from moonset.panel import (
|
||||||
|
ACTION_DEFINITIONS,
|
||||||
|
ActionDef,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestActionDefinitions:
|
||||||
|
"""Tests for action definition structure."""
|
||||||
|
|
||||||
|
def test_has_five_actions(self) -> None:
|
||||||
|
assert len(ACTION_DEFINITIONS) == 5
|
||||||
|
|
||||||
|
def test_action_order_by_destructiveness(self) -> None:
|
||||||
|
names = [a.name for a in ACTION_DEFINITIONS]
|
||||||
|
assert names == ["lock", "logout", "hibernate", "reboot", "shutdown"]
|
||||||
|
|
||||||
|
def test_lock_has_no_confirmation(self) -> None:
|
||||||
|
lock_def = ACTION_DEFINITIONS[0]
|
||||||
|
assert lock_def.name == "lock"
|
||||||
|
assert lock_def.needs_confirm is False
|
||||||
|
|
||||||
|
def test_destructive_actions_need_confirmation(self) -> None:
|
||||||
|
for action_def in ACTION_DEFINITIONS[1:]:
|
||||||
|
assert action_def.needs_confirm is True, (
|
||||||
|
f"{action_def.name} should need confirmation"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_all_actions_have_icon_names(self) -> None:
|
||||||
|
for action_def in ACTION_DEFINITIONS:
|
||||||
|
assert action_def.icon_name, f"{action_def.name} missing icon_name"
|
||||||
|
assert action_def.icon_name.endswith("-symbolic")
|
||||||
|
|
||||||
|
def test_all_actions_have_callable_functions(self) -> None:
|
||||||
|
for action_def in ACTION_DEFINITIONS:
|
||||||
|
assert callable(action_def.action_fn)
|
||||||
|
|
||||||
|
def test_action_labels_from_strings(self) -> None:
|
||||||
|
strings = load_strings("en")
|
||||||
|
for action_def in ACTION_DEFINITIONS:
|
||||||
|
label = action_def.get_label(strings)
|
||||||
|
assert label, f"{action_def.name} has empty label"
|
||||||
|
|
||||||
|
def test_action_error_messages_from_strings(self) -> None:
|
||||||
|
strings = load_strings("en")
|
||||||
|
for action_def in ACTION_DEFINITIONS:
|
||||||
|
error_msg = action_def.get_error_message(strings)
|
||||||
|
assert error_msg, f"{action_def.name} has empty error message"
|
||||||
|
|
||||||
|
def test_confirmable_actions_have_confirm_prompts(self) -> None:
|
||||||
|
strings = load_strings("en")
|
||||||
|
for action_def in ACTION_DEFINITIONS:
|
||||||
|
if action_def.needs_confirm:
|
||||||
|
prompt = action_def.get_confirm_prompt(strings)
|
||||||
|
assert prompt, f"{action_def.name} has empty confirm prompt"
|
||||||
|
|
||||||
|
def test_lock_confirm_prompt_is_none(self) -> None:
|
||||||
|
strings = load_strings("en")
|
||||||
|
lock_def = ACTION_DEFINITIONS[0]
|
||||||
|
assert lock_def.get_confirm_prompt(strings) is None
|
||||||
139
tests/test_power.py
Normal file
139
tests/test_power.py
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
# ABOUTME: Tests for power actions — lock, logout, hibernate, reboot, shutdown.
|
||||||
|
# ABOUTME: Uses mocking to avoid actually calling system commands.
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from moonset.power import lock, logout, hibernate, reboot, shutdown, POWER_TIMEOUT
|
||||||
|
|
||||||
|
|
||||||
|
class TestLock:
|
||||||
|
"""Tests for the lock power action."""
|
||||||
|
|
||||||
|
@patch("moonset.power.subprocess.run")
|
||||||
|
def test_calls_loginctl_lock_session(self, mock_run) -> None:
|
||||||
|
lock()
|
||||||
|
|
||||||
|
mock_run.assert_called_once_with(
|
||||||
|
["loginctl", "lock-session"], check=True, timeout=POWER_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("moonset.power.subprocess.run")
|
||||||
|
def test_raises_on_failure(self, mock_run) -> None:
|
||||||
|
mock_run.side_effect = subprocess.CalledProcessError(1, "loginctl")
|
||||||
|
|
||||||
|
with pytest.raises(subprocess.CalledProcessError):
|
||||||
|
lock()
|
||||||
|
|
||||||
|
@patch("moonset.power.subprocess.run")
|
||||||
|
def test_raises_on_timeout(self, mock_run) -> None:
|
||||||
|
mock_run.side_effect = subprocess.TimeoutExpired("loginctl", POWER_TIMEOUT)
|
||||||
|
|
||||||
|
with pytest.raises(subprocess.TimeoutExpired):
|
||||||
|
lock()
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogout:
|
||||||
|
"""Tests for the logout power action."""
|
||||||
|
|
||||||
|
@patch("moonset.power.subprocess.run")
|
||||||
|
def test_calls_niri_quit(self, mock_run) -> None:
|
||||||
|
logout()
|
||||||
|
|
||||||
|
mock_run.assert_called_once_with(
|
||||||
|
["niri", "msg", "action", "quit"], check=True, timeout=POWER_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("moonset.power.subprocess.run")
|
||||||
|
def test_raises_on_failure(self, mock_run) -> None:
|
||||||
|
mock_run.side_effect = subprocess.CalledProcessError(1, "niri")
|
||||||
|
|
||||||
|
with pytest.raises(subprocess.CalledProcessError):
|
||||||
|
logout()
|
||||||
|
|
||||||
|
@patch("moonset.power.subprocess.run")
|
||||||
|
def test_raises_on_timeout(self, mock_run) -> None:
|
||||||
|
mock_run.side_effect = subprocess.TimeoutExpired("niri", POWER_TIMEOUT)
|
||||||
|
|
||||||
|
with pytest.raises(subprocess.TimeoutExpired):
|
||||||
|
logout()
|
||||||
|
|
||||||
|
|
||||||
|
class TestHibernate:
|
||||||
|
"""Tests for the hibernate power action."""
|
||||||
|
|
||||||
|
@patch("moonset.power.subprocess.run")
|
||||||
|
def test_calls_systemctl_hibernate(self, mock_run) -> None:
|
||||||
|
hibernate()
|
||||||
|
|
||||||
|
mock_run.assert_called_once_with(
|
||||||
|
["systemctl", "hibernate"], check=True, timeout=POWER_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("moonset.power.subprocess.run")
|
||||||
|
def test_raises_on_failure(self, mock_run) -> None:
|
||||||
|
mock_run.side_effect = subprocess.CalledProcessError(1, "systemctl")
|
||||||
|
|
||||||
|
with pytest.raises(subprocess.CalledProcessError):
|
||||||
|
hibernate()
|
||||||
|
|
||||||
|
@patch("moonset.power.subprocess.run")
|
||||||
|
def test_raises_on_timeout(self, mock_run) -> None:
|
||||||
|
mock_run.side_effect = subprocess.TimeoutExpired("systemctl", POWER_TIMEOUT)
|
||||||
|
|
||||||
|
with pytest.raises(subprocess.TimeoutExpired):
|
||||||
|
hibernate()
|
||||||
|
|
||||||
|
|
||||||
|
class TestReboot:
|
||||||
|
"""Tests for the reboot power action."""
|
||||||
|
|
||||||
|
@patch("moonset.power.subprocess.run")
|
||||||
|
def test_calls_loginctl_reboot(self, mock_run) -> None:
|
||||||
|
reboot()
|
||||||
|
|
||||||
|
mock_run.assert_called_once_with(
|
||||||
|
["loginctl", "reboot"], check=True, timeout=POWER_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("moonset.power.subprocess.run")
|
||||||
|
def test_raises_on_failure(self, mock_run) -> None:
|
||||||
|
mock_run.side_effect = subprocess.CalledProcessError(1, "loginctl")
|
||||||
|
|
||||||
|
with pytest.raises(subprocess.CalledProcessError):
|
||||||
|
reboot()
|
||||||
|
|
||||||
|
@patch("moonset.power.subprocess.run")
|
||||||
|
def test_raises_on_timeout(self, mock_run) -> None:
|
||||||
|
mock_run.side_effect = subprocess.TimeoutExpired("loginctl", POWER_TIMEOUT)
|
||||||
|
|
||||||
|
with pytest.raises(subprocess.TimeoutExpired):
|
||||||
|
reboot()
|
||||||
|
|
||||||
|
|
||||||
|
class TestShutdown:
|
||||||
|
"""Tests for the shutdown power action."""
|
||||||
|
|
||||||
|
@patch("moonset.power.subprocess.run")
|
||||||
|
def test_calls_loginctl_poweroff(self, mock_run) -> None:
|
||||||
|
shutdown()
|
||||||
|
|
||||||
|
mock_run.assert_called_once_with(
|
||||||
|
["loginctl", "poweroff"], check=True, timeout=POWER_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("moonset.power.subprocess.run")
|
||||||
|
def test_raises_on_failure(self, mock_run) -> None:
|
||||||
|
mock_run.side_effect = subprocess.CalledProcessError(1, "loginctl")
|
||||||
|
|
||||||
|
with pytest.raises(subprocess.CalledProcessError):
|
||||||
|
shutdown()
|
||||||
|
|
||||||
|
@patch("moonset.power.subprocess.run")
|
||||||
|
def test_raises_on_timeout(self, mock_run) -> None:
|
||||||
|
mock_run.side_effect = subprocess.TimeoutExpired("loginctl", POWER_TIMEOUT)
|
||||||
|
|
||||||
|
with pytest.raises(subprocess.TimeoutExpired):
|
||||||
|
shutdown()
|
||||||
45
uv.lock
generated
Normal file
45
uv.lock
generated
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "moonset"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pygobject" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [{ name = "pygobject", specifier = ">=3.46" }]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pycairo"
|
||||||
|
version = "1.29.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/22/d9/1728840a22a4ef8a8f479b9156aa2943cd98c3907accd3849fb0d5f82bfd/pycairo-1.29.0.tar.gz", hash = "sha256:f3f7fde97325cae80224c09f12564ef58d0d0f655da0e3b040f5807bd5bd3142", size = 665871, upload-time = "2025-11-11T19:13:01.584Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/92/1b904087e831806a449502786d47d3a468e5edb8f65755f6bd88e8038e53/pycairo-1.29.0-cp311-cp311-win32.whl", hash = "sha256:12757ebfb304b645861283c20585c9204c3430671fad925419cba04844d6dfed", size = 751342, upload-time = "2025-11-11T19:11:37.386Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/09/a0ab6a246a7ede89e817d749a941df34f27a74bedf15551da51e86ae105e/pycairo-1.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:3391532db03f9601c1cee9ebfa15b7d1db183c6020f3e75c1348cee16825934f", size = 845036, upload-time = "2025-11-11T19:11:43.408Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/b2/bf455454bac50baef553e7356d36b9d16e482403bf132cfb12960d2dc2e7/pycairo-1.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:b69be8bb65c46b680771dc6a1a422b1cdd0cffb17be548f223e8cbbb6205567c", size = 694644, upload-time = "2025-11-11T19:11:48.599Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/28/6363087b9e60af031398a6ee5c248639eefc6cc742884fa2789411b1f73b/pycairo-1.29.0-cp312-cp312-win32.whl", hash = "sha256:91bcd7b5835764c616a615d9948a9afea29237b34d2ed013526807c3d79bb1d0", size = 751486, upload-time = "2025-11-11T19:11:54.451Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/d2/d146f1dd4ef81007686ac52231dd8f15ad54cf0aa432adaefc825475f286/pycairo-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f01c3b5e49ef9411fff6bc7db1e765f542dc1c9cfed4542958a5afa3a8b8e76", size = 845383, upload-time = "2025-11-11T19:12:01.551Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/16/6e6f33bb79ec4a527c9e633915c16dc55a60be26b31118dbd0d5859e8c51/pycairo-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:eafe3d2076f3533535ad4a361fa0754e0ee66b90e548a3a0f558fed00b1248f2", size = 694518, upload-time = "2025-11-11T19:12:06.561Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/21/3f477dc318dd4e84a5ae6301e67284199d7e5a2384f3063714041086b65d/pycairo-1.29.0-cp313-cp313-win32.whl", hash = "sha256:3eb382a4141591807073274522f7aecab9e8fa2f14feafd11ac03a13a58141d7", size = 750949, upload-time = "2025-11-11T19:12:12.198Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/34/7d27a333c558d6ac16dbc12a35061d389735e99e494ee4effa4ec6d99bed/pycairo-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:91114e4b3fbf4287c2b0788f83e1f566ce031bda49cf1c3c3c19c3e986e95c38", size = 844149, upload-time = "2025-11-11T19:12:19.171Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/43/e782131e23df69e5c8e631a016ed84f94bbc4981bf6411079f57af730a23/pycairo-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:09b7f69a5ff6881e151354ea092137b97b0b1f0b2ab4eb81c92a02cc4a08e335", size = 693595, upload-time = "2025-11-11T19:12:23.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/fa/87eaeeb9d53344c769839d7b2854db7ff2cd596211e00dd1b702eeb1838f/pycairo-1.29.0-cp314-cp314-win32.whl", hash = "sha256:69e2a7968a3fbb839736257bae153f547bca787113cc8d21e9e08ca4526e0b6b", size = 767198, upload-time = "2025-11-11T19:12:42.336Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/90/3564d0f64d0a00926ab863dc3c4a129b1065133128e96900772e1c4421f8/pycairo-1.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:e91243437a21cc4c67c401eff4433eadc45745275fa3ade1a0d877e50ffb90da", size = 871579, upload-time = "2025-11-11T19:12:48.982Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/91/93632b6ba12ad69c61991e3208bde88486fdfc152be8cfdd13444e9bc650/pycairo-1.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:b72200ea0e5f73ae4c788cd2028a750062221385eb0e6d8f1ecc714d0b4fdf82", size = 719537, upload-time = "2025-11-11T19:12:55.016Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/23/37053c039f8d3b9b5017af9bc64d27b680c48a898d48b72e6d6583cf0155/pycairo-1.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5e45fce6185f553e79e4ef1722b8e98e6cde9900dbc48cb2637a9ccba86f627a", size = 874015, upload-time = "2025-11-11T19:12:28.47Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/54/123f6239685f5f3f2edc123f1e38d2eefacebee18cf3c532d2f4bd51d0ef/pycairo-1.29.0-cp314-cp314t-win_arm64.whl", hash = "sha256:caba0837a4b40d47c8dfb0f24cccc12c7831e3dd450837f2a356c75f21ce5a15", size = 721404, upload-time = "2025-11-11T19:12:36.919Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygobject"
|
||||||
|
version = "3.56.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pycairo" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a2/80/09247a2be28af2c2240132a0af6c1005a2b1d089242b13a2cd782d2de8d7/pygobject-3.56.2.tar.gz", hash = "sha256:b816098969544081de9eecedb94ad6ac59c77e4d571fe7051f18bebcec074313", size = 1409059, upload-time = "2026-03-25T16:14:04.008Z" }
|
||||||
Loading…
x
Reference in New Issue
Block a user