diff --git a/docs/CONFIG.md b/docs/CONFIG.md index d502bc597..52eabee79 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -913,6 +913,8 @@ icon_style = "auto" `WAYSCRIBER_TRAY_FORCE_PIXMAP=1` takes precedence over this setting and also disables named menu icons for compatibility with tray hosts that render them incorrectly. +With the symbolic icon, the tray also advertises the install prefix's `share/icons` directory as its icon theme path, so hosts can find the icon in a custom install such as `/opt/wayscriber`. It is left out when the host already searches that directory through `XDG_DATA_HOME` or `XDG_DATA_DIRS`, as with `/usr` and `/usr/local` installs. `WAYSCRIBER_ICON_THEME_PATH` replaces the advertised path; set it to an empty value to advertise none. + The tray menu reads the configuration to draw itself and changes none of it. Its **Session persistence settings…** entry launches the configurator on the Session screen, where the change is made and saved. @@ -2684,6 +2686,7 @@ wayscriber --daemon & These override behavior at runtime. Bool-ish values treat anything except `0`, `false`, or `off` as true. - `WAYSCRIBER_NO_TRAY=1` disables the tray icon (default: tray enabled) +- `WAYSCRIBER_ICON_THEME_PATH=/path/to/icons` replaces the icon theme path the tray advertises; an empty value advertises none (default: the install prefix's `share/icons` with the symbolic tray icon, unless the XDG data directories already cover it) - `WAYSCRIBER_RESUME_SESSION=1/0` forces session persistence on/off for the current run (default: unset; follows config) - `WAYSCRIBER_CONFIGURATOR=/path/to/wayscriber-configurator` overrides the configurator executable path - `WAYSCRIBER_DISABLE_UPDATE_CHECK=1` disables the background update check for this run (overrides `[updates] check`; `--check-update` still works) diff --git a/src/daemon/tray/icon_theme_path.rs b/src/daemon/tray/icon_theme_path.rs new file mode 100644 index 000000000..861c56735 --- /dev/null +++ b/src/daemon/tray/icon_theme_path.rs @@ -0,0 +1,104 @@ +//! The `IconThemePath` the tray item advertises beside its icon name. +//! +//! The StatusNotifierItem spec makes this path an addition to the host's icon +//! theme search path. Hosts already search the XDG data directories, so a +//! package installed under one of them gains nothing by advertising its icon +//! directory, and some hosts index the advertised directory recursively: +//! `/usr/share/icons` made them walk every installed icon theme (#412). + +use crate::env_vars::ICON_THEME_PATH_ENV; +use crate::paths; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +/// The `IconThemePath` to advertise. +/// +/// [`ICON_THEME_PATH_ENV`] wins whenever it is set, even to an empty value. +/// Otherwise the install prefix's icon directory is advertised, but only while +/// the tray names an icon for the host to find there, and only when the host +/// does not already search that directory. +pub(super) fn resolve_icon_theme_path(names_tray_icon: bool) -> String { + if let Ok(value) = env::var(ICON_THEME_PATH_ENV) { + return value; + } + if !names_tray_icon { + return String::new(); + } + + let Ok(exe_path) = env::current_exe() else { + return String::new(); + }; + installed_icon_theme_path(&exe_path, &standard_icon_dirs()) + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_default() +} + +/// `icons` under each data directory a spec-following host searches. +fn standard_icon_dirs() -> Vec { + paths::searched_data_dirs() + .into_iter() + .map(|dir| dir.join("icons")) + .collect() +} + +/// `/share/icons` for the prefix `exe_path` is installed under, unless +/// that directory is missing or is one of `standard_icon_dirs`. +fn installed_icon_theme_path(exe_path: &Path, standard_icon_dirs: &[PathBuf]) -> Option { + let install_root = exe_path.parent()?.parent()?; + let icon_theme_path = install_root.join("share/icons"); + if !icon_theme_path.is_dir() { + return None; + } + + // Compare resolved paths so a symlinked data directory still matches. + let resolved = fs::canonicalize(&icon_theme_path).ok()?; + let already_searched = standard_icon_dirs + .iter() + .filter_map(|dir| fs::canonicalize(dir).ok()) + .any(|dir| dir == resolved); + (!already_searched).then_some(icon_theme_path) +} + +#[cfg(test)] +mod tests { + use super::installed_icon_theme_path; + use std::fs; + + #[test] + fn installed_icon_dir_is_withheld_when_hosts_already_search_it() { + let root = crate::test_temp::tempdir().unwrap(); + let share = root.path().join("usr/share"); + fs::create_dir_all(share.join("icons")).unwrap(); + let linked_share = root.path().join("linked-share"); + std::os::unix::fs::symlink(&share, &linked_share).unwrap(); + let exe = root.path().join("usr/bin/wayscriber"); + + assert_eq!( + installed_icon_theme_path(&exe, &[share.join("icons")]), + None + ); + assert_eq!( + installed_icon_theme_path(&exe, &[linked_share.join("icons")]), + None + ); + } + + #[test] + fn installed_icon_dir_is_advertised_for_a_private_prefix() { + let root = crate::test_temp::tempdir().unwrap(); + let private_icons = root.path().join("opt/wayscriber/share/icons"); + let system_icons = root.path().join("usr/share/icons"); + fs::create_dir_all(&private_icons).unwrap(); + fs::create_dir_all(&system_icons).unwrap(); + let exe = root.path().join("opt/wayscriber/bin/wayscriber"); + + assert_eq!( + installed_icon_theme_path( + &exe, + &[system_icons, root.path().join("missing/share/icons")] + ), + Some(private_icons) + ); + } +} diff --git a/src/daemon/tray/ksni.rs b/src/daemon/tray/ksni.rs index a903c8e50..4db994194 100644 --- a/src/daemon/tray/ksni.rs +++ b/src/daemon/tray/ksni.rs @@ -3,6 +3,8 @@ use super::super::types::TrayStatus; #[cfg(feature = "tray")] use super::WayscriberTray; #[cfg(feature = "tray")] +use super::icon_theme_path::resolve_icon_theme_path; +#[cfg(feature = "tray")] use super::shortcut_hint_io::configured_toggle_shortcut_hint; #[cfg(feature = "tray")] use crate::config::{Action, TrayIconStyle, action_label}; @@ -10,8 +12,7 @@ use crate::config::{Action, TrayIconStyle, action_label}; use crate::configurator_destination::{ConfiguratorDestination, ConfiguratorScreen}; #[cfg(feature = "tray")] use crate::env_vars::{ - DESKTOP_SESSION_ENV, ICON_THEME_PATH_ENV, TRAY_FORCE_PIXMAP_ENV, XDG_CURRENT_DESKTOP_ENV, - XDG_SESSION_DESKTOP_ENV, + DESKTOP_SESSION_ENV, TRAY_FORCE_PIXMAP_ENV, XDG_CURRENT_DESKTOP_ENV, XDG_SESSION_DESKTOP_ENV, }; #[cfg(feature = "tray")] use crate::label_format::format_binding_label; @@ -22,8 +23,6 @@ use log::{info, warn}; #[cfg(feature = "tray")] use std::env; #[cfg(feature = "tray")] -use std::path::PathBuf; -#[cfg(feature = "tray")] use std::sync::atomic::Ordering; #[cfg(feature = "tray")] use std::time::{Duration, Instant}; @@ -60,7 +59,7 @@ impl ksni::Tray for WayscriberTray { } fn icon_theme_path(&self) -> String { - resolve_icon_theme_path() + resolve_icon_theme_path(tray_symbolic_icon_enabled(self.icon_style)) } fn tool_tip(&self) -> ksni::ToolTip { @@ -423,16 +422,6 @@ fn menu_icon_name(name: &str, use_theme_icons: bool) -> String { } } -#[cfg(feature = "tray")] -fn resolve_icon_theme_path() -> String { - if let Ok(value) = env::var(ICON_THEME_PATH_ENV) { - return value; - } - installed_icon_theme_path() - .map(|path| path.to_string_lossy().into_owned()) - .unwrap_or_default() -} - #[cfg(feature = "tray")] fn tray_theme_icons_supported(desktop_env: &str, session_env: &str, desktop_session: &str) -> bool { !tray_theme_icons_blocked_by_desktop(desktop_env) @@ -448,14 +437,6 @@ fn tray_theme_icons_blocked_by_desktop(value: &str) -> bool { || value.contains("cosmic") } -#[cfg(feature = "tray")] -fn installed_icon_theme_path() -> Option { - let exe_path = env::current_exe().ok()?; - let install_root = exe_path.parent()?.parent()?; - let icon_theme_path = install_root.join("share/icons"); - icon_theme_path.is_dir().then_some(icon_theme_path) -} - #[cfg(feature = "tray")] fn toggle_overlay_menu_label() -> String { let base = "Toggle Overlay"; diff --git a/src/daemon/tray/mod.rs b/src/daemon/tray/mod.rs index 4b3b8c9ec..ed154b282 100644 --- a/src/daemon/tray/mod.rs +++ b/src/daemon/tray/mod.rs @@ -1,5 +1,7 @@ mod helpers; #[cfg(feature = "tray")] +mod icon_theme_path; +#[cfg(feature = "tray")] mod ksni; mod runtime; #[cfg(feature = "tray")] diff --git a/src/env_vars.rs b/src/env_vars.rs index ae6af1616..5b744bc6f 100644 --- a/src/env_vars.rs +++ b/src/env_vars.rs @@ -55,6 +55,7 @@ pub const XDG_ACTIVATION_TOKEN_ENV: &str = "XDG_ACTIVATION_TOKEN"; pub const XDG_CACHE_HOME_ENV: &str = "XDG_CACHE_HOME"; pub const XDG_CONFIG_HOME_ENV: &str = "XDG_CONFIG_HOME"; pub const XDG_DATA_HOME_ENV: &str = "XDG_DATA_HOME"; +pub const XDG_DATA_DIRS_ENV: &str = "XDG_DATA_DIRS"; pub const XDG_PICTURES_DIR_ENV: &str = "XDG_PICTURES_DIR"; pub const XDG_RUNTIME_DIR_ENV: &str = "XDG_RUNTIME_DIR"; /// Seat this session belongs to; the input HUD's system capture follows it so diff --git a/src/paths/mod.rs b/src/paths/mod.rs index 411655cea..c9c6960fc 100644 --- a/src/paths/mod.rs +++ b/src/paths/mod.rs @@ -1,9 +1,10 @@ use std::env; +use std::ffi::OsString; use std::path::PathBuf; use crate::env_vars::{ - HOME_ENV, USERPROFILE_ENV, XDG_CACHE_HOME_ENV, XDG_CONFIG_HOME_ENV, XDG_DATA_HOME_ENV, - XDG_PICTURES_DIR_ENV, XDG_RUNTIME_DIR_ENV, + HOME_ENV, USERPROFILE_ENV, XDG_CACHE_HOME_ENV, XDG_CONFIG_HOME_ENV, XDG_DATA_DIRS_ENV, + XDG_DATA_HOME_ENV, XDG_PICTURES_DIR_ENV, XDG_RUNTIME_DIR_ENV, }; /// Resolve the user's home directory. @@ -53,6 +54,53 @@ pub fn data_dir() -> Option { home_dir().map(|home| home.join(".local").join("share")) } +/// The data directories a program following the XDG base directory spec +/// searches, most important first: the data home, then the system data +/// directories. +/// +/// Unlike [`data_dir`], this ignores relative values, as the spec requires, so +/// the result does not depend on the current directory. +pub fn searched_data_dirs() -> Vec { + searched_data_dirs_from( + env::var_os(XDG_DATA_HOME_ENV), + home_dir(), + env::var_os(XDG_DATA_DIRS_ENV), + ) +} + +/// Reads `XDG_DATA_HOME` and `XDG_DATA_DIRS` values as the base directory spec +/// does: an unset, empty, or relative data home means `~/.local/share`. +fn searched_data_dirs_from( + data_home: Option, + home: Option, + data_dirs: Option, +) -> Vec { + let data_home = data_home + .map(PathBuf::from) + .filter(|dir| dir.is_absolute()) + .or_else(|| home.map(|home| home.join(".local").join("share"))) + .filter(|dir| dir.is_absolute()); + + data_home + .into_iter() + .chain(system_data_dirs_from(data_dirs)) + .collect() +} + +/// Parses an `XDG_DATA_DIRS` value as the base directory spec reads it: an +/// unset or empty value means the defaults, and relative entries are ignored. +fn system_data_dirs_from(value: Option) -> Vec { + let Some(value) = value.filter(|value| !value.is_empty()) else { + return vec![ + PathBuf::from("/usr/local/share"), + PathBuf::from("/usr/share"), + ]; + }; + env::split_paths(&value) + .filter(|dir| dir.is_absolute()) + .collect() +} + /// Location for generated, persistent runtime UI preferences. This is kept /// separate from the authored configuration and drawing-session stores. pub(crate) fn runtime_ui_state_file() -> PathBuf { diff --git a/src/paths/tests.rs b/src/paths/tests.rs index b790558bb..5411d1b90 100644 --- a/src/paths/tests.rs +++ b/src/paths/tests.rs @@ -217,6 +217,51 @@ fn data_dir_falls_back_to_home_share() { } } +#[test] +fn searched_data_dirs_follow_the_base_directory_spec() { + let home = || Some(PathBuf::from("/home/user")); + let default_home = PathBuf::from("/home/user/.local/share"); + let system_defaults = [ + PathBuf::from("/usr/local/share"), + PathBuf::from("/usr/share"), + ]; + let with_defaults = |data_home: PathBuf| { + let mut dirs = vec![data_home]; + dirs.extend(system_defaults.clone()); + dirs + }; + + assert_eq!( + searched_data_dirs_from(None, home(), None), + with_defaults(default_home.clone()) + ); + assert_eq!( + searched_data_dirs_from(Some(OsString::new()), home(), Some(OsString::new())), + with_defaults(default_home.clone()) + ); + // A relative data home would resolve against the current directory. + assert_eq!( + searched_data_dirs_from(Some("share".into()), home(), None), + with_defaults(default_home) + ); + assert_eq!( + searched_data_dirs_from(Some("/data".into()), home(), None), + with_defaults(PathBuf::from("/data")) + ); + assert_eq!( + searched_data_dirs_from( + Some("/data".into()), + home(), + Some("/opt/share::relative/share:/usr/share/".into()) + ), + vec![ + PathBuf::from("/data"), + PathBuf::from("/opt/share"), + PathBuf::from("/usr/share/") + ] + ); +} + #[test] fn pictures_dir_prefers_xdg_when_set() { let _guard = crate::test_env::lock();