diff --git a/configurator/src/app/daemon_setup/shortcut.rs b/configurator/src/app/daemon_setup/shortcut.rs index f8daf4e..f7f2e9b 100644 --- a/configurator/src/app/daemon_setup/shortcut.rs +++ b/configurator/src/app/daemon_setup/shortcut.rs @@ -1,6 +1,11 @@ use std::fs; use crate::models::{DesktopEnvironment, ShortcutBackend}; +use wayscriber::shortcut_hint::{ + GNOME_MEDIA_KEYS_KEY, GNOME_MEDIA_KEYS_SCHEMA, GNOME_WAYSCRIBER_KEYBINDING_PATH, + gnome_effective_shortcut, gnome_shortcut_schema_with_path, normalize_shortcut_hint, + parse_gsettings_path_list, +}; use super::command::{command_available, run_command, run_command_checked}; use super::service::{ @@ -10,13 +15,6 @@ use super::service::{ const PORTAL_APP_ID: &str = "wayscriber"; const TOGGLE_COMMAND: &str = "pkill -SIGUSR1 wayscriber"; - -const GNOME_MEDIA_KEYS_SCHEMA: &str = "org.gnome.settings-daemon.plugins.media-keys"; -const GNOME_MEDIA_KEYS_KEY: &str = "custom-keybindings"; -const GNOME_CUSTOM_KEYBINDING_SCHEMA: &str = - "org.gnome.settings-daemon.plugins.media-keys.custom-keybinding"; -const GNOME_WAYSCRIBER_KEYBINDING_PATH: &str = - "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/wayscriber-toggle/"; const GNOME_SHORTCUT_NAME: &str = "Wayscriber Toggle"; pub(super) fn read_configured_shortcut(backend: ShortcutBackend) -> Option { @@ -80,8 +78,7 @@ fn apply_gnome_custom_shortcut(binding: &str) -> Result<(), String> { &rendered_bindings, ])?; - let schema_with_path = - format!("{GNOME_CUSTOM_KEYBINDING_SCHEMA}:{GNOME_WAYSCRIBER_KEYBINDING_PATH}"); + let schema_with_path = gnome_shortcut_schema_with_path(); run_gsettings_command(&[ "set", &schema_with_path, @@ -116,13 +113,21 @@ fn read_gnome_shortcut_binding() -> Option { if !command_available("gsettings") { return None; } - let schema_with_path = - format!("{GNOME_CUSTOM_KEYBINDING_SCHEMA}:{GNOME_WAYSCRIBER_KEYBINDING_PATH}"); - let capture = run_command("gsettings", &["get", &schema_with_path, "binding"]).ok()?; - if !capture.success { + let custom_keybindings = run_command( + "gsettings", + &["get", GNOME_MEDIA_KEYS_SCHEMA, GNOME_MEDIA_KEYS_KEY], + ) + .ok()?; + if !custom_keybindings.success { + return None; + } + + let schema_with_path = gnome_shortcut_schema_with_path(); + let binding = run_command("gsettings", &["get", &schema_with_path, "binding"]).ok()?; + if !binding.success { return None; } - parse_gsettings_string_value(capture.stdout.trim()) + resolve_gnome_shortcut_from_gsettings(&custom_keybindings.stdout, &binding.stdout) } fn require_gsettings_available() -> Result<(), String> { @@ -182,38 +187,11 @@ fn parse_portal_shortcut_from_dropin(content: &str) -> Option { return None; } let inner = &trimmed[prefix.len()..trimmed.len() - 1]; - if inner.is_empty() { - return None; - } - Some(inner.replace("\\\"", "\"").replace("\\\\", "\\")) + let unescaped = inner.replace("\\\"", "\"").replace("\\\\", "\\"); + normalize_shortcut_hint(Some(&unescaped)) }) } -fn parse_gsettings_path_list(raw: &str) -> Result, String> { - let trimmed = raw.trim(); - let list_literal = trimmed.strip_prefix("@as ").map_or(trimmed, str::trim); - if !list_literal.starts_with('[') || !list_literal.ends_with(']') { - return Err(format!( - "Unexpected gsettings list format: `{}`", - raw.trim() - )); - } - let inner = list_literal[1..list_literal.len() - 1].trim(); - if inner.is_empty() { - return Ok(Vec::new()); - } - - let mut values = Vec::new(); - for chunk in inner.split(',') { - let value = chunk.trim().trim_matches('\'').trim_matches('"').trim(); - if value.is_empty() { - continue; - } - values.push(value.to_string()); - } - Ok(values) -} - fn serialize_gsettings_path_list(paths: &[String]) -> String { let mut rendered = String::from("["); for (index, path) in paths.iter().enumerate() { @@ -233,19 +211,11 @@ fn gvariant_string_literal(value: &str) -> String { format!("'{escaped}'") } -fn parse_gsettings_string_value(raw: &str) -> Option { - let trimmed = raw.trim(); - if trimmed.is_empty() || trimmed == "''" { - return None; - } - let unquoted = trimmed - .strip_prefix('\'') - .and_then(|value| value.strip_suffix('\'')) - .unwrap_or(trimmed); - if unquoted.is_empty() { - return None; - } - Some(unquoted.replace("\\'", "'").replace("\\\\", "\\")) +fn resolve_gnome_shortcut_from_gsettings( + custom_keybindings_output: &str, + binding_output: &str, +) -> Option { + gnome_effective_shortcut(custom_keybindings_output, binding_output) } fn normalize_shortcut_for_gnome(input: &str) -> Result { @@ -367,6 +337,44 @@ mod tests { ); } + #[test] + fn parse_portal_shortcut_ignores_blank_value() { + let content = "[Service]\nEnvironment=\"WAYSCRIBER_PORTAL_SHORTCUT= \"\n"; + assert_eq!(parse_portal_shortcut_from_dropin(content), None); + } + + #[test] + fn resolve_gnome_shortcut_requires_registered_path() { + let custom_keybindings = + "['/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/not-wayscriber/']"; + assert_eq!( + resolve_gnome_shortcut_from_gsettings(custom_keybindings, "'g'"), + None + ); + } + + #[test] + fn resolve_gnome_shortcut_rejects_disabled_binding() { + let custom_keybindings = "['/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/wayscriber-toggle/']"; + assert_eq!( + resolve_gnome_shortcut_from_gsettings(custom_keybindings, "'disabled'"), + None + ); + assert_eq!( + resolve_gnome_shortcut_from_gsettings(custom_keybindings, "''"), + None + ); + } + + #[test] + fn resolve_gnome_shortcut_accepts_registered_binding() { + let custom_keybindings = "['/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/wayscriber-toggle/']"; + assert_eq!( + resolve_gnome_shortcut_from_gsettings(custom_keybindings, "'g'"), + Some("g".to_string()) + ); + } + #[test] fn normalize_shortcut_supports_human_readable_input() { assert_eq!( diff --git a/configurator/src/models/daemon.rs b/configurator/src/models/daemon.rs index 2a7bdd4..9625d16 100644 --- a/configurator/src/models/daemon.rs +++ b/configurator/src/models/daemon.rs @@ -1,3 +1,5 @@ +use wayscriber::shortcut_hint::is_gnome_desktop; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DesktopEnvironment { Gnome, @@ -13,10 +15,10 @@ impl DesktopEnvironment { } pub fn from_desktop_strings(current: &str, session: &str) -> Self { - let combined = format!("{current};{session}").to_lowercase(); - if combined.contains("gnome") { + if is_gnome_desktop(current, session) { return Self::Gnome; } + let combined = format!("{current};{session}").to_lowercase(); if combined.contains("kde") || combined.contains("plasma") { return Self::Kde; } diff --git a/src/daemon/tray/ksni.rs b/src/daemon/tray/ksni.rs index 61cfc52..b68e0db 100644 --- a/src/daemon/tray/ksni.rs +++ b/src/daemon/tray/ksni.rs @@ -5,6 +5,8 @@ use super::WayscriberTray; #[cfg(feature = "tray")] use super::runtime::update_session_resume_in_config; #[cfg(feature = "tray")] +use super::shortcut_hint_io::configured_toggle_shortcut_hint; +#[cfg(feature = "tray")] use crate::config::{Action, action_label}; #[cfg(feature = "tray")] use crate::label_format::format_binding_label; @@ -15,8 +17,6 @@ use log::{info, warn}; #[cfg(feature = "tray")] use std::env; #[cfg(feature = "tray")] -use std::process::Command; -#[cfg(feature = "tray")] use std::sync::atomic::Ordering; #[cfg(feature = "tray")] use std::time::{Duration, Instant}; @@ -316,42 +316,3 @@ fn toggle_overlay_menu_label() -> String { None => base.to_string(), } } - -#[cfg(feature = "tray")] -fn configured_toggle_shortcut_hint() -> Option { - if let Ok(shortcut) = env::var("WAYSCRIBER_PORTAL_SHORTCUT") - && !shortcut.trim().is_empty() - { - return Some(shortcut.trim().to_string()); - } - read_gnome_toggle_shortcut_binding() -} - -#[cfg(feature = "tray")] -fn read_gnome_toggle_shortcut_binding() -> Option { - let schema = "org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/wayscriber-toggle/"; - let output = Command::new("gsettings") - .args(["get", schema, "binding"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - parse_gsettings_string_value(&String::from_utf8_lossy(&output.stdout)) -} - -#[cfg(feature = "tray")] -fn parse_gsettings_string_value(raw: &str) -> Option { - let trimmed = raw.trim(); - if trimmed.is_empty() || trimmed == "''" { - return None; - } - let unquoted = trimmed - .strip_prefix('\'') - .and_then(|value| value.strip_suffix('\'')) - .unwrap_or(trimmed); - if unquoted.is_empty() { - return None; - } - Some(unquoted.replace("\\'", "'").replace("\\\\", "\\")) -} diff --git a/src/daemon/tray/mod.rs b/src/daemon/tray/mod.rs index 880d869..cb6aca4 100644 --- a/src/daemon/tray/mod.rs +++ b/src/daemon/tray/mod.rs @@ -2,6 +2,8 @@ mod helpers; #[cfg(feature = "tray")] mod ksni; mod runtime; +#[cfg(feature = "tray")] +mod shortcut_hint_io; pub(crate) use runtime::start_system_tray; diff --git a/src/daemon/tray/shortcut_hint_io.rs b/src/daemon/tray/shortcut_hint_io.rs new file mode 100644 index 0000000..217f8a5 --- /dev/null +++ b/src/daemon/tray/shortcut_hint_io.rs @@ -0,0 +1,60 @@ +#[cfg(feature = "tray")] +use std::env; +#[cfg(feature = "tray")] +use std::process::Command; +#[cfg(feature = "tray")] +use wayscriber::shortcut_hint::{ + GNOME_MEDIA_KEYS_KEY, GNOME_MEDIA_KEYS_SCHEMA, gnome_shortcut_schema_with_path, + is_gnome_desktop, normalize_shortcut_hint, resolve_toggle_shortcut_hint, +}; + +#[cfg(feature = "tray")] +pub(super) fn configured_toggle_shortcut_hint() -> Option { + let portal_shortcut_env = env::var("WAYSCRIBER_PORTAL_SHORTCUT").ok(); + if let Some(shortcut) = normalize_shortcut_hint(portal_shortcut_env.as_deref()) { + return Some(shortcut); + } + let gnome_desktop = current_desktop_is_gnome(); + let (custom_keybindings_raw, binding_raw) = if gnome_desktop { + match read_gnome_shortcut_outputs() { + Some((custom_keybindings, binding)) => (Some(custom_keybindings), Some(binding)), + None => (None, None), + } + } else { + (None, None) + }; + resolve_toggle_shortcut_hint( + portal_shortcut_env.as_deref(), + gnome_desktop, + custom_keybindings_raw.as_deref(), + binding_raw.as_deref(), + ) +} + +#[cfg(feature = "tray")] +fn current_desktop_is_gnome() -> bool { + let current = env::var("XDG_CURRENT_DESKTOP").unwrap_or_default(); + let session = env::var("XDG_SESSION_DESKTOP").unwrap_or_default(); + is_gnome_desktop(¤t, &session) +} + +#[cfg(feature = "tray")] +fn read_gnome_shortcut_outputs() -> Option<(String, String)> { + let custom_keybindings_raw = + read_gsettings_value(GNOME_MEDIA_KEYS_SCHEMA, GNOME_MEDIA_KEYS_KEY)?; + let schema_with_path = gnome_shortcut_schema_with_path(); + let binding_raw = read_gsettings_value(&schema_with_path, "binding")?; + Some((custom_keybindings_raw, binding_raw)) +} + +#[cfg(feature = "tray")] +fn read_gsettings_value(schema: &str, key: &str) -> Option { + let output = Command::new("gsettings") + .args(["get", schema, key]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).to_string()) +} diff --git a/src/lib.rs b/src/lib.rs index d68c53a..a56cdd0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ pub mod input; mod label_format; pub mod paths; pub mod session; +pub mod shortcut_hint; pub mod systemd_user_service; pub mod time_utils; pub mod toolbar_icons; diff --git a/src/shortcut_hint.rs b/src/shortcut_hint.rs new file mode 100644 index 0000000..329dc34 --- /dev/null +++ b/src/shortcut_hint.rs @@ -0,0 +1,214 @@ +pub const GNOME_MEDIA_KEYS_SCHEMA: &str = "org.gnome.settings-daemon.plugins.media-keys"; +pub const GNOME_MEDIA_KEYS_KEY: &str = "custom-keybindings"; +pub const GNOME_CUSTOM_KEYBINDING_SCHEMA: &str = + "org.gnome.settings-daemon.plugins.media-keys.custom-keybinding"; +pub const GNOME_WAYSCRIBER_KEYBINDING_PATH: &str = + "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/wayscriber-toggle/"; + +pub fn gnome_shortcut_schema_with_path() -> String { + format!("{GNOME_CUSTOM_KEYBINDING_SCHEMA}:{GNOME_WAYSCRIBER_KEYBINDING_PATH}") +} + +pub fn is_gnome_desktop(current: &str, session: &str) -> bool { + let combined = format!("{current};{session}").to_lowercase(); + combined.contains("gnome") +} + +pub fn normalize_shortcut_hint(value: Option<&str>) -> Option { + let trimmed = value?.trim(); + if trimmed.is_empty() { + return None; + } + Some(trimmed.to_string()) +} + +pub fn normalize_binding_hint(value: Option<&str>) -> Option { + let trimmed = value?.trim(); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("disabled") { + return None; + } + Some(trimmed.to_string()) +} + +pub fn parse_gsettings_string_value(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed == "''" { + return None; + } + let unquoted = trimmed + .strip_prefix('\'') + .and_then(|value| value.strip_suffix('\'')) + .unwrap_or(trimmed); + if unquoted.is_empty() { + return None; + } + Some(unquoted.replace("\\'", "'").replace("\\\\", "\\")) +} + +pub fn parse_gsettings_path_list(raw: &str) -> Result, String> { + let trimmed = raw.trim(); + let list_literal = trimmed.strip_prefix("@as ").map_or(trimmed, str::trim); + if !list_literal.starts_with('[') || !list_literal.ends_with(']') { + return Err(format!( + "Unexpected gsettings list format: `{}`", + raw.trim() + )); + } + let inner = list_literal[1..list_literal.len() - 1].trim(); + if inner.is_empty() { + return Ok(Vec::new()); + } + + let mut values = Vec::new(); + for chunk in inner.split(',') { + let value = chunk.trim().trim_matches('\'').trim_matches('"').trim(); + if value.is_empty() { + continue; + } + values.push(value.to_string()); + } + Ok(values) +} + +pub fn gnome_effective_shortcut(custom_keybindings_raw: &str, binding_raw: &str) -> Option { + let configured_paths = parse_gsettings_path_list(custom_keybindings_raw).ok()?; + if !configured_paths + .iter() + .any(|path| path == GNOME_WAYSCRIBER_KEYBINDING_PATH) + { + return None; + } + let binding = parse_gsettings_string_value(binding_raw)?; + normalize_binding_hint(Some(binding.as_str())) +} + +pub fn resolve_toggle_shortcut_hint( + portal_shortcut_env: Option<&str>, + gnome_desktop: bool, + gnome_custom_keybindings_raw: Option<&str>, + gnome_binding_raw: Option<&str>, +) -> Option { + if let Some(portal_shortcut) = normalize_shortcut_hint(portal_shortcut_env) { + return Some(portal_shortcut); + } + if !gnome_desktop { + return None; + } + let custom_keybindings = gnome_custom_keybindings_raw?; + let binding = gnome_binding_raw?; + gnome_effective_shortcut(custom_keybindings, binding) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_shortcut_hint_trims_and_rejects_empty() { + assert_eq!( + normalize_shortcut_hint(Some(" g ")), + Some("g".to_string()) + ); + assert_eq!(normalize_shortcut_hint(Some(" ")), None); + assert_eq!(normalize_shortcut_hint(None), None); + } + + #[test] + fn normalize_binding_hint_rejects_disabled_case_insensitive() { + assert_eq!(normalize_binding_hint(Some("disabled")), None); + assert_eq!(normalize_binding_hint(Some("DiSaBlEd")), None); + assert_eq!( + normalize_binding_hint(Some(" g ")), + Some("g".to_string()) + ); + } + + #[test] + fn parse_gsettings_path_list_handles_variants() { + assert_eq!( + parse_gsettings_path_list("@as []").unwrap(), + Vec::::new() + ); + assert_eq!( + parse_gsettings_path_list("[]").unwrap(), + Vec::::new() + ); + assert_eq!( + parse_gsettings_path_list("['/org/one/', '/org/two/']").unwrap(), + vec!["/org/one/".to_string(), "/org/two/".to_string()] + ); + } + + #[test] + fn gnome_effective_shortcut_requires_registered_path() { + assert_eq!( + gnome_effective_shortcut( + "['/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/other/']", + "'g'", + ), + None + ); + } + + #[test] + fn gnome_effective_shortcut_rejects_disabled_or_empty_binding() { + let paths = "['/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/wayscriber-toggle/']"; + assert_eq!(gnome_effective_shortcut(paths, "'disabled'"), None); + assert_eq!(gnome_effective_shortcut(paths, "' DISABLED '"), None); + assert_eq!(gnome_effective_shortcut(paths, "''"), None); + } + + #[test] + fn gnome_effective_shortcut_accepts_valid_binding() { + let paths = "['/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/wayscriber-toggle/']"; + assert_eq!( + gnome_effective_shortcut(paths, "'g'"), + Some("g".to_string()) + ); + } + + #[test] + fn resolve_toggle_shortcut_hint_prefers_portal_env() { + assert_eq!( + resolve_toggle_shortcut_hint( + Some(" Super+G "), + true, + Some( + "['/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/wayscriber-toggle/']" + ), + Some("'x'"), + ), + Some("Super+G".to_string()) + ); + } + + #[test] + fn resolve_toggle_shortcut_hint_rejects_non_gnome_fallback() { + assert_eq!( + resolve_toggle_shortcut_hint( + None, + false, + Some( + "['/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/wayscriber-toggle/']" + ), + Some("'g'"), + ), + None + ); + } + + #[test] + fn resolve_toggle_shortcut_hint_rejects_blank_portal_env() { + assert_eq!( + resolve_toggle_shortcut_hint( + Some(" "), + false, + Some( + "['/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/wayscriber-toggle/']" + ), + Some("'g'"), + ), + None + ); + } +}