14 Commits

Author SHA1 Message Date
nevaforget 13b5ac1704 fix: audit fix — avoid latent stdout pipe deadlock in run_command (v0.8.3)
Piping stdout without draining while blocking in child.wait() risks deadlock
if a command writes more than one OS pipe buffer (~64 KB on Linux). Current
callers (systemctl, niri msg, loginctl) stay well under that, but the
structure was fragile. stdout is now discarded; stderr continues to be
captured for error reporting.
2026-04-24 13:01:48 +02:00
nevaforget a47fdff1dd docs: drop Hekate persona, unify attribution on ClaudeCode
Remove the Hekate persona block from CLAUDE.md and rewrite prior
DECISIONS entries from Hekate and leftover Ragnar to ClaudeCode
for consistency with the rest of the ecosystem.
2026-04-21 09:03:22 +02:00
nevaforget d030f1360a fix: restore keyboard focus on action buttons after dismiss (v0.8.2)
Update PKGBUILD version / update-pkgver (push) Successful in 2s
After cancelling a confirmation prompt, the focused widget was removed
from the tree without restoring focus to the action buttons. With
layer-shell exclusive keyboard mode, GTK does not recover focus
automatically — the UI became keyboard-unreachable.
2026-04-06 22:36:36 +02:00
nevaforget e97535e41b Remove unnecessary pacman git install from CI workflow
Git is already available in the runner image.
2026-04-02 08:28:08 +02:00
nevaforget b518572d0f Revert CI workaround: remove pacman install step
Update PKGBUILD version / update-pkgver (push) Failing after 0s
The act_runner now uses a custom Arch-based image with git
pre-installed, so per-workflow installs are no longer needed.
2026-04-01 16:17:48 +02:00
nevaforget b3ed7fb292 chore: update Cargo.lock for v0.8.1
Update PKGBUILD version / update-pkgver (push) Successful in 2s
2026-03-31 12:53:20 +02:00
nevaforget 358c228645 fix: audit fixes — release profile, GResource compression, lock stderr, sync markers (v0.8.1)
Update PKGBUILD version / update-pkgver (push) Successful in 1s
- Add [profile.release] with LTO, codegen-units=1, strip
- Add compressed="true" to GResource CSS/SVG entries
- Inherit moonlock stderr instead of /dev/null (errors visible in journal)
- Add SYNC comments to duplicated blur/background functions
2026-03-31 11:08:43 +02:00
nevaforget a4564f2b71 docs: add v0.8.0 changelog entry, fix build.rs comment
CHANGELOG was missing the v0.8.0 entry (symlink-safe avatars, blur
downscale + padding fix, config validation). build.rs comment still
referenced removed wallpaper.jpg.
2026-03-31 09:36:01 +02:00
nevaforget 8aca2bf331 fix: audit fixes — symlink-safe avatars, blur downscale + padding, config validation (v0.8.0)
Update PKGBUILD version / update-pkgver (push) Successful in 2s
- Replace canonicalize() with symlink_metadata + is_file + !is_symlink for avatar
  lookup (prevents symlink traversal to arbitrary files)
- Fix blur padding offset from (0,0) to (-pad,-pad) to prevent edge darkening
- Add MAX_BLUR_DIMENSION (1920px) downscale before GPU blur
- Validate blur per config source (invalid user value preserves system default)
- Wallpaper: use symlink_metadata + is_file + !is_symlink in resolve_background_path
2026-03-30 16:08:50 +02:00
nevaforget f01c6bd25d ci: also update .SRCINFO in pkgver workflow
Update PKGBUILD version / update-pkgver (push) Successful in 1s
paru reads .SRCINFO (not PKGBUILD) for version comparison during
sysupgrade. Without updating .SRCINFO, paru never detects upgrades
for PKGBUILD repository packages.
2026-03-30 13:49:16 +02:00
nevaforget 7cd1f8cb6d ci: replace actions/checkout with plain git clone (no node needed)
Update PKGBUILD version / update-pkgver (push) Successful in 2s
2026-03-29 23:02:58 +02:00
nevaforget c22bc5bca1 ci: use moonarch runner label instead of ubuntu-latest
Update PKGBUILD version / update-pkgver (push) Failing after 18s
2026-03-29 23:02:09 +02:00
nevaforget 069387761b ci: add workflow to auto-update pkgver in moonarch-pkgbuilds
Update PKGBUILD version / update-pkgver (push) Failing after 1m23s
2026-03-29 22:55:48 +02:00
nevaforget e59ed53d7a fix: use systemctl for reboot/shutdown — loginctl lacks these verbs (v0.7.3) 2026-03-29 18:59:00 +02:00
12 changed files with 186 additions and 63 deletions
+43
View File
@@ -0,0 +1,43 @@
# 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
+19
View File
@@ -3,6 +3,25 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
Format based on [Keep a Changelog](https://keepachangelog.com/). 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 ## [0.7.2] - 2026-03-29
### Fixed ### Fixed
-2
View File
@@ -1,7 +1,5 @@
# 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 Rust + gtk4-rs + gtk4-layer-shell.
Generated
+1 -1
View File
@@ -616,7 +616,7 @@ dependencies = [
[[package]] [[package]]
name = "moonset" name = "moonset"
version = "0.7.2" version = "0.8.3"
dependencies = [ dependencies = [
"dirs", "dirs",
"gdk-pixbuf", "gdk-pixbuf",
+6 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "moonset" name = "moonset"
version = "0.7.2" version = "0.8.3"
edition = "2024" edition = "2024"
description = "Wayland session power menu with GTK4 and Layer Shell" description = "Wayland session power menu with GTK4 and Layer Shell"
license = "MIT" license = "MIT"
@@ -22,5 +22,10 @@ systemd-journal-logger = "2.2"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
[profile.release]
lto = "thin"
codegen-units = 1
strip = true
[build-dependencies] [build-dependencies]
glib-build-tools = "0.22" glib-build-tools = "0.22"
+25 -11
View File
@@ -2,79 +2,93 @@
Architectural and design decisions for Moonset, in reverse chronological order. Architectural and design decisions for Moonset, in reverse chronological order.
## 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 ## 2026-03-28 Remove wallpaper from GResource bundle
- **Who**: Ragnar, Dom - **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). - **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. - **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. - **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 ## 2026-03-28 Switch from env_logger to systemd-journal-logger
- **Who**: Ragnar, Dom - **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. - **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. - **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. - **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 ## 2026-03-28 Replace action name dispatch with `quit_after` field
- **Who**: Hekate, Dom - **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. - **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. - **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. - **How**: `ActionDef.quit_after: bool``true` for lock and logout, `false` for hibernate/reboot/shutdown.
## 2026-03-28 GPU blur via GskBlurNode replaces CPU blur ## 2026-03-28 GPU blur via GskBlurNode replaces CPU blur
- **Who**: Ragnar, Dom - **Who**: ClaudeCode, Dom
- **Why**: CPU-side Gaussian blur (`image` crate) blocked startup and added caching complexity. moonlock already migrated to GPU blur. - **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. - **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. - **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 ## 2026-03-28 Use absolute paths for system binaries
- **Who**: Hekate, Dom - **Who**: ClaudeCode, Dom
- **Why**: Security audit flagged PATH hijacking risk — relative binary names allow a malicious `$PATH` entry to intercept `systemctl`, `loginctl`, etc. - **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. - **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. - **How**: All five power action wrappers now use `/usr/bin/` prefixed paths.
## 2026-03-28 Implement power action timeout via try_wait polling ## 2026-03-28 Implement power action timeout via try_wait polling
- **Who**: Hekate, Dom - **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. - **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. - **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. - **How**: `run_command` now polls `child.try_wait()` against a 30s deadline, kills the child on timeout.
## 2026-03-28 Centralize GRESOURCE_PREFIX ## 2026-03-28 Centralize GRESOURCE_PREFIX
- **Who**: Hekate, Dom - **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. - **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. - **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. - **How**: Single `pub(crate) const GRESOURCE_PREFIX` in `main.rs`, referenced everywhere else.
## 2026-03-28 Remove journal.md ## 2026-03-28 Remove journal.md
- **Who**: Hekate, Dom - **Who**: ClaudeCode, Dom
- **Why**: One-time development notes from the Rust rewrite, never updated after initial session. Overlapped with memory system and git history. - **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. - **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. - **How**: Deleted. Useful technical learnings migrated to persistent memory.
## 2026-03-27 OVERLAY layer instead of TOP ## 2026-03-27 OVERLAY layer instead of TOP
- **Who**: Hekate, Dom - **Who**: ClaudeCode, Dom
- **Why**: Waybar occupies the TOP layer. The power menu must appear above it. - **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. - **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. - **How**: `setup_layer_shell` uses `gtk4_layer_shell::Layer::Overlay` for the panel window.
## 2026-03-27 Lock without confirmation ## 2026-03-27 Lock without confirmation
- **Who**: Hekate, Dom - **Who**: ClaudeCode, Dom
- **Why**: Lock is immediately reversible (just unlock). All other actions (logout, hibernate, reboot, shutdown) are destructive or disruptive. - **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. - **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. - **How**: `ActionDef.needs_confirm = false` for lock; all others require inline confirmation.
## 2026-03-27 Niri-specific logout via `niri msg action quit` ## 2026-03-27 Niri-specific logout via `niri msg action quit`
- **Who**: Hekate, Dom - **Who**: ClaudeCode, Dom
- **Why**: Moonarch is built exclusively for the Niri compositor. Generic Wayland logout mechanisms don't exist — each compositor has its own. - **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. - **Tradeoffs**: Hard dependency on Niri. If the compositor changes, `power::logout()` must be updated.
- **How**: `Command::new("/usr/bin/niri").args(["msg", "action", "quit"])`. - **How**: `Command::new("/usr/bin/niri").args(["msg", "action", "quit"])`.
+1 -1
View File
@@ -1,5 +1,5 @@
// ABOUTME: Build script for compiling GResource bundle. // ABOUTME: Build script for compiling GResource bundle.
// ABOUTME: Bundles style.css, wallpaper.jpg, and default-avatar.svg into the binary. // ABOUTME: Bundles style.css and default-avatar.svg into the binary.
fn main() { fn main() {
glib_build_tools::compile_resources( glib_build_tools::compile_resources(
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<gresources> <gresources>
<gresource prefix="/dev/moonarch/moonset"> <gresource prefix="/dev/moonarch/moonset">
<file>style.css</file> <file compressed="true">style.css</file>
<file>default-avatar.svg</file> <file compressed="true">default-avatar.svg</file>
</gresource> </gresource>
</gresources> </gresources>
+8 -11
View File
@@ -37,8 +37,11 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
if parsed.background_path.is_some() { if parsed.background_path.is_some() {
merged.background_path = parsed.background_path; merged.background_path = parsed.background_path;
} }
if parsed.background_blur.is_some() { // 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; merged.background_blur = parsed.background_blur;
} else if parsed.background_blur.is_some() {
log::warn!("Invalid background_blur in {}, ignoring", path.display());
} }
} }
Err(e) => { Err(e) => {
@@ -48,14 +51,6 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
} }
} }
// Validate blur range
if let Some(blur) = merged.background_blur {
if !blur.is_finite() || blur < 0.0 || blur > 200.0 {
log::warn!("Invalid background_blur value {blur}, ignoring");
merged.background_blur = None;
}
}
merged merged
} }
@@ -69,14 +64,16 @@ pub fn resolve_background_path(config: &Config) -> Option<PathBuf> {
/// Resolve with configurable moonarch wallpaper path (for testing). /// Resolve with configurable moonarch wallpaper path (for testing).
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> Option<PathBuf> { pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> Option<PathBuf> {
// User-configured path // User-configured path — reject symlinks to prevent path traversal
if let Some(ref bg) = config.background_path { if let Some(ref bg) = config.background_path {
let path = PathBuf::from(bg); let path = PathBuf::from(bg);
if path.is_file() { if let Ok(meta) = path.symlink_metadata() {
if meta.is_file() && !meta.file_type().is_symlink() {
log::debug!("Wallpaper source: config ({})", path.display()); log::debug!("Wallpaper source: config ({})", path.display());
return Some(path); return Some(path);
} }
} }
}
// Moonarch ecosystem default // Moonarch ecosystem default
if moonarch_wallpaper.is_file() { if moonarch_wallpaper.is_file() {
+42 -7
View File
@@ -103,11 +103,22 @@ pub fn load_background_texture(bg_path: Option<&Path>) -> Option<gdk::Texture> {
// -- GPU blur via GskBlurNode ------------------------------------------------- // -- 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. /// Render a blurred texture using the GPU via GskBlurNode.
/// ///
/// To avoid edge darkening (blur samples transparent pixels outside bounds), /// To avoid edge darkening (blur samples transparent pixels outside bounds),
/// the texture is rendered with padding equal to 3x the blur sigma. The blur /// 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. /// 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( fn render_blurred_texture(
widget: &impl IsA<gtk::Widget>, widget: &impl IsA<gtk::Widget>,
texture: &gdk::Texture, texture: &gdk::Texture,
@@ -116,17 +127,29 @@ fn render_blurred_texture(
let native = widget.native()?; let native = widget.native()?;
let renderer = native.renderer()?; let renderer = native.renderer()?;
let w = texture.width() as f32; let orig_w = texture.width() as f32;
let h = texture.height() 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) // Padding must cover the blur kernel radius (typically ~3x sigma)
let pad = (sigma * 3.0).ceil(); let pad = (scaled_sigma * 3.0).ceil();
let snapshot = gtk::Snapshot::new(); let snapshot = gtk::Snapshot::new();
// Clip output to original texture size // Clip output to scaled texture size
snapshot.push_clip(&graphene_rs::Rect::new(pad, pad, w, h)); snapshot.push_clip(&graphene_rs::Rect::new(pad, pad, w, h));
snapshot.push_blur(sigma as f64); snapshot.push_blur(scaled_sigma as f64);
// Render texture with padding on all sides (edges repeat via oversized bounds) // Render texture with padding on all sides (edges repeat via oversized bounds)
snapshot.append_texture(texture, &graphene_rs::Rect::new(0.0, 0.0, w + 2.0 * pad, h + 2.0 * pad)); snapshot.append_texture(texture, &graphene_rs::Rect::new(-pad, -pad, w + 2.0 * pad, h + 2.0 * pad));
snapshot.pop(); // blur snapshot.pop(); // blur
snapshot.pop(); // clip snapshot.pop(); // clip
@@ -269,6 +292,7 @@ pub fn create_panel_window(texture: Option<&gdk::Texture>, blur_radius: Option<f
&confirm_area, &confirm_area,
&confirm_box, &confirm_box,
&error_label, &error_label,
&button_box,
); );
button_box.append(&button); button_box.append(&button);
} }
@@ -352,6 +376,7 @@ fn create_action_button(
confirm_area: &gtk::Box, confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>, confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label, error_label: &gtk::Label,
button_box: &gtk::Box,
) -> gtk::Button { ) -> gtk::Button {
let button_content = gtk::Box::new(gtk::Orientation::Vertical, 4); let button_content = gtk::Box::new(gtk::Orientation::Vertical, 4);
button_content.set_halign(gtk::Align::Center); button_content.set_halign(gtk::Align::Center);
@@ -381,6 +406,8 @@ fn create_action_button(
confirm_box, confirm_box,
#[weak] #[weak]
error_label, error_label,
#[weak]
button_box,
move |_| { move |_| {
on_action_clicked( on_action_clicked(
&action_def, &action_def,
@@ -389,6 +416,7 @@ fn create_action_button(
&confirm_area, &confirm_area,
&confirm_box, &confirm_box,
&error_label, &error_label,
&button_box,
); );
} }
)); ));
@@ -411,6 +439,7 @@ fn on_action_clicked(
confirm_area: &gtk::Box, confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>, confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label, error_label: &gtk::Label,
button_box: &gtk::Box,
) { ) {
dismiss_confirm(confirm_area, confirm_box); dismiss_confirm(confirm_area, confirm_box);
error_label.set_visible(false); error_label.set_visible(false);
@@ -420,7 +449,7 @@ fn on_action_clicked(
return; return;
} }
show_confirm(action_def, strings, app, confirm_area, confirm_box, error_label); show_confirm(action_def, strings, app, confirm_area, confirm_box, error_label, button_box);
} }
/// Show inline confirmation below the action buttons. /// Show inline confirmation below the action buttons.
@@ -431,6 +460,7 @@ fn show_confirm(
confirm_area: &gtk::Box, confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>, confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label, error_label: &gtk::Label,
button_box: &gtk::Box,
) { ) {
let new_box = gtk::Box::new(gtk::Orientation::Vertical, 8); let new_box = gtk::Box::new(gtk::Orientation::Vertical, 8);
new_box.set_halign(gtk::Align::Center); new_box.set_halign(gtk::Align::Center);
@@ -478,8 +508,13 @@ fn show_confirm(
confirm_area, confirm_area,
#[strong] #[strong]
confirm_box, confirm_box,
#[weak]
button_box,
move |_| { move |_| {
dismiss_confirm(&confirm_area, &confirm_box); dismiss_confirm(&confirm_area, &confirm_box);
if let Some(first) = button_box.first_child() {
first.grab_focus();
}
} }
)); ));
button_row.append(&no_btn); button_row.append(&no_btn);
+8 -6
View File
@@ -40,7 +40,9 @@ fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(),
log::debug!("Power action: {action} ({program} {args:?})"); log::debug!("Power action: {action} ({program} {args:?})");
let mut child = Command::new(program) let mut child = Command::new(program)
.args(args) .args(args)
.stdout(Stdio::piped()) // 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()) .stderr(Stdio::piped())
.spawn() .spawn()
.map_err(|e| PowerError::CommandFailed { .map_err(|e| PowerError::CommandFailed {
@@ -107,7 +109,7 @@ pub fn lock() -> Result<(), PowerError> {
Command::new("/usr/bin/moonlock") Command::new("/usr/bin/moonlock")
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::inherit())
.spawn() .spawn()
.map_err(|e| PowerError::CommandFailed { .map_err(|e| PowerError::CommandFailed {
action: "lock", action: "lock",
@@ -127,14 +129,14 @@ pub fn hibernate() -> Result<(), PowerError> {
run_command("hibernate", "/usr/bin/systemctl", &["hibernate"]) run_command("hibernate", "/usr/bin/systemctl", &["hibernate"])
} }
/// Reboot the system via loginctl. /// Reboot the system via systemctl.
pub fn reboot() -> Result<(), PowerError> { pub fn reboot() -> Result<(), PowerError> {
run_command("reboot", "/usr/bin/loginctl", &["reboot"]) run_command("reboot", "/usr/bin/systemctl", &["reboot"])
} }
/// Shut down the system via loginctl. /// Shut down the system via systemctl.
pub fn shutdown() -> Result<(), PowerError> { pub fn shutdown() -> Result<(), PowerError> {
run_command("shutdown", "/usr/bin/loginctl", &["poweroff"]) run_command("shutdown", "/usr/bin/systemctl", &["poweroff"])
} }
#[cfg(test)] #[cfg(test)]
+29 -19
View File
@@ -47,32 +47,34 @@ pub fn get_avatar_path(home: &Path, username: Option<&str>) -> Option<PathBuf> {
} }
/// Find avatar with configurable AccountsService dir (for testing). /// Find avatar with configurable AccountsService dir (for testing).
/// Rejects symlinks to prevent path traversal.
pub fn get_avatar_path_with( pub fn get_avatar_path_with(
home: &Path, home: &Path,
username: Option<&str>, username: Option<&str>,
accountsservice_dir: &Path, accountsservice_dir: &Path,
) -> Option<PathBuf> { ) -> Option<PathBuf> {
// ~/.face takes priority — canonicalize to resolve symlinks // ~/.face takes priority
let face = home.join(".face"); let face = home.join(".face");
if face.exists() { if let Ok(meta) = face.symlink_metadata() {
if let Ok(canonical) = std::fs::canonicalize(&face) { if meta.file_type().is_symlink() {
log::debug!("Avatar: using ~/.face ({})", canonical.display()); log::warn!("Rejecting symlink avatar: {}", face.display());
return Some(canonical); } else if meta.is_file() {
log::debug!("Avatar: using ~/.face ({})", face.display());
return Some(face);
} }
// canonicalize failed (e.g. permissions) — skip rather than return unresolved symlink
log::warn!("Avatar: ~/.face exists but canonicalize failed, skipping");
} }
// AccountsService icon — also canonicalize for consistency // AccountsService icon fallback
if let Some(name) = username { if let Some(name) = username {
if accountsservice_dir.exists() { if accountsservice_dir.exists() {
let icon = accountsservice_dir.join(name); let icon = accountsservice_dir.join(name);
if icon.exists() { if let Ok(meta) = icon.symlink_metadata() {
if let Ok(canonical) = std::fs::canonicalize(&icon) { if meta.file_type().is_symlink() {
log::debug!("Avatar: using AccountsService icon ({})", canonical.display()); log::warn!("Rejecting symlink avatar: {}", icon.display());
return Some(canonical); } else if meta.is_file() {
log::debug!("Avatar: using AccountsService icon ({})", icon.display());
return Some(icon);
} }
log::warn!("Avatar: AccountsService icon exists but canonicalize failed, skipping");
} }
} }
} }
@@ -107,8 +109,7 @@ mod tests {
let face = dir.path().join(".face"); let face = dir.path().join(".face");
fs::write(&face, "fake image").unwrap(); fs::write(&face, "fake image").unwrap();
let path = get_avatar_path_with(dir.path(), None, Path::new("/nonexistent")); let path = get_avatar_path_with(dir.path(), None, Path::new("/nonexistent"));
let expected = fs::canonicalize(&face).unwrap(); assert_eq!(path, Some(face));
assert_eq!(path, Some(expected));
} }
#[test] #[test]
@@ -119,8 +120,7 @@ mod tests {
let icon = icons_dir.join("testuser"); let icon = icons_dir.join("testuser");
fs::write(&icon, "fake image").unwrap(); fs::write(&icon, "fake image").unwrap();
let path = get_avatar_path_with(dir.path(), Some("testuser"), &icons_dir); let path = get_avatar_path_with(dir.path(), Some("testuser"), &icons_dir);
let expected = fs::canonicalize(&icon).unwrap(); assert_eq!(path, Some(icon));
assert_eq!(path, Some(expected));
} }
#[test] #[test]
@@ -133,8 +133,18 @@ mod tests {
let icon = icons_dir.join("testuser"); let icon = icons_dir.join("testuser");
fs::write(&icon, "fake image").unwrap(); fs::write(&icon, "fake image").unwrap();
let path = get_avatar_path_with(dir.path(), Some("testuser"), &icons_dir); let path = get_avatar_path_with(dir.path(), Some("testuser"), &icons_dir);
let expected = fs::canonicalize(&face).unwrap(); assert_eq!(path, Some(face));
assert_eq!(path, Some(expected)); }
#[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] #[test]