Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
166 changes: 161 additions & 5 deletions src/backend/hyprpaper.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
use std::{
collections::HashSet,
path::PathBuf,
fs,
path::{Path, PathBuf},
process::{Command, Output},
sync::{Mutex, OnceLock},
thread,
time::Duration,
};

use log::{debug, error, info, warn};
use walkdir::WalkDir;

const HYPERPAPER_SERVICE: &str = "hyprpaper.service";
const HYPERPAPER_PROCESS_NAME: &str = "hyprpaper";
Expand Down Expand Up @@ -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<String> {
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<String> {
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<PathBuf> {
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<PathBuf> {
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}");
Expand Down Expand Up @@ -620,18 +702,36 @@ 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,
unsupported_active_query_verification_message, with_hyprpaper_hint,
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() {
Expand Down Expand Up @@ -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!(
Expand Down
14 changes: 13 additions & 1 deletion src/gui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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<String>) {
let message = message.into();
log::info!("gui success: {message}");
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(())
}

Expand All @@ -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(())
}

Expand Down
9 changes: 9 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <install|enable|disable|uninstall|status|interval>";
const RANDOM_USAGE: &str = "walt random [--same|DISPLAY_INDEX]";
Expand Down Expand Up @@ -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 => {
Expand Down
14 changes: 13 additions & 1 deletion src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Self> {
let config = Config::new();
let theme = ThemeKind::from_name(&config.theme_name);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(())
}

Expand All @@ -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(())
}

Expand Down