Compare commits

..

4 Commits

Author SHA1 Message Date
473bed479a docs: add CHANGELOG.md, DECISIONS.md, bump version to 0.1.1
Add CHANGELOG documenting all changes since 0.1.0 and the initial
release. Add DECISIONS.md as an architectural decision log. Update
CLAUDE.md to reflect current architecture. Bump to 0.1.1 for the
security and correctness fixes in the previous commit.
2026-03-28 10:17:22 +01:00
496a7a4c72 fix: address audit findings — security, performance, and correctness
- Use absolute paths for all binaries in power.rs to prevent PATH hijacking
- Implement POWER_TIMEOUT via try_wait() polling (was declared but unused)
- Fix potential panic in load_background_texture when GResource path
  fails to_str() — now falls back to known wallpaper resource path
- Compress wallpaper.jpg in GResource bundle (saves ~374 KB in binary)
- Merge double idle_add_local_once into single cycle for faster focus
- Centralize GRESOURCE_PREFIX as pub(crate) const in main.rs
- Fix fallback user UID from 0 (root) to u32::MAX
- Fix CSS comment: "square card" → "circular card" (border-radius: 50%)
2026-03-28 10:13:18 +01:00
2d1d364270 i18n: migrate German text to English, remove stale journal
Translate README.md and config/moonset.toml comments from German
to English to enforce the repo language policy. Remove journal.md
as it was a one-time snapshot, not an actively maintained document.
2026-03-28 09:53:10 +01:00
b22172c3a0 perf: optimize startup by caching icons, texture, and async avatar
- Replace manual icon theme lookup + Pixbuf scaling with native
  GTK4 Image::from_icon_name() (uses internal cache + GPU rendering)
- Decode wallpaper texture once and share across all windows
  instead of N+1 separate JPEG decodes
- Load file-based avatars asynchronously via gio::spawn_blocking
  to avoid blocking the UI thread
2026-03-28 09:47:47 +01:00
14 changed files with 246 additions and 167 deletions

38
CHANGELOG.md Normal file
View File

@ -0,0 +1,38 @@
# 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, default-avatar.svg)
- `resources/` — GResource-Assets (style.css, wallpaper.jpg komprimiert, default-avatar.svg)
- `config/` — Beispiel-Konfigurationsdateien
## Kommandos
@ -35,20 +35,24 @@ LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonset
## Architektur
- `power.rs` — 5 Power-Action-Wrapper (lock, logout, hibernate, reboot, shutdown)
- `main.rs` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor, zentrale `GRESOURCE_PREFIX`-Konstante
- `power.rs` — 5 Power-Action-Wrapper mit absoluten Pfaden und 30s Timeout (lock, logout, hibernate, reboot, shutdown)
- `i18n.rs` — Locale-Erkennung und String-Tabellen (DE/EN)
- `config.rs` — TOML-Config + Wallpaper-Fallback
- `panel.rs` — GTK4 UI (Action-Buttons, Inline-Confirmation, WallpaperWindow)
- `main.rs` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor
- `resources/style.css`Catppuccin Mocha Theme (aus Python-Version übernommen)
- `users.rs` — User-Erkennung, Avatar-Loading (AccountsService, ~/.face, GResource-Fallback)
- `resources/style.css`GTK-Theme-Colors für Konsistenz mit dem aktiven Desktop-Theme
## 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
- **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
- **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

View File

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

52
DECISIONS.md Normal file
View File

@ -0,0 +1,52 @@
# 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 für das Moonarch-Ökosystem.
Wayland Session Power Menu for the Moonarch ecosystem.
Per Keybind aufrufbares Fullscreen-Overlay mit 5 Aktionen:
A fullscreen overlay triggered by keybind with 5 actions:
**Lock** · **Logout** · **Hibernate** · **Reboot** · **Shutdown**
## Features
- 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)
- 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)
## Installation
@ -22,48 +22,48 @@ cargo build --release
install -Dm755 target/release/moonset /usr/bin/moonset
```
Oder via PKGBUILD:
Or via PKGBUILD:
```bash
cd pkg && makepkg -si
```
## Verwendung
## Usage
```bash
# Direkt starten
# Launch directly
moonset
# Per Niri-Keybind (in ~/.config/niri/config.kdl)
# Via Niri keybind (in ~/.config/niri/config.kdl)
# binds {
# Mod+Escape { spawn "moonset"; }
# }
```
## Konfiguration
## Configuration
Konfigurationsdatei: `~/.config/moonset/moonset.toml` oder `/etc/moonset/moonset.toml`
Config file: `~/.config/moonset/moonset.toml` or `/etc/moonset/moonset.toml`
```toml
# Pfad zum Hintergrundbild (optional)
# Path to background image (optional)
background_path = "/usr/share/moonarch/wallpaper.jpg"
```
Wallpaper-Fallback: Konfiguration → `/usr/share/moonarch/wallpaper.jpg` → eingebettetes Package-Wallpaper
Wallpaper fallback: config → `/usr/share/moonarch/wallpaper.jpg` → bundled package wallpaper
## Entwicklung
## Development
```bash
# Tests
cargo test
# Release-Build
# Release build
cargo build --release
```
## Teil des Moonarch-Ökosystems
## Part of the Moonarch ecosystem
- **moonarch** — Reproduzierbares Arch-Linux-Setup
- **moongreet** — greetd Greeter für Wayland
- **moonlock** — Wayland Lockscreen
- **moonset** — Session Power Menu
- **moonarch** — Reproducible Arch Linux setup
- **moongreet** — greetd greeter for Wayland
- **moonlock** — Wayland lockscreen
- **moonset** — Session power menu

View File

@ -1,6 +1,6 @@
# Moonset — Wayland Session Power Menu
# Konfigurationsdatei: ~/.config/moonset/moonset.toml oder /etc/moonset/moonset.toml
# Config file: ~/.config/moonset/moonset.toml or /etc/moonset/moonset.toml
# Pfad zum Hintergrundbild (optional)
# Fallback-Reihenfolge: config → /usr/share/moonarch/wallpaper.jpg → Package-Wallpaper
# Path to background image (optional)
# Fallback order: config → /usr/share/moonarch/wallpaper.jpg → bundled package wallpaper
# background_path = "/usr/share/moonarch/wallpaper.jpg"

View File

@ -1,38 +0,0 @@
# 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>wallpaper.jpg</file>
<file compressed="true">wallpaper.jpg</file>
<file>default-avatar.svg</file>
</gresource>
</gresources>

View File

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

View File

@ -6,7 +6,6 @@ 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> {
@ -65,7 +64,8 @@ pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path)
}
// GResource fallback path (loaded from compiled resources at runtime)
PathBuf::from(format!("{GRESOURCE_PREFIX}/wallpaper.jpg"))
let prefix = crate::GRESOURCE_PREFIX;
PathBuf::from(format!("{prefix}/wallpaper.jpg"))
}
#[cfg(test)]

View File

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

View File

@ -79,14 +79,29 @@ 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(bg_path: &Path, app: &gtk::Application) -> gtk::ApplicationWindow {
pub fn create_wallpaper_window(texture: &gdk::Texture, app: &gtk::Application) -> gtk::ApplicationWindow {
let window = gtk::ApplicationWindow::builder()
.application(app)
.build();
window.add_css_class("wallpaper");
let background = create_background_picture(bg_path);
let background = create_background_picture(texture);
window.set_child(Some(&background));
// Fade-in on map
@ -104,7 +119,7 @@ pub fn create_wallpaper_window(bg_path: &Path, app: &gtk::Application) -> gtk::A
}
/// Create the main panel window with action buttons and confirm flow.
pub fn create_panel_window(bg_path: &Path, app: &gtk::Application) -> gtk::ApplicationWindow {
pub fn create_panel_window(texture: &gdk::Texture, app: &gtk::Application) -> gtk::ApplicationWindow {
let window = gtk::ApplicationWindow::builder()
.application(app)
.build();
@ -115,7 +130,7 @@ pub fn create_panel_window(bg_path: &Path, app: &gtk::Application) -> gtk::Appli
username: "user".to_string(),
display_name: "User".to_string(),
home: dirs::home_dir().unwrap_or_default(),
uid: 0,
uid: u32::MAX,
});
// State for confirm box
@ -126,7 +141,7 @@ pub fn create_panel_window(bg_path: &Path, app: &gtk::Application) -> gtk::Appli
window.set_child(Some(&overlay));
// Background wallpaper
let background = create_background_picture(bg_path);
let background = create_background_picture(texture);
overlay.set_child(Some(&background));
// Click on background dismisses the menu
@ -157,13 +172,8 @@ pub fn create_panel_window(bg_path: &Path, app: &gtk::Application) -> gtk::Appli
avatar_frame.append(&avatar_image);
content_box.append(&avatar_frame);
// 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);
}
// 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));
@ -226,24 +236,18 @@ pub fn create_panel_window(bg_path: &Path, app: &gtk::Application) -> gtk::Appli
let bb = button_box_clone.clone();
glib::idle_add_local_once(move || {
w.add_css_class("visible");
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.
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(""))
};
/// Create a Picture widget for the wallpaper background from a shared texture.
fn create_background_picture(texture: &gdk::Texture) -> gtk::Picture {
let background = gtk::Picture::for_paintable(texture);
background.set_content_fit(gtk::ContentFit::Cover);
background.set_hexpand(true);
background.set_vexpand(true);
@ -302,34 +306,9 @@ fn create_action_button(
button
}
/// Load a symbolic icon at 22px and scale to 64px via GdkPixbuf.
/// Load a symbolic icon using native GTK4 rendering at the target size.
fn load_scaled_icon(icon_name: &str) -> gtk::Image {
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));
let icon = gtk::Image::from_icon_name(icon_name);
icon.set_pixel_size(64);
icon
}
@ -479,15 +458,39 @@ fn execute_action(
));
}
/// 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));
/// 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")),
}
Err(_) => {
image.set_icon_name(Some("avatar-default-symbolic"));
}
));
}
None => {
// Default SVG avatar: needs widget color, keep synchronous
set_default_avatar(image, window);
}
}
}

View File

@ -2,16 +2,15 @@
// ABOUTME: Wrappers around system commands for the session power menu.
use std::fmt;
use std::process::Command;
use std::time::Duration;
use std::io::Read;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
#[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 },
}
@ -32,55 +31,73 @@ 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 child = Command::new(program)
let mut child = Command::new(program)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| 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);
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 {}: {}", output.status, stderr.trim()),
message: format!("exit code {}: {}", status, stderr_buf.trim()),
});
}
Ok(())
return Ok(());
}
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Err(PowerError::Timeout { action });
}
std::thread::sleep(Duration::from_millis(100));
}
Err(e) => {
return Err(PowerError::CommandFailed {
action,
message: e.to_string(),
});
}
}
}
}
/// Lock the current session by launching moonlock.
pub fn lock() -> Result<(), PowerError> {
run_command("lock", "moonlock", &[])
run_command("lock", "/usr/bin/moonlock", &[])
}
/// Quit the Niri compositor (logout).
pub fn logout() -> Result<(), PowerError> {
run_command("logout", "niri", &["msg", "action", "quit"])
run_command("logout", "/usr/bin/niri", &["msg", "action", "quit"])
}
/// Hibernate the system via systemctl.
pub fn hibernate() -> Result<(), PowerError> {
run_command("hibernate", "systemctl", &["hibernate"])
run_command("hibernate", "/usr/bin/systemctl", &["hibernate"])
}
/// Reboot the system via loginctl.
pub fn reboot() -> Result<(), PowerError> {
run_command("reboot", "loginctl", &["reboot"])
run_command("reboot", "/usr/bin/loginctl", &["reboot"])
}
/// Shut down the system via loginctl.
pub fn shutdown() -> Result<(), PowerError> {
run_command("shutdown", "loginctl", &["poweroff"])
run_command("shutdown", "/usr/bin/loginctl", &["poweroff"])
}
#[cfg(test)]

View File

@ -5,7 +5,6 @@ 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)]
@ -74,7 +73,8 @@ pub fn get_avatar_path_with(
/// Return the GResource path to the default avatar SVG.
pub fn get_default_avatar_path() -> String {
format!("{GRESOURCE_PREFIX}/default-avatar.svg")
let prefix = crate::GRESOURCE_PREFIX;
format!("{prefix}/default-avatar.svg")
}
#[cfg(test)]