2 Commits

Author SHA1 Message Date
nevaforget 510d45a9b1 fix: audit LOW fixes — rejection-path tests, wallpaper-fallback docs (v0.9.1)
Update PKGBUILD version / update-pkgver (push) Successful in 4s
- Test AccountsService-icon symlink rejection (users.rs)
- Tests for wallpaper symlink/extension/size rejection (config.rs)
- Fix stale 'bundled package wallpaper' fallback docs (README, example config) — bundled tier removed 2026-03-28, actual chain is two-tier
2026-06-17 13:06:15 +02:00
nevaforget 7dae48f6cc feat(power): logout via loginctl, not Niri-locked
Default `loginctl terminate-session $XDG_SESSION_ID`; `logout_command`
TOML override for other compositors.
2026-06-17 12:40:24 +02:00
11 changed files with 289 additions and 48 deletions
+66
View File
@@ -3,6 +3,72 @@
All notable changes to this project will be documented in this file.
Format based on [Keep a Changelog](https://keepachangelog.com/).
## [0.9.1] - 2026-06-17
### Fixed
- Wallpaper fallback docs (README, example config) referenced a removed "bundled package wallpaper" tier; corrected to two-tier (config → moonarch default → CSS background)
### Added
- Tests for avatar/wallpaper rejection paths (AccountsService symlink, wallpaper symlink/extension/size)
## [0.9.0] - 2026-06-17
### Changed
- Logout is no longer Niri-locked — default terminates the logind session via `loginctl terminate-session $XDG_SESSION_ID` (compositor-agnostic)
### Added
- `logout_command` config key — override the logout command (e.g. `niri msg action quit`) for non-logind or compositor-specific setups
## [0.8.5] - 2026-04-24
### Changed
- Require `MOONSET_DEBUG=1` (not mere presence of the variable) to raise log verbosity, so path information is not written to the journal by accident
### Fixed
- Warn when the home directory cannot be resolved instead of silently searching for `.face` in the current working directory
### Removed
- Drop the unused `uid` field from `users::User` and its obsolete `u32::MAX` fallback sentinel
## [0.8.4] - 2026-04-24
### Security
- Restrict wallpaper paths to an image-extension allowlist (jpg, jpeg, png, webp), reject symlinks, and cap file size at 10 MB — narrows the gdk-pixbuf parser attack surface
### Fixed
- Free the `spawn_blocking` slot on every `wait()` exit path (RAII guard) so a failed `wait()` no longer pins a thread for the full 30 s timeout; distinguish the timeout's own SIGKILL from an external OOM-kill
- Honor POSIX locale precedence (`LC_ALL``LC_MESSAGES``LANG`) before `/etc/locale.conf`, so an English install with `LC_ALL=de_DE.UTF-8` shows the German UI
- Disable the action buttons while an action runs, so a double-click or key repeat cannot trigger the same power action twice
## [0.8.3] - 2026-04-24
### Fixed
- Discard child stdout in `run_command` to remove a latent pipe-deadlock risk when a command writes more than one OS pipe buffer (~64 KB); stderr is still captured for error reporting
## [0.8.2] - 2026-04-06
### Fixed
- Restore keyboard focus to the action buttons after cancelling a confirmation — under layer-shell exclusive keyboard mode the menu otherwise became keyboard-unreachable
## [0.8.1] - 2026-03-31
### Changed
- Optimize the release profile (LTO, single codegen unit, symbol stripping)
- Compress the bundled GResource assets (CSS, SVG)
- Inherit moonlock's stderr so its errors surface in the journal instead of being discarded
## [0.8.0] - 2026-03-30
### Changed
+14 -42
View File
@@ -1,57 +1,29 @@
# Moonset
## Project
Wayland session power menu, part of the Moonarch ecosystem.
Keybind-invoked overlay with 5 actions: Lock, Logout, Hibernate, Reboot, Shutdown.
Moonset is a Wayland session power menu, built with Rust + gtk4-rs + gtk4-layer-shell.
Part of the Moonarch ecosystem. A keybind-invoked overlay with 5 actions:
Lock, Logout, Hibernate, Reboot, Shutdown.
## Tech Stack
- Rust (edition 2024), gtk4-rs 0.11, glib 0.22
- gtk4-layer-shell 0.8 for the Wayland Layer Shell (OVERLAY layer)
- `cargo test` for unit tests
## Project Structure
- `src/` — Rust source code (main.rs, power.rs, i18n.rs, config.rs, users.rs, panel.rs)
- `resources/` — GResource assets (style.css, default-avatar.svg)
- `config/` — example configuration files
Stack: Rust / gtk4-rs / gtk4-layer-shell (OVERLAY layer). Versions live in `Cargo.toml`.
## Commands
```bash
# Run tests
cargo test
# Release build
cargo build --release
# Start the power menu (in a Niri session)
LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonset
cargo test # unit tests
cargo build --release # release build
LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonset # run (in Niri)
```
## Architecture
## Source (`src/`)
- `main.rs` — entry point, GTK app, Layer Shell setup, multi-monitor, systemd journal logging, debug level via the `MOONSET_DEBUG` env var, central `GRESOURCE_PREFIX` constant
- `power.rs` — 5 power-action wrappers with absolute paths and a 30s timeout (lock, logout, hibernate, reboot, shutdown)
- `i18n.rs` — locale detection and string tables (DE/EN)
- `config.rs` — TOML config + wallpaper fallback
- `main.rs` — entry point, GTK app, Layer Shell, multi-monitor, journal logging (`MOONSET_DEBUG`), `GRESOURCE_PREFIX`
- `power.rs` — 5 power-action wrappers (absolute paths, 30s timeout)
- `panel.rs` — GTK4 UI (action buttons, inline confirmation, WallpaperWindow)
- `users.rs` — user detection, avatar loading (AccountsService, ~/.face, GResource fallback)
- `resources/style.css` — GTK theme colors for consistency with the active desktop theme
- `config.rs` — TOML config + wallpaper fallback
- `i18n.rs` — locale detection, DE/EN string tables
## Design Decisions
`resources/` holds the GResource bundle (style.css, default-avatar.svg); `config/` holds example configs.
See `DECISIONS.md` for the full decision log.
## Decisions
Summary of the most important decisions:
- **OVERLAY instead of TOP layer**: Waybar sits on TOP, moonset must be above it
- **Niri-specific logout** (`niri msg action quit`): Moonarch commits firmly to Niri
- **Single launch per keybind**: no daemon, the GTK `application_id` prevents double launch
- **System icons**: Adwaita/Catppuccin provide all required symbolic icons
- **Lock without confirmation**: lock is immediately reversible, needs no confirm
- **Absolute paths for binaries**: `/usr/bin/systemctl` etc. instead of relative paths (security)
- **GResource bundle**: CSS and default avatar are compiled into the binary (the wallpaper comes from the filesystem)
- **Async power actions**: `glib::spawn_future_local` + `gio::spawn_blocking` with a 30s timeout
- **Journal logging**: `systemd-journal-logger` instead of file logging — `journalctl -t moonset`, debug level via the `MOONSET_DEBUG` env var
See `DECISIONS.md` for the full decision log (layer choice, Niri logout, async power actions, journal logging, …).
Generated
+1 -1
View File
@@ -616,7 +616,7 @@ dependencies = [
[[package]]
name = "moonset"
version = "0.8.5"
version = "0.9.1"
dependencies = [
"dirs",
"gdk-pixbuf",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "moonset"
version = "0.8.5"
version = "0.9.1"
edition = "2024"
description = "Wayland session power menu with GTK4 and Layer Shell"
license = "MIT"
+8
View File
@@ -106,3 +106,11 @@ Architectural and design decisions for Moonset, in reverse chronological order.
- **Why**: Moonarch is built exclusively for the Niri compositor. Generic Wayland logout mechanisms don't exist — each compositor has its own.
- **Tradeoffs**: Hard dependency on Niri. If the compositor changes, `power::logout()` must be updated.
- **How**: `Command::new("/usr/bin/niri").args(["msg", "action", "quit"])`.
- **Superseded by** the 2026-06-17 entry below.
## 2026-06-17 Compositor-agnostic logout via `loginctl terminate-session`
- **Who**: ClaudeCode, Dom
- **Why**: Reverses the 2026-03-27 decision. Hardcoding `niri msg action quit` made logout the only compositor-locked action (lock/hibernate/reboot/shutdown were already agnostic via moonlock/systemctl). There is no Wayland protocol for "end session", but systemd-logind sits one layer below and offers a portable mechanism.
- **Tradeoffs**: `loginctl terminate-session` SIGTERMs all session processes rather than letting the compositor quit itself — functionally equivalent for logout. Requires a logind session (verified: greetd's PAM chain loads `pam_systemd`, which sets `XDG_SESSION_ID`). Niri's own quit command remains available via the override.
- **How**: `power::logout()` resolves a command — TOML `logout_command` override (space-separated) if set, else `/usr/bin/loginctl terminate-session $XDG_SESSION_ID`. Errors with a hint to set `logout_command` when no session id is present.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Dominik Kressler
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+16 -1
View File
@@ -15,6 +15,13 @@ A fullscreen overlay triggered by keybind with 5 actions:
- DE/EN localization
- Configurable wallpaper (TOML)
## Requirements
- A Wayland compositor with `wlr-layer-shell` (Moonarch targets **Niri**)
- A systemd-logind session for the default logout (`loginctl terminate-session`); other setups can override via `logout_command`
- Runtime: `gtk4`, `gtk4-layer-shell`
- Build: `cargo`, `git`
## Installation
```bash
@@ -47,9 +54,13 @@ Config file: `~/.config/moonset/moonset.toml` or `/etc/moonset/moonset.toml`
```toml
# Path to background image (optional)
background_path = "/usr/share/moonarch/wallpaper.jpg"
# Logout command override (optional)
# Default: loginctl terminate-session $XDG_SESSION_ID
logout_command = "niri msg action quit"
```
Wallpaper fallback: config → `/usr/share/moonarch/wallpaper.jpg`bundled package wallpaper
Wallpaper fallback: config → `/usr/share/moonarch/wallpaper.jpg`CSS background (no image)
## Development
@@ -67,3 +78,7 @@ cargo build --release
- **moongreet** — greetd greeter for Wayland
- **moonlock** — Wayland lockscreen
- **moonset** — Session power menu
## License
MIT
+7 -1
View File
@@ -2,5 +2,11 @@
# Config file: ~/.config/moonset/moonset.toml or /etc/moonset/moonset.toml
# Path to background image (optional)
# Fallback order: config → /usr/share/moonarch/wallpaper.jpg → bundled package wallpaper
# Fallback order: config → /usr/share/moonarch/wallpaper.jpg → CSS background (no image)
# background_path = "/usr/share/moonarch/wallpaper.jpg"
# Logout command override (optional, space-separated program + args)
# Default: terminate the logind session via `loginctl terminate-session $XDG_SESSION_ID`
# (compositor-agnostic). Set this only for non-logind setups or to delegate
# logout to the compositor.
# logout_command = "niri msg action quit"
+55
View File
@@ -21,6 +21,9 @@ fn default_config_paths() -> Vec<PathBuf> {
pub struct Config {
pub background_path: Option<String>,
pub background_blur: Option<f32>,
/// Override for the logout command (space-separated program + args).
/// When unset, logout terminates the logind session via `loginctl`.
pub logout_command: Option<String>,
}
/// Load config from TOML files. Later paths override earlier ones.
@@ -37,6 +40,9 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
if parsed.background_path.is_some() {
merged.background_path = parsed.background_path;
}
if parsed.logout_command.is_some() {
merged.logout_command = parsed.logout_command;
}
// Validate blur per source — invalid values preserve the previous default
if parsed.background_blur.is_some_and(|b| b.is_finite() && (0.0..=200.0).contains(&b)) {
merged.background_blur = parsed.background_blur;
@@ -129,6 +135,17 @@ mod tests {
let config = Config::default();
assert!(config.background_path.is_none());
assert!(config.background_blur.is_none());
assert!(config.logout_command.is_none());
}
#[test]
fn load_config_reads_logout_command() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moonset.toml");
fs::write(&conf, "logout_command = \"niri msg action quit\"\n").unwrap();
let paths = vec![conf];
let config = load_config(Some(&paths));
assert_eq!(config.logout_command.as_deref(), Some("niri msg action quit"));
}
#[test]
@@ -233,6 +250,44 @@ mod tests {
assert_eq!(result, None);
}
#[test]
fn resolve_rejects_symlinked_config_wallpaper() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("real.jpg");
fs::write(&target, "fake").unwrap();
let link = dir.path().join("link.jpg");
std::os::unix::fs::symlink(&target, &link).unwrap();
let config = Config {
background_path: Some(link.to_str().unwrap().to_string()),
..Config::default()
};
assert_eq!(resolve_background_path_with(&config, Path::new("/nonexistent")), None);
}
#[test]
fn resolve_rejects_disallowed_extension() {
let dir = tempfile::tempdir().unwrap();
let wp = dir.path().join("wallpaper.bmp");
fs::write(&wp, "fake").unwrap();
let config = Config {
background_path: Some(wp.to_str().unwrap().to_string()),
..Config::default()
};
assert_eq!(resolve_background_path_with(&config, Path::new("/nonexistent")), None);
}
#[test]
fn resolve_rejects_oversized_wallpaper() {
let dir = tempfile::tempdir().unwrap();
let wp = dir.path().join("huge.jpg");
fs::write(&wp, vec![0u8; (MAX_WALLPAPER_FILE_SIZE + 1) as usize]).unwrap();
let config = Config {
background_path: Some(wp.to_str().unwrap().to_string()),
..Config::default()
};
assert_eq!(resolve_background_path_with(&config, Path::new("/nonexistent")), None);
}
#[test]
fn load_config_ignores_invalid_toml_syntax() {
let dir = tempfile::tempdir().unwrap();
+86 -2
View File
@@ -125,9 +125,50 @@ pub fn lock() -> Result<(), PowerError> {
Ok(())
}
/// Quit the Niri compositor (logout).
/// Resolve the logout command into program + arguments.
///
/// Priority: a user-configured `logout_command` (space-separated) overrides
/// everything. Otherwise the logind session is terminated via
/// `loginctl terminate-session <id>` — compositor-agnostic, since every
/// pam_systemd session sets `XDG_SESSION_ID`.
fn resolve_logout_command(
override_cmd: Option<&str>,
session_id: Option<&str>,
) -> Result<Vec<String>, PowerError> {
if let Some(cmd) = override_cmd {
let parts: Vec<String> = cmd.split_whitespace().map(str::to_string).collect();
if parts.is_empty() {
return Err(PowerError::CommandFailed {
action: "logout",
message: "logout_command is empty".to_string(),
});
}
return Ok(parts);
}
match session_id {
Some(id) if !id.is_empty() => Ok(vec![
"/usr/bin/loginctl".to_string(),
"terminate-session".to_string(),
id.to_string(),
]),
_ => Err(PowerError::CommandFailed {
action: "logout",
message: "XDG_SESSION_ID unset; set logout_command in moonset.toml".to_string(),
}),
}
}
/// End the session (logout).
///
/// Terminates the logind session by default; honours a `logout_command`
/// override from the config for non-logind or compositor-specific setups.
pub fn logout() -> Result<(), PowerError> {
run_command("logout", "/usr/bin/niri", &["msg", "action", "quit"])
let config = crate::config::load_config(None);
let session_id = std::env::var("XDG_SESSION_ID").ok();
let parts = resolve_logout_command(config.logout_command.as_deref(), session_id.as_deref())?;
let args: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
run_command("logout", &parts[0], &args)
}
/// Hibernate the system via systemctl.
@@ -192,4 +233,47 @@ mod tests {
let result = run_command("test", "echo", &["hello", "world"]);
assert!(result.is_ok());
}
#[test]
fn logout_default_uses_loginctl_with_session_id() {
let parts = resolve_logout_command(None, Some("3")).unwrap();
assert_eq!(parts, vec!["/usr/bin/loginctl", "terminate-session", "3"]);
}
#[test]
fn logout_override_takes_precedence() {
let parts = resolve_logout_command(Some("niri msg action quit"), Some("3")).unwrap();
assert_eq!(parts, vec!["niri", "msg", "action", "quit"]);
}
#[test]
fn logout_override_ignores_session_id() {
// An override resolves even without a session id.
let parts = resolve_logout_command(Some("/usr/bin/swaymsg exit"), None).unwrap();
assert_eq!(parts, vec!["/usr/bin/swaymsg", "exit"]);
}
#[test]
fn logout_default_errors_without_session_id() {
assert!(matches!(
resolve_logout_command(None, None),
Err(PowerError::CommandFailed { action: "logout", .. })
));
}
#[test]
fn logout_default_errors_on_empty_session_id() {
assert!(matches!(
resolve_logout_command(None, Some("")),
Err(PowerError::CommandFailed { action: "logout", .. })
));
}
#[test]
fn logout_override_errors_when_blank() {
assert!(matches!(
resolve_logout_command(Some(" "), Some("3")),
Err(PowerError::CommandFailed { action: "logout", .. })
));
}
}
+14
View File
@@ -145,6 +145,20 @@ mod tests {
assert!(path.is_none());
}
#[test]
fn rejects_symlink_accountsservice_icon() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("secret");
fs::write(&target, "secret content").unwrap();
let icons_dir = dir.path().join("icons");
fs::create_dir(&icons_dir).unwrap();
let icon = icons_dir.join("testuser");
std::os::unix::fs::symlink(&target, &icon).unwrap();
// No ~/.face, so resolution falls through to the AccountsService branch
let path = get_avatar_path_with(dir.path(), Some("testuser"), &icons_dir);
assert!(path.is_none());
}
#[test]
fn returns_none_when_no_avatar() {
let dir = tempfile::tempdir().unwrap();