From 2cc9349602587de99689832779e8cbb2f282328b Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 4 Apr 2026 19:09:28 +0200 Subject: [PATCH] Fix GNOME portal shortcut shutdown handling --- configurator/Cargo.toml | 3 +- configurator/src/app/daemon_setup/mod.rs | 30 ++- configurator/src/app/daemon_setup/service.rs | 27 +++ configurator/src/app/daemon_setup/shortcut.rs | 66 ++++-- configurator/src/app/update/daemon.rs | 8 +- configurator/src/app/view/daemon.rs | 29 ++- configurator/src/models/daemon.rs | 120 +++++++++-- configurator/src/models/mod.rs | 3 +- docs/SETUP.md | 3 +- src/daemon/core.rs | 27 ++- src/daemon/global_shortcuts.rs | 18 +- src/daemon/tray/shortcut_hint_io.rs | 41 ++-- src/shortcut_hint.rs | 191 ++++++++++++++++++ 13 files changed, 485 insertions(+), 81 deletions(-) diff --git a/configurator/Cargo.toml b/configurator/Cargo.toml index e93a3f13..36d2b8fa 100644 --- a/configurator/Cargo.toml +++ b/configurator/Cargo.toml @@ -4,7 +4,8 @@ version = "0.9.13" edition = "2024" [dependencies] -wayscriber = { path = "..", default-features = false } +# Keep configurator-side runtime/status logic aligned with the daemon's portal-capable builds. +wayscriber = { path = "..", default-features = false, features = ["portal"] } iced = { version = "0.12", features = ["tokio", "canvas"] } [features] diff --git a/configurator/src/app/daemon_setup/mod.rs b/configurator/src/app/daemon_setup/mod.rs index 74ae9f99..c914b191 100644 --- a/configurator/src/app/daemon_setup/mod.rs +++ b/configurator/src/app/daemon_setup/mod.rs @@ -9,9 +9,10 @@ use crate::models::{ use command::command_available; use service::{ SERVICE_NAME, detect_service_unit_path, install_or_update_user_service, query_service_active, - query_service_enabled, require_systemctl_available, run_systemctl_user, + query_service_enabled, remove_portal_shortcut_dropin_if_gnome, require_systemctl_available, + run_systemctl_user, }; -use shortcut::{apply_shortcut, read_configured_shortcut}; +use shortcut::{apply_shortcut, read_configured_shortcut, read_portal_shortcut_dropin_state}; pub(super) async fn load_daemon_runtime_status() -> Result { load_daemon_runtime_status_sync() @@ -33,10 +34,23 @@ fn perform_daemon_action_sync( match action { DaemonAction::RefreshStatus => Ok("Daemon status refreshed.".to_string()), DaemonAction::InstallOrUpdateService => { + let desktop = DesktopEnvironment::detect_current(); let service_path = install_or_update_user_service()?; + let removed_dropin = remove_portal_shortcut_dropin_if_gnome(desktop)?; + if command_available("systemctl") && removed_dropin { + run_systemctl_user(&["daemon-reload"])?; + if query_service_active() { + run_systemctl_user(&["restart", SERVICE_NAME])?; + } + } Ok(format!( - "Installed/updated user service at {}", - service_path.display() + "Installed/updated user service at {}{}", + service_path.display(), + if removed_dropin { + "; removed stale GNOME portal shortcut drop-in" + } else { + "" + } )) } DaemonAction::EnableAndStartService => { @@ -64,7 +78,12 @@ fn load_daemon_runtime_status_sync() -> Result { let systemctl_available = command_available("systemctl"); let gsettings_available = command_available("gsettings"); let shortcut_backend = - ShortcutBackend::from_environment(desktop, gsettings_available, systemctl_available); + ShortcutBackend::from_runtime_inputs(desktop, read_portal_shortcut_dropin_state()); + let shortcut_apply_capability = crate::models::ShortcutApplyCapability::from_environment( + desktop, + gsettings_available, + systemctl_available, + ); let service_unit_path = detect_service_unit_path(systemctl_available); let service_installed = service_unit_path.is_some(); let service_enabled = if systemctl_available { @@ -82,6 +101,7 @@ fn load_daemon_runtime_status_sync() -> Result { Ok(DaemonRuntimeStatus { desktop, shortcut_backend, + shortcut_apply_capability, systemctl_available, gsettings_available, service_installed, diff --git a/configurator/src/app/daemon_setup/service.rs b/configurator/src/app/daemon_setup/service.rs index d3d92462..b47739c7 100644 --- a/configurator/src/app/daemon_setup/service.rs +++ b/configurator/src/app/daemon_setup/service.rs @@ -2,6 +2,7 @@ use std::env; use std::fs; use std::path::{Path, PathBuf}; +use crate::models::DesktopEnvironment; use wayscriber::systemd_user_service::{ USER_SERVICE_NAME, escape_systemd_env_value as shared_escape_systemd_env_value, portal_shortcut_dropin_path as shared_portal_shortcut_dropin_path, render_user_service_unit, @@ -121,6 +122,32 @@ pub(super) fn install_or_update_user_service() -> Result { Ok(service_path) } +pub(super) fn remove_portal_shortcut_dropin_if_gnome( + desktop: DesktopEnvironment, +) -> Result { + if desktop != DesktopEnvironment::Gnome { + return Ok(false); + } + remove_portal_shortcut_dropin() +} + +pub(super) fn remove_portal_shortcut_dropin() -> Result { + let Some(path) = portal_shortcut_dropin_path() else { + return Ok(false); + }; + if !path.exists() { + return Ok(false); + } + fs::remove_file(&path).map_err(|err| { + format!( + "Failed to remove portal shortcut drop-in {}: {}", + path.display(), + err + ) + })?; + Ok(true) +} + fn package_service_paths() -> Vec { vec![ PathBuf::from("/usr/lib/systemd/user").join(SERVICE_NAME), diff --git a/configurator/src/app/daemon_setup/shortcut.rs b/configurator/src/app/daemon_setup/shortcut.rs index f7f2e9b9..d0ab4275 100644 --- a/configurator/src/app/daemon_setup/shortcut.rs +++ b/configurator/src/app/daemon_setup/shortcut.rs @@ -1,16 +1,18 @@ use std::fs; -use crate::models::{DesktopEnvironment, ShortcutBackend}; +use crate::models::{DesktopEnvironment, ShortcutApplyCapability, 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, + PORTAL_APP_ID_ENV, PORTAL_SHORTCUT_ENV, PORTAL_SHORTCUT_OPT_IN_ENV, PortalShortcutDropInState, + gnome_effective_shortcut, gnome_shortcut_schema_with_path, parse_gsettings_path_list, + parse_portal_shortcut_dropin_state, + parse_portal_shortcut_from_dropin as shared_parse_portal_shortcut_from_dropin, }; use super::command::{command_available, run_command, run_command_checked}; use super::service::{ escape_systemd_env_value, portal_shortcut_dropin_path, query_service_active, - require_systemctl_available, run_systemctl_user, + remove_portal_shortcut_dropin_if_gnome, require_systemctl_available, run_systemctl_user, }; const PORTAL_APP_ID: &str = "wayscriber"; @@ -25,21 +27,38 @@ pub(super) fn read_configured_shortcut(backend: ShortcutBackend) -> Option PortalShortcutDropInState { + let Some(path) = portal_shortcut_dropin_path() else { + return PortalShortcutDropInState::default(); + }; + let Ok(content) = fs::read_to_string(path) else { + return PortalShortcutDropInState::default(); + }; + parse_portal_shortcut_dropin_state(&content) +} + pub(super) fn apply_shortcut(shortcut_input: &str) -> Result { let desktop = DesktopEnvironment::detect_current(); - let backend = ShortcutBackend::from_environment( + let apply_capability = ShortcutApplyCapability::from_environment( desktop, command_available("gsettings"), command_available("systemctl"), ); - match backend { - ShortcutBackend::GnomeCustomShortcut => { + match apply_capability { + ShortcutApplyCapability::GnomeCustomShortcut => { let normalized = normalize_shortcut_for_gnome(shortcut_input)?; apply_gnome_custom_shortcut(&normalized)?; + let removed_dropin = remove_portal_shortcut_dropin_if_gnome(desktop)?; + if command_available("systemctl") { + run_systemctl_user(&["daemon-reload"])?; + if removed_dropin && query_service_active() { + run_systemctl_user(&["restart", "wayscriber.service"])?; + } + } Ok(format!("Configured GNOME shortcut: {normalized}")) } - ShortcutBackend::PortalServiceDropIn => { + ShortcutApplyCapability::PortalServiceDropIn => { require_systemctl_available()?; let normalized = normalize_shortcut_for_portal(shortcut_input)?; let dropin_path = write_portal_shortcut_dropin(&normalized)?; @@ -52,7 +71,7 @@ pub(super) fn apply_shortcut(shortcut_input: &str) -> Result { dropin_path.display() )) } - ShortcutBackend::Manual => Err( + ShortcutApplyCapability::Manual => Err( "Automatic shortcut setup is not available in this desktop session; bind `pkill -SIGUSR1 wayscriber` manually." .to_string(), ), @@ -160,9 +179,7 @@ fn write_portal_shortcut_dropin(shortcut: &str) -> Result Result String { + format!( + "[Service]\nEnvironment=\"{PORTAL_SHORTCUT_OPT_IN_ENV}=1\"\nEnvironment=\"{PORTAL_SHORTCUT_ENV}={escaped_shortcut}\"\nEnvironment=\"{PORTAL_APP_ID_ENV}={escaped_app_id}\"\n" + ) +} + fn read_portal_shortcut_from_dropin() -> Option { let path = portal_shortcut_dropin_path()?; let content = fs::read_to_string(path).ok()?; @@ -180,16 +203,7 @@ fn read_portal_shortcut_from_dropin() -> Option { } fn parse_portal_shortcut_from_dropin(content: &str) -> Option { - content.lines().find_map(|line| { - let trimmed = line.trim(); - let prefix = "Environment=\"WAYSCRIBER_PORTAL_SHORTCUT="; - if !trimmed.starts_with(prefix) || !trimmed.ends_with('"') { - return None; - } - let inner = &trimmed[prefix.len()..trimmed.len() - 1]; - let unescaped = inner.replace("\\\"", "\"").replace("\\\\", "\\"); - normalize_shortcut_hint(Some(&unescaped)) - }) + shared_parse_portal_shortcut_from_dropin(content) } fn serialize_gsettings_path_list(paths: &[String]) -> String { @@ -343,6 +357,14 @@ mod tests { assert_eq!(parse_portal_shortcut_from_dropin(content), None); } + #[test] + fn render_portal_shortcut_dropin_includes_explicit_opt_in_marker() { + let rendered = render_portal_shortcut_dropin("g", PORTAL_APP_ID); + assert!(rendered.contains("Environment=\"WAYSCRIBER_ENABLE_PORTAL_SHORTCUTS=1\"")); + assert!(rendered.contains("Environment=\"WAYSCRIBER_PORTAL_SHORTCUT=g\"")); + assert!(rendered.contains("Environment=\"WAYSCRIBER_PORTAL_APP_ID=wayscriber\"")); + } + #[test] fn resolve_gnome_shortcut_requires_registered_path() { let custom_keybindings = diff --git a/configurator/src/app/update/daemon.rs b/configurator/src/app/update/daemon.rs index a54a89e0..7d16303c 100644 --- a/configurator/src/app/update/daemon.rs +++ b/configurator/src/app/update/daemon.rs @@ -159,7 +159,7 @@ fn action_pending_message(action: DaemonAction) -> String { #[cfg(test)] mod tests { use super::*; - use crate::models::{DesktopEnvironment, ShortcutBackend}; + use crate::models::{DesktopEnvironment, ShortcutApplyCapability, ShortcutBackend}; #[test] fn daemon_status_loaded_sets_default_shortcut_when_missing() { @@ -168,6 +168,7 @@ mod tests { let status = DaemonRuntimeStatus { desktop: DesktopEnvironment::Kde, shortcut_backend: ShortcutBackend::PortalServiceDropIn, + shortcut_apply_capability: ShortcutApplyCapability::PortalServiceDropIn, systemctl_available: true, gsettings_available: false, service_installed: false, @@ -208,6 +209,7 @@ mod tests { let status = DaemonRuntimeStatus { desktop: DesktopEnvironment::Kde, shortcut_backend: ShortcutBackend::PortalServiceDropIn, + shortcut_apply_capability: ShortcutApplyCapability::PortalServiceDropIn, systemctl_available: true, gsettings_available: false, service_installed: false, @@ -235,6 +237,7 @@ mod tests { let status = DaemonRuntimeStatus { desktop: DesktopEnvironment::Kde, shortcut_backend: ShortcutBackend::PortalServiceDropIn, + shortcut_apply_capability: ShortcutApplyCapability::PortalServiceDropIn, systemctl_available: true, gsettings_available: false, service_installed: false, @@ -264,6 +267,7 @@ mod tests { let stale_status = DaemonRuntimeStatus { desktop: DesktopEnvironment::Kde, shortcut_backend: ShortcutBackend::PortalServiceDropIn, + shortcut_apply_capability: ShortcutApplyCapability::PortalServiceDropIn, systemctl_available: true, gsettings_available: false, service_installed: false, @@ -305,6 +309,7 @@ mod tests { let old_status = DaemonRuntimeStatus { desktop: DesktopEnvironment::Kde, shortcut_backend: ShortcutBackend::PortalServiceDropIn, + shortcut_apply_capability: ShortcutApplyCapability::PortalServiceDropIn, systemctl_available: true, gsettings_available: false, service_installed: false, @@ -316,6 +321,7 @@ mod tests { let new_status = DaemonRuntimeStatus { desktop: DesktopEnvironment::Kde, shortcut_backend: ShortcutBackend::PortalServiceDropIn, + shortcut_apply_capability: ShortcutApplyCapability::PortalServiceDropIn, systemctl_available: true, gsettings_available: false, service_installed: true, diff --git a/configurator/src/app/view/daemon.rs b/configurator/src/app/view/daemon.rs index 3a9d642f..8badb8ba 100644 --- a/configurator/src/app/view/daemon.rs +++ b/configurator/src/app/view/daemon.rs @@ -3,7 +3,7 @@ use iced::theme; use iced::widget::{button, column, horizontal_rule, row, scrollable, text, text_input}; use crate::messages::Message; -use crate::models::{DaemonAction, ShortcutBackend}; +use crate::models::{DaemonAction, ShortcutApplyCapability}; use super::super::state::ConfiguratorApp; @@ -157,14 +157,20 @@ impl ConfiguratorApp { .into(); } - let placeholder = match self.daemon_status.as_ref().map(|s| s.shortcut_backend) { - Some(ShortcutBackend::GnomeCustomShortcut) => "e.g. Super+G or g", - Some(ShortcutBackend::PortalServiceDropIn) => "e.g. Ctrl+Shift+G or g", + let apply_capability = self + .daemon_status + .as_ref() + .map(|s| s.shortcut_apply_capability); + let placeholder = match apply_capability { + Some(ShortcutApplyCapability::GnomeCustomShortcut) => "e.g. Super+G or g", + Some(ShortcutApplyCapability::PortalServiceDropIn) => { + "e.g. Ctrl+Shift+G or g" + } _ => "e.g. Ctrl+Shift+G", }; let mut shortcut_button = button("Apply Shortcut").style(theme::Button::Primary); - if !busy { + if !busy && apply_capability != Some(ShortcutApplyCapability::Manual) { shortcut_button = shortcut_button .on_press(Message::DaemonActionRequested(DaemonAction::ApplyShortcut)); } @@ -192,6 +198,14 @@ impl ConfiguratorApp { ); } + if apply_capability == Some(ShortcutApplyCapability::Manual) { + step = step.push( + text("Automatic shortcut setup is unavailable here. Add a manual keybind for `pkill -SIGUSR1 wayscriber`.") + .size(12) + .style(theme::Text::Color(iced::Color::from_rgb(0.95, 0.8, 0.3))), + ); + } + step = step.push( text_input(placeholder, &self.daemon_shortcut_input) .on_input(Message::DaemonShortcutInputChanged) @@ -293,6 +307,11 @@ impl ConfiguratorApp { .size(12) .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), ); + details = details.push( + text(status.shortcut_apply_capability.friendly_label()) + .size(12) + .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), + ); if let Some(path) = status.service_unit_path.as_deref() { details = details.push( diff --git a/configurator/src/models/daemon.rs b/configurator/src/models/daemon.rs index 9625d16c..ee11968a 100644 --- a/configurator/src/models/daemon.rs +++ b/configurator/src/models/daemon.rs @@ -1,4 +1,7 @@ -use wayscriber::shortcut_hint::is_gnome_desktop; +use wayscriber::shortcut_hint::{ + PortalShortcutDropInState, ShortcutRuntimeBackend, ShortcutRuntimeInputs, is_gnome_desktop, + portal_runtime_supported, resolve_shortcut_runtime_backend, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DesktopEnvironment { @@ -49,15 +52,50 @@ pub enum ShortcutBackend { } impl ShortcutBackend { + pub fn from_runtime_inputs( + desktop: DesktopEnvironment, + portal_dropin_state: PortalShortcutDropInState, + ) -> Self { + match resolve_shortcut_runtime_backend(ShortcutRuntimeInputs { + gnome_desktop: desktop == DesktopEnvironment::Gnome, + portal_runtime_supported: portal_runtime_supported(), + portal_dropin_state, + }) { + ShortcutRuntimeBackend::GnomeCustomShortcut => Self::GnomeCustomShortcut, + ShortcutRuntimeBackend::PortalGlobalShortcuts => Self::PortalServiceDropIn, + ShortcutRuntimeBackend::Manual => Self::Manual, + } + } + + pub fn friendly_label(self) -> &'static str { + match self { + Self::GnomeCustomShortcut => "Active shortcut backend: GNOME custom shortcut", + Self::PortalServiceDropIn => "Active shortcut backend: desktop portal", + Self::Manual => "Active shortcut backend: manual/none", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShortcutApplyCapability { + GnomeCustomShortcut, + PortalServiceDropIn, + Manual, +} + +impl ShortcutApplyCapability { pub fn from_environment( desktop: DesktopEnvironment, gsettings_available: bool, systemctl_available: bool, ) -> Self { - if desktop == DesktopEnvironment::Gnome && gsettings_available { - return Self::GnomeCustomShortcut; + if desktop == DesktopEnvironment::Gnome { + if gsettings_available { + return Self::GnomeCustomShortcut; + } + return Self::Manual; } - if systemctl_available { + if systemctl_available && portal_runtime_supported() { return Self::PortalServiceDropIn; } Self::Manual @@ -65,11 +103,9 @@ impl ShortcutBackend { pub fn friendly_label(self) -> &'static str { match self { - Self::GnomeCustomShortcut => "Shortcut will be configured via GNOME Settings", - Self::PortalServiceDropIn => "Shortcut will be configured via your desktop portal", - Self::Manual => { - "Automatic shortcut setup is not available — you'll need to add a keybind manually" - } + Self::GnomeCustomShortcut => "Shortcut setup available via GNOME Settings", + Self::PortalServiceDropIn => "Shortcut setup available via desktop portal drop-in", + Self::Manual => "Automatic shortcut setup unavailable in this session", } } } @@ -88,6 +124,7 @@ pub enum DaemonAction { pub struct DaemonRuntimeStatus { pub desktop: DesktopEnvironment, pub shortcut_backend: ShortcutBackend, + pub shortcut_apply_capability: ShortcutApplyCapability, pub systemctl_available: bool, pub gsettings_available: bool, pub service_installed: bool, @@ -140,18 +177,71 @@ mod tests { } #[test] - fn shortcut_backend_selection_prefers_gnome_when_available() { + fn shortcut_backend_selection_prefers_gnome_without_explicit_opt_in() { assert_eq!( - ShortcutBackend::from_environment(DesktopEnvironment::Gnome, true, true), + ShortcutBackend::from_runtime_inputs( + DesktopEnvironment::Gnome, + PortalShortcutDropInState { + portal_shortcut_present: true, + portal_app_id_present: true, + explicit_portal_opt_in_present: false, + } + ), ShortcutBackend::GnomeCustomShortcut ); assert_eq!( - ShortcutBackend::from_environment(DesktopEnvironment::Kde, false, true), - ShortcutBackend::PortalServiceDropIn + ShortcutBackend::from_runtime_inputs( + DesktopEnvironment::Gnome, + PortalShortcutDropInState { + portal_shortcut_present: true, + portal_app_id_present: true, + explicit_portal_opt_in_present: true, + } + ), + if portal_runtime_supported() { + ShortcutBackend::PortalServiceDropIn + } else { + ShortcutBackend::GnomeCustomShortcut + } ); assert_eq!( - ShortcutBackend::from_environment(DesktopEnvironment::Unknown, false, false), - ShortcutBackend::Manual + ShortcutBackend::from_runtime_inputs( + DesktopEnvironment::Kde, + PortalShortcutDropInState::default(), + ), + if portal_runtime_supported() { + ShortcutBackend::PortalServiceDropIn + } else { + ShortcutBackend::Manual + } + ); + } + + #[test] + fn shortcut_apply_capability_does_not_fallback_to_portal_on_gnome() { + assert_eq!( + ShortcutApplyCapability::from_environment(DesktopEnvironment::Gnome, true, true), + ShortcutApplyCapability::GnomeCustomShortcut + ); + assert_eq!( + ShortcutApplyCapability::from_environment(DesktopEnvironment::Gnome, false, true), + ShortcutApplyCapability::Manual + ); + assert_eq!( + ShortcutApplyCapability::from_environment(DesktopEnvironment::Unknown, false, true), + if portal_runtime_supported() { + ShortcutApplyCapability::PortalServiceDropIn + } else { + ShortcutApplyCapability::Manual + } + ); + } + + #[test] + fn configurator_build_keeps_portal_runtime_support_enabled() { + assert!( + portal_runtime_supported(), + "wayscriber-configurator must enable the wayscriber `portal` feature so status and shortcut setup stay aligned with portal-capable daemon builds" ); } } diff --git a/configurator/src/models/mod.rs b/configurator/src/models/mod.rs index 9f240e47..b60b167a 100644 --- a/configurator/src/models/mod.rs +++ b/configurator/src/models/mod.rs @@ -12,7 +12,8 @@ pub use color::{ColorMode, ColorQuadInput, ColorTripletInput, NamedColorOption}; pub use color_picker::{ColorPickerId, ColorPickerValue}; pub use config::{BoardBackgroundOption, BoardItemTextField, BoardItemToggleField, ConfigDraft}; pub use daemon::{ - DaemonAction, DaemonActionResult, DaemonRuntimeStatus, DesktopEnvironment, ShortcutBackend, + DaemonAction, DaemonActionResult, DaemonRuntimeStatus, DesktopEnvironment, + ShortcutApplyCapability, ShortcutBackend, }; pub use fields::{ DragToolField, EraserModeOption, FontStyleOption, FontWeightOption, OverrideOption, diff --git a/docs/SETUP.md b/docs/SETUP.md index a217e6d4..6e302eea 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -23,7 +23,8 @@ If you installed `wayscriber-configurator`, you can set this up entirely in GUI: Desktop-specific shortcut handling: - GNOME: creates/updates a GNOME custom shortcut that runs `pkill -SIGUSR1 wayscriber`. -- KDE/Plasma: writes a systemd user drop-in with `WAYSCRIBER_PORTAL_SHORTCUT` for portal global shortcut handling. +- GNOME migrations: `Install/Update Service` and `Apply Shortcut` remove stale `~/.config/systemd/user/wayscriber.service.d/shortcut.conf` files so old portal settings do not override GNOME behavior. +- KDE/Plasma: writes a systemd user drop-in with `WAYSCRIBER_ENABLE_PORTAL_SHORTCUTS=1` and `WAYSCRIBER_PORTAL_SHORTCUT` for portal global shortcut handling. ### Quick Install diff --git a/src/daemon/core.rs b/src/daemon/core.rs index f44ff629..94d1c9d6 100644 --- a/src/daemon/core.rs +++ b/src/daemon/core.rs @@ -18,6 +18,7 @@ use crate::SESSION_OVERRIDE_FOLLOW_CONFIG; use crate::paths::daemon_lock_file; use crate::session::try_lock_exclusive; use crate::{RESUME_SESSION_ENV, decode_session_override, encode_session_override}; +use wayscriber::shortcut_hint::{ShortcutRuntimeBackend, current_shortcut_runtime_backend}; use super::control::DaemonToggleRequest; use super::global_shortcuts::start_global_shortcuts_listener; @@ -309,13 +310,25 @@ impl Daemon { info!("System tray disabled; running daemon without tray"); } - self.global_shortcuts_thread = start_global_shortcuts_listener( - self.toggle_requested.clone(), - self.should_quit.clone(), - self.portal_activation_token_slot.clone(), - ); - if self.global_shortcuts_thread.is_some() { - info!("Global shortcuts portal listener started"); + match current_shortcut_runtime_backend() { + ShortcutRuntimeBackend::PortalGlobalShortcuts => { + self.global_shortcuts_thread = start_global_shortcuts_listener( + self.toggle_requested.clone(), + self.should_quit.clone(), + self.portal_activation_token_slot.clone(), + ); + if self.global_shortcuts_thread.is_some() { + info!("Global shortcuts portal listener started"); + } + } + ShortcutRuntimeBackend::GnomeCustomShortcut => { + info!( + "Global shortcuts portal listener skipped on GNOME; using GNOME shortcut backend" + ); + } + ShortcutRuntimeBackend::Manual => { + info!("Global shortcuts portal listener skipped; portal runtime unavailable"); + } } info!("Daemon ready - waiting for toggle signal"); diff --git a/src/daemon/global_shortcuts.rs b/src/daemon/global_shortcuts.rs index a63c9e49..54c25076 100644 --- a/src/daemon/global_shortcuts.rs +++ b/src/daemon/global_shortcuts.rs @@ -1,7 +1,12 @@ -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; +#[cfg(feature = "portal")] +use std::sync::atomic::Ordering; +#[cfg(feature = "portal")] +use wayscriber::shortcut_hint::{PORTAL_APP_ID_ENV, PORTAL_SHORTCUT_ENV}; + #[cfg(feature = "portal")] use anyhow::{Context, Result, anyhow}; #[cfg(feature = "portal")] @@ -17,7 +22,9 @@ use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; #[cfg(feature = "portal")] use zbus::{Connection, proxy}; +#[cfg(feature = "portal")] const DEFAULT_PREFERRED_TRIGGER: &str = "g"; +#[cfg(feature = "portal")] const DEFAULT_PORTAL_APP_ID: &str = "wayscriber"; #[cfg(feature = "portal")] @@ -35,11 +42,11 @@ pub(super) fn start_global_shortcuts_listener( #[cfg(feature = "portal")] { let listener_quit_flag = quit_flag.clone(); - let preferred_trigger = std::env::var("WAYSCRIBER_PORTAL_SHORTCUT") + let preferred_trigger = std::env::var(PORTAL_SHORTCUT_ENV) .ok() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| DEFAULT_PREFERRED_TRIGGER.to_string()); - let portal_app_id = std::env::var("WAYSCRIBER_PORTAL_APP_ID") + let portal_app_id = std::env::var(PORTAL_APP_ID_ENV) .ok() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| DEFAULT_PORTAL_APP_ID.to_string()); @@ -240,6 +247,11 @@ async fn run_listener( } } + if quit_flag.load(Ordering::Acquire) { + debug!("Skipping GlobalShortcuts session close during shutdown"); + return Ok(()); + } + if let Err(err) = close_global_shortcuts_session(&connection, &session_handle).await { warn!("Failed to close GlobalShortcuts session cleanly: {}", err); } diff --git a/src/daemon/tray/shortcut_hint_io.rs b/src/daemon/tray/shortcut_hint_io.rs index 217f8a5c..fdfa10d6 100644 --- a/src/daemon/tray/shortcut_hint_io.rs +++ b/src/daemon/tray/shortcut_hint_io.rs @@ -4,31 +4,32 @@ use std::env; 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, + GNOME_MEDIA_KEYS_KEY, GNOME_MEDIA_KEYS_SCHEMA, PORTAL_SHORTCUT_ENV, ShortcutRuntimeBackend, + current_shortcut_runtime_backend, 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), + match current_shortcut_runtime_backend() { + ShortcutRuntimeBackend::PortalGlobalShortcuts => { + let portal_shortcut_env = env::var(PORTAL_SHORTCUT_ENV).ok(); + normalize_shortcut_hint(portal_shortcut_env.as_deref()) + } + ShortcutRuntimeBackend::GnomeCustomShortcut => { + if !current_desktop_is_gnome() { + return None; + } + let (custom_keybindings_raw, binding_raw) = read_gnome_shortcut_outputs()?; + resolve_toggle_shortcut_hint( + None, + true, + Some(custom_keybindings_raw.as_str()), + Some(binding_raw.as_str()), + ) } - } else { - (None, None) - }; - resolve_toggle_shortcut_hint( - portal_shortcut_env.as_deref(), - gnome_desktop, - custom_keybindings_raw.as_deref(), - binding_raw.as_deref(), - ) + ShortcutRuntimeBackend::Manual => None, + } } #[cfg(feature = "tray")] diff --git a/src/shortcut_hint.rs b/src/shortcut_hint.rs index 329dc342..b58f7d99 100644 --- a/src/shortcut_hint.rs +++ b/src/shortcut_hint.rs @@ -1,9 +1,41 @@ +use std::fs; + +use crate::systemd_user_service::portal_shortcut_dropin_path; + 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 const PORTAL_SHORTCUT_ENV: &str = "WAYSCRIBER_PORTAL_SHORTCUT"; +pub const PORTAL_APP_ID_ENV: &str = "WAYSCRIBER_PORTAL_APP_ID"; +pub const PORTAL_SHORTCUT_OPT_IN_ENV: &str = "WAYSCRIBER_ENABLE_PORTAL_SHORTCUTS"; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PortalShortcutDropInState { + pub portal_shortcut_present: bool, + pub portal_app_id_present: bool, + pub explicit_portal_opt_in_present: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ShortcutRuntimeInputs { + pub gnome_desktop: bool, + pub portal_runtime_supported: bool, + pub portal_dropin_state: PortalShortcutDropInState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShortcutRuntimeBackend { + GnomeCustomShortcut, + PortalGlobalShortcuts, + Manual, +} + +pub const fn portal_runtime_supported() -> bool { + cfg!(feature = "portal") +} pub fn gnome_shortcut_schema_with_path() -> String { format!("{GNOME_CUSTOM_KEYBINDING_SCHEMA}:{GNOME_WAYSCRIBER_KEYBINDING_PATH}") @@ -14,6 +46,32 @@ pub fn is_gnome_desktop(current: &str, session: &str) -> bool { combined.contains("gnome") } +pub fn resolve_shortcut_runtime_backend(inputs: ShortcutRuntimeInputs) -> ShortcutRuntimeBackend { + if inputs.gnome_desktop { + if inputs.portal_runtime_supported + && inputs.portal_dropin_state.explicit_portal_opt_in_present + { + return ShortcutRuntimeBackend::PortalGlobalShortcuts; + } + return ShortcutRuntimeBackend::GnomeCustomShortcut; + } + if inputs.portal_runtime_supported { + ShortcutRuntimeBackend::PortalGlobalShortcuts + } else { + ShortcutRuntimeBackend::Manual + } +} + +pub fn current_shortcut_runtime_backend() -> ShortcutRuntimeBackend { + let current = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default(); + let session = std::env::var("XDG_SESSION_DESKTOP").unwrap_or_default(); + resolve_shortcut_runtime_backend(ShortcutRuntimeInputs { + gnome_desktop: is_gnome_desktop(¤t, &session), + portal_runtime_supported: portal_runtime_supported(), + portal_dropin_state: read_portal_shortcut_dropin_state(), + }) +} + pub fn normalize_shortcut_hint(value: Option<&str>) -> Option { let trimmed = value?.trim(); if trimmed.is_empty() { @@ -70,6 +128,45 @@ pub fn parse_gsettings_path_list(raw: &str) -> Result, String> { Ok(values) } +pub fn read_portal_shortcut_dropin_state() -> PortalShortcutDropInState { + portal_shortcut_dropin_path() + .and_then(|path| fs::read_to_string(path).ok()) + .map(|content| parse_portal_shortcut_dropin_state(&content)) + .unwrap_or_default() +} + +pub fn parse_portal_shortcut_dropin_state(content: &str) -> PortalShortcutDropInState { + let mut state = PortalShortcutDropInState::default(); + for line in content.lines() { + let Some((name, value)) = parse_systemd_environment_assignment(line) else { + continue; + }; + match name.as_str() { + PORTAL_SHORTCUT_ENV => { + state.portal_shortcut_present = normalize_shortcut_hint(Some(&value)).is_some(); + } + PORTAL_APP_ID_ENV => { + state.portal_app_id_present = normalize_shortcut_hint(Some(&value)).is_some(); + } + PORTAL_SHORTCUT_OPT_IN_ENV => { + state.explicit_portal_opt_in_present = value.trim() == "1"; + } + _ => {} + } + } + state +} + +pub fn parse_portal_shortcut_from_dropin(content: &str) -> Option { + content.lines().find_map(|line| { + let (name, value) = parse_systemd_environment_assignment(line)?; + if name != PORTAL_SHORTCUT_ENV { + return None; + } + normalize_shortcut_hint(Some(&value)) + }) +} + 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 @@ -99,6 +196,42 @@ pub fn resolve_toggle_shortcut_hint( gnome_effective_shortcut(custom_keybindings, binding) } +fn parse_systemd_environment_assignment(line: &str) -> Option<(String, String)> { + let trimmed = line.trim(); + let raw_assignment = trimmed.strip_prefix("Environment=")?; + let assignment = if let Some(quoted_assignment) = raw_assignment + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + { + unescape_systemd_env_assignment(quoted_assignment) + } else { + raw_assignment.to_string() + }; + let (name, value) = assignment.split_once('=')?; + Some((name.trim().to_string(), value.trim().to_string())) +} + +fn unescape_systemd_env_assignment(value: &str) -> String { + let mut unescaped = String::with_capacity(value.len()); + let mut escaped = false; + for ch in value.chars() { + if escaped { + unescaped.push(ch); + escaped = false; + continue; + } + if ch == '\\' { + escaped = true; + continue; + } + unescaped.push(ch); + } + if escaped { + unescaped.push('\\'); + } + unescaped +} + #[cfg(test)] mod tests { use super::*; @@ -211,4 +344,62 @@ mod tests { None ); } + + #[test] + fn parse_portal_shortcut_dropin_state_handles_legacy_gnome_dropin() { + let content = "[Service]\nEnvironment=\"WAYSCRIBER_PORTAL_SHORTCUT=g\"\nEnvironment=\"WAYSCRIBER_PORTAL_APP_ID=com.devmobasa.wayscriber\"\n"; + assert_eq!( + parse_portal_shortcut_dropin_state(content), + PortalShortcutDropInState { + portal_shortcut_present: true, + portal_app_id_present: true, + explicit_portal_opt_in_present: false, + } + ); + assert_eq!( + resolve_shortcut_runtime_backend(ShortcutRuntimeInputs { + gnome_desktop: true, + portal_runtime_supported: true, + portal_dropin_state: parse_portal_shortcut_dropin_state(content), + }), + ShortcutRuntimeBackend::GnomeCustomShortcut + ); + } + + #[test] + fn parse_portal_shortcut_dropin_state_handles_explicit_opt_in() { + let content = "[Service]\nEnvironment=\"WAYSCRIBER_ENABLE_PORTAL_SHORTCUTS=1\"\nEnvironment=\"WAYSCRIBER_PORTAL_SHORTCUT=g\"\nEnvironment=\"WAYSCRIBER_PORTAL_APP_ID=wayscriber\"\n"; + assert_eq!( + parse_portal_shortcut_dropin_state(content), + PortalShortcutDropInState { + portal_shortcut_present: true, + portal_app_id_present: true, + explicit_portal_opt_in_present: true, + } + ); + assert_eq!( + parse_portal_shortcut_from_dropin(content), + Some("g".to_string()) + ); + } + + #[test] + fn resolve_shortcut_runtime_backend_preserves_non_gnome_portal_default() { + assert_eq!( + resolve_shortcut_runtime_backend(ShortcutRuntimeInputs { + gnome_desktop: false, + portal_runtime_supported: true, + portal_dropin_state: PortalShortcutDropInState::default(), + }), + ShortcutRuntimeBackend::PortalGlobalShortcuts + ); + assert_eq!( + resolve_shortcut_runtime_backend(ShortcutRuntimeInputs { + gnome_desktop: false, + portal_runtime_supported: false, + portal_dropin_state: PortalShortcutDropInState::default(), + }), + ShortcutRuntimeBackend::Manual + ); + } }