Compare commits

..

No commits in common. "473bed479ae31bea6e13e391fdd8812c4cbd95c6" and "d6979c179215b3d93747c1160f14b5a2142e1af9" have entirely different histories.

14 changed files with 167 additions and 246 deletions

View File

@ -1,38 +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.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

View File

@ -17,7 +17,7 @@ Lock, Logout, Hibernate, Reboot, Shutdown.
## Projektstruktur
- `src/` — Rust-Quellcode (main.rs, power.rs, i18n.rs, config.rs, users.rs, panel.rs)
- `resources/` — GResource-Assets (style.css, wallpaper.jpg komprimiert, default-avatar.svg)
- `resources/` — GResource-Assets (style.css, wallpaper.jpg, default-avatar.svg)
- `config/` — Beispiel-Konfigurationsdateien
## Kommandos
@ -35,24 +35,20 @@ LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonset
## Architektur
- `main.rs` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor, zentrale `GRESOURCE_PREFIX`-Konstante
- `power.rs` — 5 Power-Action-Wrapper mit absoluten Pfaden und 30s Timeout (lock, logout, hibernate, reboot, shutdown)
- `power.rs` — 5 Power-Action-Wrapper (lock, logout, hibernate, reboot, shutdown)
- `i18n.rs` — Locale-Erkennung und String-Tabellen (DE/EN)
- `config.rs` — TOML-Config + Wallpaper-Fallback
- `panel.rs` — GTK4 UI (Action-Buttons, Inline-Confirmation, WallpaperWindow)
- `users.rs` — User-Erkennung, Avatar-Loading (AccountsService, ~/.face, GResource-Fallback)
- `resources/style.css`GTK-Theme-Colors für Konsistenz mit dem aktiven Desktop-Theme
- `main.rs` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor
- `resources/style.css`Catppuccin Mocha Theme (aus Python-Version übernommen)
## 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
- **Niri-spezifischer Logout** (`niri msg action quit`): Moonarch setzt fest auf Niri
- **Einmal-Start per Keybind**: Kein Daemon, GTK `application_id` verhindert Doppelstart
- **System-Icons**: Adwaita/Catppuccin liefern alle benötigten symbolischen Icons
- **Lock ohne Confirmation**: Lock ist sofort reversibel, braucht kein Confirm
- **Absolute Pfade für Binaries**: `/usr/bin/systemctl` etc. statt relativer Pfade (Security)
- **GResource-Bundle**: CSS, Wallpaper (komprimiert) und Default-Avatar sind in die Binary kompiliert
- **Async Power Actions**: `glib::spawn_future_local` + `gio::spawn_blocking` mit 30s Timeout
- **Icon-Scaling**: 22px Theme-Variante laden, auf 64px skalieren via GdkPixbuf
- **GResource-Bundle**: CSS, Wallpaper und Default-Avatar sind in die Binary kompiliert
- **Async Power Actions**: `glib::spawn_future_local` + `gio::spawn_blocking` statt raw Threads

View File

@ -1,6 +1,6 @@
[package]
name = "moonset"
version = "0.1.1"
version = "0.1.0"
edition = "2024"
description = "Wayland session power menu with GTK4 and Layer Shell"
license = "MIT"

View File

@ -1,52 +0,0 @@
# Decisions
Architectural and design decisions for Moonset, in reverse chronological order.
## 2026-03-28 Use absolute paths for system binaries
- **Who**: Hekate, 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**: Hekate, 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**: Hekate, 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**: Hekate, 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**: Hekate, 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**: Hekate, 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**: Hekate, 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"])`.

View File

@ -1,19 +1,19 @@
# 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**
## Features
- Rust + gtk4-rs + gtk4-layer-shell (OVERLAY layer — above Waybar)
- Catppuccin Mocha theme
- Multi-monitor support (wallpaper on secondary monitors)
- Inline confirmation for destructive actions
- Escape or background click to dismiss
- DE/EN localization
- Configurable wallpaper (TOML)
- Rust + gtk4-rs + gtk4-layer-shell (OVERLAY Layer — über Waybar)
- Catppuccin Mocha Theme
- Multi-Monitor-Support (Wallpaper auf Sekundärmonitoren)
- Inline-Confirmation für destruktive Aktionen
- Escape oder Hintergrund-Klick zum Schließen
- DE/EN Lokalisierung
- Konfigurierbare Wallpaper (TOML)
## Installation
@ -22,48 +22,48 @@ cargo build --release
install -Dm755 target/release/moonset /usr/bin/moonset
```
Or via PKGBUILD:
Oder via PKGBUILD:
```bash
cd pkg && makepkg -si
```
## Usage
## Verwendung
```bash
# Launch directly
# Direkt starten
moonset
# Via Niri keybind (in ~/.config/niri/config.kdl)
# Per Niri-Keybind (in ~/.config/niri/config.kdl)
# binds {
# 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
# Path to background image (optional)
# Pfad zum Hintergrundbild (optional)
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` → eingebettetes Package-Wallpaper
## Development
## Entwicklung
```bash
# Tests
cargo test
# Release build
# Release-Build
cargo build --release
```
## Part of the Moonarch ecosystem
## Teil des Moonarch-Ökosystems
- **moonarch** — Reproducible Arch Linux setup
- **moongreet** — greetd greeter for Wayland
- **moonlock** — Wayland lockscreen
- **moonset** — Session power menu
- **moonarch** — Reproduzierbares Arch-Linux-Setup
- **moongreet** — greetd Greeter für Wayland
- **moonlock** — Wayland Lockscreen
- **moonset** — Session Power Menu

View File

@ -1,6 +1,6 @@
# 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)
# Fallback order: config → /usr/share/moonarch/wallpaper.jpg → bundled package wallpaper
# Pfad zum Hintergrundbild (optional)
# Fallback-Reihenfolge: config → /usr/share/moonarch/wallpaper.jpg → Package-Wallpaper
# background_path = "/usr/share/moonarch/wallpaper.jpg"

38
journal.md Normal file
View File

@ -0,0 +1,38 @@
# Hekate — Journal
## 2026-03-27 — Rust Rewrite
Rewrite von Python auf Rust (gtk4-rs + gtk4-layer-shell). Motivation: ~800ms Startzeit der Python-Version durch Interpreter-Overhead.
Alle Module 1:1 portiert:
- `power.rs` — Command::new statt subprocess.run, PowerError enum statt Exceptions
- `i18n.rs` — Static Strings statt Dataclass, parse_lang_prefix() separat testbar (kein env::set_var nötig)
- `config.rs` — serde::Deserialize für TOML, GResource-Pfad als letzter Fallback
- `users.rs` — nix-crate für getuid/getpwuid, GResource-Pfad für default-avatar
- `panel.rs` — Freie Funktionen statt Klassen, Rc<RefCell> für Confirm-State, glib::spawn_future_local + gio::spawn_blocking für async Power-Actions
- `main.rs` — GResource-Registration, LayerShell trait statt Gtk4LayerShell-Modul
45 Unit-Tests grün. Release-Binary: 3.1 MB.
Gelernt:
- gtk4-rs 0.11 braucht Rust ≥1.92 (system hatte 1.91 → rustup update)
- `ContentFit` und `Widget::color()` brauchen Feature-Flags (`v4_8`, `v4_10`)
- GTK-Objekte (WeakRef) sind nicht Send → glib::spawn_future_local statt std::thread für UI-Updates
- `set_from_paintable` heißt jetzt `set_paintable` in gtk4-rs 0.11
- GResource-Bundle kompiliert CSS/Wallpaper/Avatar in die Binary — kein importlib.resources mehr nötig
## 2026-03-27 — Initiale Python-Version
Erster Tag. Moonset von Null auf v0.1.0 gebracht. TDD durchgezogen — alle 54 Tests grün, bevor der erste manuelle Start passiert ist. Das Pattern aus moongreet/moonlock hat sich bewährt: power.py, i18n.py, config.py sind fast 1:1 übernommen, nur mit den 5 Aktionen erweitert.
Layer Shell brauchte `LD_PRELOAD` — selbes Thema wie bei moongreet. GI-Import allein reicht nicht, weil die Linker-Reihenfolge stimmen muss. Erster Start ohne LD_PRELOAD gab die bekannten Warnings, mit LD_PRELOAD lief alles sauber: Overlay auf allen Monitoren, Escape schließt, Buttons da.
Designentscheidung: Lock ohne Confirmation, alles andere mit Inline-Confirm. Fühlt sich richtig an — Lock ist sofort reversibel, Shutdown nicht.
v0.2.0 direkt hinterher. Viel gelernt:
- `exclusive_zone = -1` ist Pflicht, sonst respektiert man Waybars Zone
- Monitor-Detection über `is_primary()` ist unzuverlässig auf Niri — stattdessen kein `set_monitor()` und den Compositor entscheiden lassen
- Icon-Theme-Lookup: 22px-Variante laden und per GdkPixbuf auf 64px skalieren, damit die gleichen Icons wie bei moonlock erscheinen
- CSS Fade-In Animationen auf Layer Shell Surfaces wirken ruckelig (wenige FPS) — rausgenommen
- `loginctl lock-session` braucht einen D-Bus-Listener der schwer aufzusetzen ist — moonlock direkt aufrufen ist einfacher und zuverlässiger
- LD_PRELOAD über den Niri-Keybind setzen spart den Reexec und damit ~1s Startzeit

View File

@ -2,7 +2,7 @@
<gresources>
<gresource prefix="/dev/moonarch/moonset">
<file>style.css</file>
<file compressed="true">wallpaper.jpg</file>
<file>wallpaper.jpg</file>
<file>default-avatar.svg</file>
</gresource>
</gresources>

View File

@ -31,7 +31,7 @@ window.wallpaper {
margin-bottom: 40px;
}
/* Action button — circular card */
/* Action button — square card */
.action-button {
min-width: 120px;
min-height: 120px;

View File

@ -6,6 +6,7 @@ use std::fs;
use std::path::{Path, PathBuf};
const MOONARCH_WALLPAPER: &str = "/usr/share/moonarch/wallpaper.jpg";
const GRESOURCE_PREFIX: &str = "/dev/moonarch/moonset";
/// Default config search paths: system-wide, then user-specific.
fn default_config_paths() -> Vec<PathBuf> {
@ -64,8 +65,7 @@ pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path)
}
// GResource fallback path (loaded from compiled resources at runtime)
let prefix = crate::GRESOURCE_PREFIX;
PathBuf::from(format!("{prefix}/wallpaper.jpg"))
PathBuf::from(format!("{GRESOURCE_PREFIX}/wallpaper.jpg"))
}
#[cfg(test)]

View File

@ -12,11 +12,9 @@ use gtk4::prelude::*;
use gtk4::{self as gtk, gio};
use gtk4_layer_shell::LayerShell;
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"));
css_provider.load_from_resource("/dev/moonarch/moonset/style.css");
gtk::style_context_add_provider_for_display(
display,
&css_provider,
@ -53,13 +51,12 @@ fn activate(app: &gtk::Application) {
load_css(&display);
// Resolve wallpaper once, decode texture once, share across all windows
// Resolve wallpaper once, share across all windows
let config = config::load_config(None);
let bg_path = config::resolve_background_path(&config);
let texture = panel::load_background_texture(&bg_path);
// Panel on focused output (no set_monitor → compositor picks focused)
let panel = panel::create_panel_window(&texture, app);
let panel = panel::create_panel_window(&bg_path, app);
setup_layer_shell(&panel, true, gtk4_layer_shell::Layer::Overlay);
panel.present();
@ -67,7 +64,7 @@ fn activate(app: &gtk::Application) {
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, app);
let wallpaper = panel::create_wallpaper_window(&bg_path, app);
setup_layer_shell(&wallpaper, false, gtk4_layer_shell::Layer::Top);
wallpaper.set_monitor(Some(&monitor));
wallpaper.present();

View File

@ -79,29 +79,14 @@ pub fn action_definitions() -> Vec<ActionDef> {
]
}
/// Load the wallpaper as a texture once, for sharing across all windows.
pub fn load_background_texture(bg_path: &Path) -> gdk::Texture {
let fallback = format!("{}/wallpaper.jpg", crate::GRESOURCE_PREFIX);
if bg_path.starts_with(crate::GRESOURCE_PREFIX) {
let resource_path = bg_path.to_str().unwrap_or(&fallback);
gdk::Texture::from_resource(resource_path)
} else {
let file = gio::File::for_path(bg_path);
gdk::Texture::from_file(&file).unwrap_or_else(|_| {
gdk::Texture::from_resource(&fallback)
})
}
}
/// Create a wallpaper-only window for secondary monitors.
pub fn create_wallpaper_window(texture: &gdk::Texture, app: &gtk::Application) -> gtk::ApplicationWindow {
pub fn create_wallpaper_window(bg_path: &Path, app: &gtk::Application) -> gtk::ApplicationWindow {
let window = gtk::ApplicationWindow::builder()
.application(app)
.build();
window.add_css_class("wallpaper");
let background = create_background_picture(texture);
let background = create_background_picture(bg_path);
window.set_child(Some(&background));
// Fade-in on map
@ -119,7 +104,7 @@ pub fn create_wallpaper_window(texture: &gdk::Texture, app: &gtk::Application) -
}
/// Create the main panel window with action buttons and confirm flow.
pub fn create_panel_window(texture: &gdk::Texture, app: &gtk::Application) -> gtk::ApplicationWindow {
pub fn create_panel_window(bg_path: &Path, app: &gtk::Application) -> gtk::ApplicationWindow {
let window = gtk::ApplicationWindow::builder()
.application(app)
.build();
@ -130,7 +115,7 @@ pub fn create_panel_window(texture: &gdk::Texture, app: &gtk::Application) -> gt
username: "user".to_string(),
display_name: "User".to_string(),
home: dirs::home_dir().unwrap_or_default(),
uid: u32::MAX,
uid: 0,
});
// State for confirm box
@ -141,7 +126,7 @@ pub fn create_panel_window(texture: &gdk::Texture, app: &gtk::Application) -> gt
window.set_child(Some(&overlay));
// Background wallpaper
let background = create_background_picture(texture);
let background = create_background_picture(bg_path);
overlay.set_child(Some(&background));
// Click on background dismisses the menu
@ -172,8 +157,13 @@ pub fn create_panel_window(texture: &gdk::Texture, app: &gtk::Application) -> gt
avatar_frame.append(&avatar_image);
content_box.append(&avatar_frame);
// Load avatar (file-based avatars load asynchronously)
load_avatar_async(&avatar_image, &window, &user);
// Load avatar
let avatar_path = users::get_avatar_path(&user.home, Some(&user.username));
if let Some(path) = avatar_path {
set_avatar_from_file(&avatar_image, &path);
} else {
set_default_avatar(&avatar_image, &window);
}
// Username label
let username_label = gtk::Label::new(Some(&user.display_name));
@ -236,18 +226,24 @@ pub fn create_panel_window(texture: &gdk::Texture, app: &gtk::Application) -> gt
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();
}
glib::idle_add_local_once(move || {
if let Some(first) = bb.first_child() {
first.grab_focus();
}
});
});
});
window
}
/// Create a Picture widget for the wallpaper background from a shared texture.
fn create_background_picture(texture: &gdk::Texture) -> gtk::Picture {
let background = gtk::Picture::for_paintable(texture);
/// Create a Picture widget for the wallpaper background.
fn create_background_picture(bg_path: &Path) -> gtk::Picture {
let background = if bg_path.starts_with("/dev/moonarch/moonset") {
gtk::Picture::for_resource(bg_path.to_str().unwrap_or(""))
} else {
gtk::Picture::for_filename(bg_path.to_str().unwrap_or(""))
};
background.set_content_fit(gtk::ContentFit::Cover);
background.set_hexpand(true);
background.set_vexpand(true);
@ -306,9 +302,34 @@ fn create_action_button(
button
}
/// Load a symbolic icon using native GTK4 rendering at the target size.
/// Load a symbolic icon at 22px and scale to 64px via GdkPixbuf.
fn load_scaled_icon(icon_name: &str) -> gtk::Image {
let icon = gtk::Image::from_icon_name(icon_name);
let display = gdk::Display::default().unwrap();
let theme = gtk::IconTheme::for_display(&display);
let icon_paintable = theme.lookup_icon(
icon_name,
&[],
22,
1,
gtk::TextDirection::None,
gtk::IconLookupFlags::FORCE_SYMBOLIC,
);
let icon = gtk::Image::new();
if let Some(file) = icon_paintable.file() {
if let Some(path) = file.path() {
if let Ok(pixbuf) =
Pixbuf::from_file_at_scale(path.to_str().unwrap_or(""), 64, 64, true)
{
let texture = gdk::Texture::for_pixbuf(&pixbuf);
icon.set_paintable(Some(&texture));
return icon;
}
}
}
// Fallback: use icon name directly
icon.set_icon_name(Some(icon_name));
icon.set_pixel_size(64);
icon
}
@ -458,39 +479,15 @@ fn execute_action(
));
}
/// Load the avatar asynchronously. File-based avatars are decoded off the UI thread.
fn load_avatar_async(image: &gtk::Image, window: &gtk::ApplicationWindow, user: &users::User) {
let avatar_path = users::get_avatar_path(&user.home, Some(&user.username));
match avatar_path {
Some(path) => {
// 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.to_str().unwrap_or(""),
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")),
}
}
));
/// Load an image file and set it as the avatar.
fn set_avatar_from_file(image: &gtk::Image, path: &Path) {
match Pixbuf::from_file_at_scale(path.to_str().unwrap_or(""), AVATAR_SIZE, AVATAR_SIZE, true) {
Ok(pixbuf) => {
let texture = gdk::Texture::for_pixbuf(&pixbuf);
image.set_paintable(Some(&texture));
}
None => {
// Default SVG avatar: needs widget color, keep synchronous
set_default_avatar(image, window);
Err(_) => {
image.set_icon_name(Some("avatar-default-symbolic"));
}
}
}

View File

@ -2,15 +2,16 @@
// ABOUTME: Wrappers around system commands for the session power menu.
use std::fmt;
use std::io::Read;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use std::process::Command;
use std::time::Duration;
#[allow(dead_code)]
const POWER_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug)]
pub enum PowerError {
CommandFailed { action: &'static str, message: String },
#[allow(dead_code)]
Timeout { action: &'static str },
}
@ -31,73 +32,55 @@ impl std::error::Error for PowerError {}
/// Run a command with timeout and return a PowerError on failure.
fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(), PowerError> {
let mut child = Command::new(program)
let child = Command::new(program)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| PowerError::CommandFailed {
action,
message: e.to_string(),
})?;
let deadline = Instant::now() + POWER_TIMEOUT;
loop {
match child.try_wait() {
Ok(Some(status)) => {
if !status.success() {
let mut stderr_buf = String::new();
if let Some(mut stderr) = child.stderr.take() {
let _ = stderr.read_to_string(&mut stderr_buf);
}
return Err(PowerError::CommandFailed {
action,
message: format!("exit code {}: {}", status, stderr_buf.trim()),
});
}
return Ok(());
}
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Err(PowerError::Timeout { action });
}
std::thread::sleep(Duration::from_millis(100));
}
Err(e) => {
return Err(PowerError::CommandFailed {
action,
message: e.to_string(),
});
}
}
let output = child
.wait_with_output()
.map_err(|e| PowerError::CommandFailed {
action,
message: e.to_string(),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PowerError::CommandFailed {
action,
message: format!("exit code {}: {}", output.status, stderr.trim()),
});
}
Ok(())
}
/// Lock the current session by launching moonlock.
pub fn lock() -> Result<(), PowerError> {
run_command("lock", "/usr/bin/moonlock", &[])
run_command("lock", "moonlock", &[])
}
/// Quit the Niri compositor (logout).
pub fn logout() -> Result<(), PowerError> {
run_command("logout", "/usr/bin/niri", &["msg", "action", "quit"])
run_command("logout", "niri", &["msg", "action", "quit"])
}
/// Hibernate the system via systemctl.
pub fn hibernate() -> Result<(), PowerError> {
run_command("hibernate", "/usr/bin/systemctl", &["hibernate"])
run_command("hibernate", "systemctl", &["hibernate"])
}
/// Reboot the system via loginctl.
pub fn reboot() -> Result<(), PowerError> {
run_command("reboot", "/usr/bin/loginctl", &["reboot"])
run_command("reboot", "loginctl", &["reboot"])
}
/// Shut down the system via loginctl.
pub fn shutdown() -> Result<(), PowerError> {
run_command("shutdown", "/usr/bin/loginctl", &["poweroff"])
run_command("shutdown", "loginctl", &["poweroff"])
}
#[cfg(test)]

View File

@ -5,6 +5,7 @@ use nix::unistd::{getuid, User as NixUser};
use std::path::{Path, PathBuf};
const DEFAULT_ACCOUNTSSERVICE_DIR: &str = "/var/lib/AccountsService/icons";
const GRESOURCE_PREFIX: &str = "/dev/moonarch/moonset";
/// Represents the current user for the power menu.
#[derive(Debug, Clone)]
@ -73,8 +74,7 @@ pub fn get_avatar_path_with(
/// 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")
format!("{GRESOURCE_PREFIX}/default-avatar.svg")
}
#[cfg(test)]