From d1aaeaffce72e82929a159c14ef9b5d6821e3dea Mon Sep 17 00:00:00 2001 From: Digvijay Mahapatra Date: Tue, 31 Mar 2026 22:37:28 +0530 Subject: [PATCH] fix: surface hyprpaper compatibility warnings Warn when Hyprland is sourcing hyprpaper config files and when Walt cannot verify wallpaper changes, so users get an actionable explanation instead of a false success signal. --- README.md | 2 + src/backend/hyprpaper.rs | 166 +++++++++++++++++++++++++++++++++++++-- src/gui/app.rs | 14 +++- src/main.rs | 9 +++ src/ui/mod.rs | 14 +++- 5 files changed, 198 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index bd87fa4..3625312 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,8 @@ Make sure `hyprpaper` is running: exec-once = hyprpaper ``` +If you are on newer `hyprpaper`, do not add `source = ~/.config/hypr/hyprpaper.conf` to `hyprland.conf`. `hyprpaper` reads `~/.config/hypr/hyprpaper.conf` on its own, and sourcing that file from Hyprland can prevent wallpaper updates even though Walt reports that the IPC call succeeded. + Walt works across older and newer `hyprpaper` behavior and falls back gracefully when active wallpaper status is unavailable. Debug logs are written to `~/.cache/walt/logs/walt.log`. Use `WALT_LOG=debug` to increase verbosity. diff --git a/src/backend/hyprpaper.rs b/src/backend/hyprpaper.rs index ec9ef25..3a4dfe3 100644 --- a/src/backend/hyprpaper.rs +++ b/src/backend/hyprpaper.rs @@ -1,6 +1,7 @@ use std::{ collections::HashSet, - path::PathBuf, + fs, + path::{Path, PathBuf}, process::{Command, Output}, sync::{Mutex, OnceLock}, thread, @@ -8,6 +9,7 @@ use std::{ }; use log::{debug, error, info, warn}; +use walkdir::WalkDir; const HYPERPAPER_SERVICE: &str = "hyprpaper.service"; const HYPERPAPER_PROCESS_NAME: &str = "hyprpaper"; @@ -463,6 +465,86 @@ fn unsupported_active_query_verification_message( ) } +fn active_query_user_notice(active_query_support: CapabilitySupport) -> Option<&'static str> { + (active_query_support == CapabilitySupport::Unsupported).then_some( + "Walt sent the wallpaper command, but it cannot verify the visual result because this hyprpaper build does not support `hyprctl hyprpaper listactive`.", + ) +} + +pub fn hyprpaper_compatibility_notice() -> Option { + compatibility_notice( + detect_hyprpaper_source_in_hyprland_config().as_deref(), + cached_capability_support(&ACTIVE_QUERY_SUPPORT_CACHE), + ) +} + +fn compatibility_notice( + source_file: Option<&Path>, + active_query_support: CapabilitySupport, +) -> Option { + let active_query_notice = active_query_user_notice(active_query_support); + let source_notice = source_file.map(format_hyprpaper_source_notice); + + match (active_query_notice, source_notice) { + (Some(active_query_notice), Some(source_notice)) => { + Some(format!("{active_query_notice} {source_notice}")) + } + (Some(active_query_notice), None) => Some(active_query_notice.to_string()), + (None, Some(source_notice)) => Some(source_notice), + (None, None) => None, + } +} + +fn format_hyprpaper_source_notice(path: &Path) -> String { + format!( + "Hyprland config `{}` appears to source a hyprpaper config. Remove that `source = ...hyprpaper...` line and keep `exec-once = hyprpaper`; newer hyprpaper reads its own config directly.", + path.display() + ) +} + +fn detect_hyprpaper_source_in_hyprland_config() -> Option { + let hypr_config_dir = dirs::config_dir()?.join("hypr"); + detect_hyprpaper_source_in_dir(&hypr_config_dir) +} + +fn detect_hyprpaper_source_in_dir(dir: &Path) -> Option { + if !dir.is_dir() { + return None; + } + + WalkDir::new(dir) + .into_iter() + .filter_map(Result::ok) + .find_map(|entry| { + let path = entry.path(); + if !entry.file_type().is_file() + || path.extension().and_then(|ext| ext.to_str()) != Some("conf") + { + return None; + } + + let content = fs::read_to_string(path).ok()?; + content + .lines() + .any(line_sources_hyprpaper_config) + .then(|| path.to_path_buf()) + }) +} + +fn line_sources_hyprpaper_config(line: &str) -> bool { + let uncommented = line.split('#').next().unwrap_or("").trim(); + let Some((directive, value)) = uncommented.split_once('=') else { + return false; + }; + + if directive.trim() != "source" { + return false; + } + + let value = value.trim().to_ascii_lowercase(); + value.contains("hyprpaper.conf") || value.contains("hyprpaper.d") +} + fn apply_wallpaper_to_monitor(monitor_name: &str, wallpaper_path: &str) -> anyhow::Result<()> { let arg = format!("{monitor_name},{wallpaper_path}"); debug!("applying hyprpaper wallpaper arg={arg}"); @@ -620,10 +702,12 @@ fn should_wait_for_another_hyprpaper_attempt(attempt: usize, max_attempts: usize #[cfg(test)] mod tests { use super::{ - active_wallpaper_assignments_or_empty, active_wallpapers_from_assignments, - capability_cache_state_after_result, classify_backend_unavailable_message, - classify_hyprpaper_command_failure_message, command_output_details, + active_query_user_notice, active_wallpaper_assignments_or_empty, + active_wallpapers_from_assignments, capability_cache_state_after_result, + classify_backend_unavailable_message, classify_hyprpaper_command_failure_message, + command_output_details, compatibility_notice, compatibility_wrap_active_wallpaper_assignments, deduplicate_active_wallpapers, + detect_hyprpaper_source_in_dir, line_sources_hyprpaper_config, parse_active_wallpaper_assignments, parse_monitors, preload_unique_wallpapers, should_retry_hyprpaper_command_failure, should_wait_for_another_hyprpaper_attempt, summarize_multi_monitor_apply_failures, unique_wallpaper_paths, @@ -631,7 +715,23 @@ mod tests { ActiveWallpaperAssignment, BackendUnavailableReason, CapabilitySupport, HyprpaperCommandFailure, Monitor, PreloadSupport, }; - use std::{os::unix::process::ExitStatusExt, path::PathBuf, process::Output}; + use std::{ + fs, + os::unix::process::ExitStatusExt, + path::{Path, PathBuf}, + process::Output, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn make_temp_dir() -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before unix epoch") + .as_nanos(); + let dir = std::env::temp_dir().join(format!("walt-hyprpaper-test-{unique}")); + fs::create_dir_all(&dir).expect("create temp dir"); + dir + } #[test] fn parses_one_monitor() { @@ -968,6 +1068,62 @@ mod tests { ); } + #[test] + fn reports_user_facing_notice_when_active_query_is_unsupported() { + assert_eq!( + active_query_user_notice(CapabilitySupport::Unsupported), + Some( + "Walt sent the wallpaper command, but it cannot verify the visual result because this hyprpaper build does not support `hyprctl hyprpaper listactive`." + ) + ); + assert_eq!(active_query_user_notice(CapabilitySupport::Supported), None); + } + + #[test] + fn detects_hyprpaper_config_source_lines() { + assert!(line_sources_hyprpaper_config( + "source = ~/.config/hypr/hyprpaper.conf" + )); + assert!(line_sources_hyprpaper_config( + "source = ~/.config/hypr/hyprpaper.d/*.conf" + )); + assert!(!line_sources_hyprpaper_config( + "# source = ~/.config/hypr/hyprpaper.conf" + )); + assert!(!line_sources_hyprpaper_config("exec-once = hyprpaper")); + } + + #[test] + fn finds_hyprpaper_source_in_hypr_config_tree() { + let root = make_temp_dir(); + let config_dir = root.join("conf.d"); + fs::create_dir_all(&config_dir).expect("create config dir"); + let config_path = config_dir.join("paper.conf"); + fs::write( + &config_path, + "source = ~/.config/hypr/hyprpaper.conf\nexec-once = hyprpaper\n", + ) + .expect("write config"); + + let detected = detect_hyprpaper_source_in_dir(&root).expect("detect source line"); + assert_eq!(detected, config_path); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + + #[test] + fn combines_active_query_and_source_notice() { + let notice = compatibility_notice( + Some(Path::new("/home/test/.config/hypr/hyprland.conf")), + CapabilitySupport::Unsupported, + ) + .expect("combined notice"); + + assert!(notice.contains("cannot verify the visual result")); + assert!(notice.contains("source a hyprpaper config")); + assert!(notice.contains("exec-once = hyprpaper")); + } + #[test] fn capability_cache_marks_success_as_supported() { assert_eq!( diff --git a/src/gui/app.rs b/src/gui/app.rs index 1f1dbca..79cb2b4 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -15,7 +15,9 @@ use log::error; use tokio_util::sync::CancellationToken; use crate::{ - backend::hyprpaper::get_active_wallpaper_assignments_if_supported, + backend::hyprpaper::{ + get_active_wallpaper_assignments_if_supported, hyprpaper_compatibility_notice, + }, backend::{ apply_random_plan, disable_rotation_service, download_image_async, enable_rotation_service, format_rotation_service_status, get_monitors, get_rotation_service_status, @@ -306,6 +308,7 @@ impl GuiApp { app.refresh_active_wallpapers(true); app.select_active_wallpaper_in_all(); app.refresh_rotation_status(); + app.show_hyprpaper_compatibility_notice(); install_editorial_mono(&cc.egui_ctx); app.apply_visuals(&cc.egui_ctx); if !app.config.is_empty() { @@ -411,6 +414,12 @@ impl GuiApp { self.push_toast(ToastKind::Info, message); } + fn show_hyprpaper_compatibility_notice(&mut self) { + if let Some(message) = hyprpaper_compatibility_notice() { + self.info(message); + } + } + fn success(&mut self, message: impl Into) { let message = message.into(); log::info!("gui success: {message}"); @@ -1141,6 +1150,7 @@ impl GuiApp { active_wallpaper_paths_from_assignments(&self.active_wallpaper_assignments); self.refresh_active_wallpapers(false); self.sync_selection_with_random_plan(&plan); + self.show_hyprpaper_compatibility_notice(); if let Some(assignment) = plan.assignments.first() { if matches!(plan.mode, RandomMode::DifferentAll) { @@ -1232,6 +1242,7 @@ impl GuiApp { active_wallpaper_paths_from_assignments(&self.active_wallpaper_assignments); self.refresh_active_wallpapers(false); self.success(format!("Wallpaper set on {monitor_name}: {path_str}")); + self.show_hyprpaper_compatibility_notice(); Ok(()) } @@ -1249,6 +1260,7 @@ impl GuiApp { active_wallpaper_paths_from_assignments(&self.active_wallpaper_assignments); self.refresh_active_wallpapers(false); self.success(format!("Wallpaper set on all displays: {path_str}")); + self.show_hyprpaper_compatibility_notice(); Ok(()) } diff --git a/src/main.rs b/src/main.rs index f722d40..34929be 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,8 @@ use std::io::{self, BufRead, IsTerminal, Write}; use anyhow::{bail, Context, Result}; +use crate::backend::hyprpaper::hyprpaper_compatibility_notice; + const ROTATION_OPTIONS: &str = "install, enable, disable, uninstall, status, interval"; const ROTATION_USAGE: &str = "walt rotation "; const RANDOM_USAGE: &str = "walt random [--same|DISPLAY_INDEX]"; @@ -211,9 +213,16 @@ fn run_random_wallpaper(command: RandomCommand) -> Result<()> { plan.assignments.len() ); print_random_summary(&plan); + print_hyprpaper_compatibility_notice(); Ok(()) } +fn print_hyprpaper_compatibility_notice() { + if let Some(message) = hyprpaper_compatibility_notice() { + println!("Note: {message}"); + } +} + fn print_random_summary(plan: &backend::RandomPlan) { match &plan.mode { backend::RandomMode::DifferentAll => { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index a2c51ea..556624b 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -32,7 +32,9 @@ use std::{ }; use tokio_util::sync::CancellationToken; -use crate::backend::hyprpaper::get_active_wallpaper_assignments_if_supported; +use crate::backend::hyprpaper::{ + get_active_wallpaper_assignments_if_supported, hyprpaper_compatibility_notice, +}; use crate::backend::{ apply_random_plan, disable_rotation_service, download_image_async, enable_rotation_service, get_monitors, get_rotation_service_status, install_rotation_service, plan_random_assignments, @@ -334,6 +336,12 @@ pub struct App { } impl App { + fn print_hyprpaper_compatibility_notice(&self) { + if let Some(message) = hyprpaper_compatibility_notice() { + println!("Note: {message}"); + } + } + pub fn new() -> anyhow::Result { let config = Config::new(); let theme = ThemeKind::from_name(&config.theme_name); @@ -429,6 +437,7 @@ impl App { app.refresh_active_wallpapers(); app.select_active_wallpaper_in_all(); app.refresh_rotation_status(); + app.print_hyprpaper_compatibility_notice(); if !app.config.is_empty() { app.request_index_refresh(); @@ -1705,6 +1714,7 @@ impl App { ); self.refresh_active_wallpapers(); self.sync_selection_with_random_plan(&plan); + self.print_hyprpaper_compatibility_notice(); if let Some(assignment) = plan.assignments.first() { if matches!(plan.mode, RandomMode::DifferentAll) { @@ -2871,6 +2881,7 @@ impl App { active_wallpaper_paths_from_assignments(&self.active_wallpaper_assignments); self.refresh_active_wallpapers(); println!("Wallpaper set on {monitor_name}: {path_str}"); + self.print_hyprpaper_compatibility_notice(); Ok(()) } @@ -2888,6 +2899,7 @@ impl App { active_wallpaper_paths_from_assignments(&self.active_wallpaper_assignments); self.refresh_active_wallpapers(); println!("Wallpaper set to: {path_str}"); + self.print_hyprpaper_compatibility_notice(); Ok(()) }