From a708bee6bfe571d443e6e2a3b868101ea25a22e9 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Thu, 19 Feb 2026 09:15:23 +0100 Subject: [PATCH 1/2] feat: improve xdg fallback focus handling and portal shortcuts --- config.example.toml | 4 + configurator/src/app/entry.rs | 9 +- configurator/src/app/view/ui/mod.rs | 6 + .../src/models/config/draft/from_config.rs | 6 +- configurator/src/models/config/draft/mod.rs | 1 + configurator/src/models/config/setters.rs | 1 + configurator/src/models/config/tests.rs | 19 +- .../src/models/config/to_config/ui.rs | 7 +- configurator/src/models/fields/toggles.rs | 1 + docs/CONFIG.md | 6 +- packaging/wayscriber.desktop | 2 +- src/about_window/mod.rs | 5 +- src/app/mod.rs | 31 ++ src/app/usage.rs | 4 +- src/app_id.rs | 24 + src/backend/wayland/backend/event_loop/mod.rs | 66 ++- src/backend/wayland/backend/signals.rs | 4 + src/backend/wayland/backend/surface.rs | 9 +- src/backend/wayland/handlers/keyboard/mod.rs | 10 + src/backend/wayland/handlers/pointer/mod.rs | 3 +- src/backend/wayland/handlers/xdg.rs | 34 ++ src/backend/wayland/state/activation.rs | 29 +- src/backend/wayland/state/core/accessors.rs | 35 ++ src/backend/wayland/state/core/init.rs | 12 + src/backend/wayland/state/data.rs | 9 + src/config/enums.rs | 11 + src/config/mod.rs | 2 +- src/config/tests/load.rs | 41 ++ src/config/types/ui.rs | 35 +- src/daemon/core.rs | 53 +- src/daemon/global_shortcuts.rs | 456 ++++++++++++++++++ src/daemon/mod.rs | 1 + src/daemon/overlay/spawn.rs | 17 +- src/main.rs | 1 + src/paths/mod.rs | 5 + tools/set-portal-shortcut.sh | 26 + 36 files changed, 952 insertions(+), 33 deletions(-) create mode 100644 src/app_id.rs create mode 100644 src/daemon/global_shortcuts.rs create mode 100755 tools/set-portal-shortcut.sh diff --git a/config.example.toml b/config.example.toml index ab42ade0..9c60ab88 100644 --- a/config.example.toml +++ b/config.example.toml @@ -262,6 +262,10 @@ active_output_badge = true # Request fullscreen for the GNOME fallback overlay. Disable if fullscreen appears opaque. #xdg_fullscreen = false +# Behavior when GNOME fallback (xdg-shell) loses keyboard focus. +# "stay" (default on Ubuntu/GNOME) keeps it open. "exit" closes the overlay. +#xdg_focus_loss_behavior = "exit" + # Mouse button that toggles radial menu # Options: "middle", "right", "disabled" radial_menu_mouse_binding = "middle" diff --git a/configurator/src/app/entry.rs b/configurator/src/app/entry.rs index ef750490..d72e7ecc 100644 --- a/configurator/src/app/entry.rs +++ b/configurator/src/app/entry.rs @@ -3,10 +3,17 @@ use iced::{Application, Settings, Size}; use super::state::ConfiguratorApp; pub fn run() -> iced::Result { - let mut settings = Settings::default(); + let mut settings = Settings { + id: Some("wayscriber-configurator".to_string()), + ..Settings::default() + }; settings.window.size = Size::new(960.0, 640.0); settings.window.resizable = true; settings.window.decorations = true; + #[cfg(target_os = "linux")] + { + settings.window.platform_specific.application_id = "wayscriber-configurator".to_string(); + } if std::env::var_os("ICED_BACKEND").is_none() && should_force_tiny_skia() { // GNOME Wayland + wgpu can crash on dma-buf/present mode selection; tiny-skia avoids this. // SAFETY: setting a process-local env var before initializing iced is safe here. diff --git a/configurator/src/app/view/ui/mod.rs b/configurator/src/app/view/ui/mod.rs index 23349993..2a2c6f84 100644 --- a/configurator/src/app/view/ui/mod.rs +++ b/configurator/src/app/view/ui/mod.rs @@ -57,6 +57,12 @@ impl ConfiguratorApp { self.defaults.ui_xdg_fullscreen, ToggleField::UiXdgFullscreen, ), + toggle_row( + "Keep open on xdg focus loss", + self.draft.ui_xdg_keep_on_focus_loss, + self.defaults.ui_xdg_keep_on_focus_loss, + ToggleField::UiXdgKeepOnFocusLoss, + ), toggle_row( "Enable context menu", self.draft.ui_context_menu_enabled, diff --git a/configurator/src/models/config/draft/from_config.rs b/configurator/src/models/config/draft/from_config.rs index 9b7cc651..50c67afd 100644 --- a/configurator/src/models/config/draft/from_config.rs +++ b/configurator/src/models/config/draft/from_config.rs @@ -14,7 +14,7 @@ use super::super::boards::BoardsDraft; use super::super::presets::PresetsDraft; use super::super::toolbar_overrides::ToolbarModeOverridesDraft; use super::ConfigDraft; -use wayscriber::config::Config; +use wayscriber::config::{Config, XdgFocusLossBehavior}; impl ConfigDraft { pub fn from_config(config: &Config) -> Self { @@ -73,6 +73,10 @@ impl ConfigDraft { ui_context_menu_enabled: config.ui.context_menu.enabled, ui_preferred_output: config.ui.preferred_output.clone().unwrap_or_default(), ui_xdg_fullscreen: config.ui.xdg_fullscreen, + ui_xdg_keep_on_focus_loss: matches!( + config.ui.xdg_focus_loss_behavior, + XdgFocusLossBehavior::Stay + ), ui_command_palette_toast_duration_ms: config .ui .command_palette_toast_duration_ms diff --git a/configurator/src/models/config/draft/mod.rs b/configurator/src/models/config/draft/mod.rs index 64fc0e48..46bf23d8 100644 --- a/configurator/src/models/config/draft/mod.rs +++ b/configurator/src/models/config/draft/mod.rs @@ -62,6 +62,7 @@ pub struct ConfigDraft { pub ui_context_menu_enabled: bool, pub ui_preferred_output: String, pub ui_xdg_fullscreen: bool, + pub ui_xdg_keep_on_focus_loss: bool, pub ui_command_palette_toast_duration_ms: String, pub ui_toolbar_top_pinned: bool, pub ui_toolbar_side_pinned: bool, diff --git a/configurator/src/models/config/setters.rs b/configurator/src/models/config/setters.rs index dabaff87..6c6bc764 100644 --- a/configurator/src/models/config/setters.rs +++ b/configurator/src/models/config/setters.rs @@ -59,6 +59,7 @@ impl ConfigDraft { ToggleField::UiHelpOverlayContextFilter => self.help_context_filter = value, ToggleField::UiContextMenuEnabled => self.ui_context_menu_enabled = value, ToggleField::UiXdgFullscreen => self.ui_xdg_fullscreen = value, + ToggleField::UiXdgKeepOnFocusLoss => self.ui_xdg_keep_on_focus_loss = value, ToggleField::UiToolbarTopPinned => self.ui_toolbar_top_pinned = value, ToggleField::UiToolbarSidePinned => self.ui_toolbar_side_pinned = value, ToggleField::UiToolbarUseIcons => self.ui_toolbar_use_icons = value, diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs index 42666cf2..5edbab0b 100644 --- a/configurator/src/models/config/tests.rs +++ b/configurator/src/models/config/tests.rs @@ -5,7 +5,7 @@ use super::super::fields::{ }; use super::super::{ColorMode, NamedColorOption}; use super::ConfigDraft; -use wayscriber::config::{ColorSpec, Config, ToolPresetConfig}; +use wayscriber::config::{ColorSpec, Config, ToolPresetConfig, XdgFocusLossBehavior}; use wayscriber::input::Tool; #[test] @@ -170,3 +170,20 @@ fn config_draft_round_trips_drag_tool_mapping() { config.drawing.tab_drag_tool ); } + +#[test] +fn config_draft_round_trips_xdg_focus_loss_behavior() { + let mut config = Config::default(); + config.ui.xdg_focus_loss_behavior = XdgFocusLossBehavior::Stay; + + let draft = ConfigDraft::from_config(&config); + assert!(draft.ui_xdg_keep_on_focus_loss); + + let round_trip = draft + .to_config(&config) + .expect("expected config to round trip"); + assert_eq!( + round_trip.ui.xdg_focus_loss_behavior, + XdgFocusLossBehavior::Stay + ); +} diff --git a/configurator/src/models/config/to_config/ui.rs b/configurator/src/models/config/to_config/ui.rs index 5206a792..2217992b 100644 --- a/configurator/src/models/config/to_config/ui.rs +++ b/configurator/src/models/config/to_config/ui.rs @@ -1,7 +1,7 @@ use super::super::draft::ConfigDraft; use super::super::parse::{parse_field, parse_u64_field}; use crate::models::error::FormError; -use wayscriber::config::Config; +use wayscriber::config::{Config, XdgFocusLossBehavior}; impl ConfigDraft { pub(super) fn apply_ui(&self, config: &mut Config, errors: &mut Vec) { @@ -18,6 +18,11 @@ impl ConfigDraft { Some(preferred_output.to_string()) }; config.ui.xdg_fullscreen = self.ui_xdg_fullscreen; + config.ui.xdg_focus_loss_behavior = if self.ui_xdg_keep_on_focus_loss { + XdgFocusLossBehavior::Stay + } else { + XdgFocusLossBehavior::Exit + }; parse_u64_field( &self.ui_command_palette_toast_duration_ms, "ui.command_palette_toast_duration_ms", diff --git a/configurator/src/models/fields/toggles.rs b/configurator/src/models/fields/toggles.rs index 13048539..c410e43b 100644 --- a/configurator/src/models/fields/toggles.rs +++ b/configurator/src/models/fields/toggles.rs @@ -8,6 +8,7 @@ pub enum ToggleField { UiHelpOverlayContextFilter, UiContextMenuEnabled, UiXdgFullscreen, + UiXdgKeepOnFocusLoss, UiToolbarTopPinned, UiToolbarSidePinned, UiToolbarUseIcons, diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 0310dd32..aaaf91cf 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -262,6 +262,10 @@ active_output_badge = true # Request fullscreen for the GNOME fallback overlay (disable if opaque) #xdg_fullscreen = false +# Behavior when GNOME fallback (xdg-shell) loses keyboard focus +# Options: "exit", "stay" (default on Ubuntu/GNOME) +#xdg_focus_loss_behavior = "exit" + # Mouse button that toggles radial menu # Options: "middle", "right", "disabled" radial_menu_mouse_binding = "middle" @@ -320,7 +324,7 @@ enabled = true - **Highlight tool ring**: `show_on_highlight_tool = true` keeps a persistent halo visible while the highlight tool is active - **Context menu**: `ui.context_menu.enabled` toggles right-click / keyboard menus - **Output focus**: `multi_monitor_enabled` controls output-cycling shortcuts; `active_output_badge` shows the current monitor in the status bar -- **GNOME fallback**: `preferred_output` pins the xdg-shell overlay to a specific monitor; `xdg_fullscreen` requests fullscreen instead of maximized +- **GNOME fallback**: `preferred_output` pins the xdg-shell overlay to a specific monitor; `xdg_fullscreen` requests fullscreen instead of maximized; `xdg_focus_loss_behavior` controls whether losing focus closes (`exit`) or keeps (`stay`) the overlay - **Radial menu trigger**: `radial_menu_mouse_binding` selects which mouse button opens radial menu (`middle` default, `right`, or `disabled`) **Multi-monitor behavior:** diff --git a/packaging/wayscriber.desktop b/packaging/wayscriber.desktop index c19a9ec6..e99e739a 100644 --- a/packaging/wayscriber.desktop +++ b/packaging/wayscriber.desktop @@ -9,4 +9,4 @@ Icon=wayscriber Terminal=false Categories=Graphics; Keywords=draw;annotate;screenshot;whiteboard;wayland; -StartupNotify=false +StartupNotify=true diff --git a/src/about_window/mod.rs b/src/about_window/mod.rs index 33545317..ee3b4129 100644 --- a/src/about_window/mod.rs +++ b/src/about_window/mod.rs @@ -10,6 +10,8 @@ use smithay_client_toolkit::shm::{Shm, slot::SlotPool}; use wayland_client::Connection; use wayland_client::globals::registry_queue_init; +use crate::app_id::runtime_app_id; + mod clipboard; mod handlers; mod render; @@ -37,7 +39,8 @@ pub fn run_about_window() -> Result<()> { let wl_surface = compositor_state.create_surface(&qh); let window = xdg_shell.create_window(wl_surface, WindowDecorations::None, &qh); window.set_title("Wayscriber About"); - window.set_app_id("com.devmobasa.wayscriber"); + let app_id = runtime_app_id(); + window.set_app_id(&app_id); window.set_min_size(Some((ABOUT_WIDTH, ABOUT_HEIGHT))); window.set_max_size(Some((ABOUT_WIDTH, ABOUT_HEIGHT))); window.commit(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 4be504e8..4c7d219f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -4,12 +4,39 @@ mod usage; use crate::backend::ExitAfterCaptureMode; use crate::cli::Cli; +use crate::paths::overlay_lock_file; +use crate::session::try_lock_exclusive; use crate::session_override::set_runtime_session_override; use env::env_flag_enabled; use session::run_session_cli_commands; +use std::fs::{File, OpenOptions}; +use std::io::ErrorKind; use std::process::{Command, Stdio}; use usage::{log_overlay_controls, print_usage}; +fn acquire_overlay_lock() -> anyhow::Result> { + let lock_path = overlay_lock_file(); + if let Some(parent) = lock_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let lock_file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&lock_path)?; + + match try_lock_exclusive(&lock_file) { + Ok(()) => Ok(Some(lock_file)), + Err(err) if err.kind() == ErrorKind::WouldBlock => { + log::warn!("Overlay already running; skipping duplicate --active launch"); + Ok(None) + } + Err(err) => Err(err.into()), + } +} + fn maybe_detach_active(cli: &Cli) -> anyhow::Result { if !(cli.active || cli.freeze) { return Ok(false); @@ -89,6 +116,10 @@ pub fn run(cli: Cli) -> anyhow::Result<()> { if maybe_detach_active(&cli)? { return Ok(()); } + let _overlay_lock = match acquire_overlay_lock()? { + Some(lock) => lock, + None => return Ok(()), + }; // One-shot mode: show overlay immediately and exit when done log_overlay_controls(cli.freeze); diff --git a/src/app/usage.rs b/src/app/usage.rs index e9a8af37..f092c936 100644 --- a/src/app/usage.rs +++ b/src/app/usage.rs @@ -188,7 +188,9 @@ pub(crate) fn print_usage() { println!(" 1. Run: wayscriber --daemon"); println!(" 2. Add to Hyprland config:"); println!(" exec-once = wayscriber --daemon"); - println!(" bind = SUPER, D, exec, pkill -SIGUSR1 wayscriber"); + println!( + " bind = SUPER, D, exec, bash -lc \"kill -USR1 $(pgrep -fo 'wayscriber --daemon')\"" + ); println!(" 3. Press your bound shortcut (e.g. Super+D) to toggle overlay on/off"); println!(); println!("Requirements:"); diff --git a/src/app_id.rs b/src/app_id.rs new file mode 100644 index 00000000..142f1b31 --- /dev/null +++ b/src/app_id.rs @@ -0,0 +1,24 @@ +const DEFAULT_APP_ID: &str = "wayscriber"; +const APP_ID_ENV: &str = "WAYSCRIBER_APP_ID"; +const PORTAL_APP_ID_ENV: &str = "WAYSCRIBER_PORTAL_APP_ID"; + +pub(crate) fn runtime_app_id() -> String { + std::env::var(APP_ID_ENV) + .ok() + .and_then(non_empty_trimmed) + .or_else(|| { + std::env::var(PORTAL_APP_ID_ENV) + .ok() + .and_then(non_empty_trimmed) + }) + .unwrap_or_else(|| DEFAULT_APP_ID.to_string()) +} + +fn non_empty_trimmed(value: String) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} diff --git a/src/backend/wayland/backend/event_loop/mod.rs b/src/backend/wayland/backend/event_loop/mod.rs index ce7cec53..f8f61627 100644 --- a/src/backend/wayland/backend/event_loop/mod.rs +++ b/src/backend/wayland/backend/event_loop/mod.rs @@ -116,22 +116,43 @@ pub(super) fn run_event_loop( // Check immediately after dispatch returns. if state.input_state.should_exit { - info!("Exit requested after dispatch, breaking event loop"); - break; + let explicit_xdg_close_requested = state.take_xdg_explicit_close_requested(); + if should_defer_xdg_unfocused_exit( + state.surface.is_xdg_window(), + !state.xdg_focus_loss_exits_overlay(), + state.has_keyboard_focus(), + explicit_xdg_close_requested, + ) { + warn!("Exit requested while unfocused in xdg stay mode; keeping overlay open"); + state.input_state.should_exit = false; + } else { + info!("Exit requested after dispatch, breaking event loop"); + break; + } } if state.surface.is_xdg_window() && !state.has_keyboard_focus() && state.focus_exit_suppression_expired(Instant::now()) { - warn!("Keyboard focus not restored after clipboard action; exiting overlay"); - state.clear_focus_exit_suppression(); - notification::send_notification_async( - &state.tokio_handle, - "Wayscriber lost focus".to_string(), - "GNOME could not keep the overlay focused; closing fallback window.".to_string(), - Some("dialog-warning".to_string()), - ); - state.input_state.should_exit = true; + if state.xdg_focus_loss_exits_overlay() { + warn!("Keyboard focus not restored after clipboard action; exiting overlay"); + state.clear_focus_exit_suppression(); + notification::send_notification_async( + &state.tokio_handle, + "Wayscriber lost focus".to_string(), + "GNOME could not keep the overlay focused; closing fallback window." + .to_string(), + Some("dialog-warning".to_string()), + ); + state.input_state.should_exit = true; + } else { + warn!( + "Keyboard focus not restored after clipboard action; keeping overlay open (ui.xdg_focus_loss_behavior=stay)" + ); + state.clear_focus_exit_suppression(); + state.set_xdg_close_guard_for(Duration::from_millis(2500)); + state.request_xdg_activation(qh); + } } // Adjust keyboard interactivity if toolbar visibility changed. state.sync_toolbar_visibility(qh); @@ -180,3 +201,26 @@ pub(super) fn run_event_loop( EventLoopOutcome { loop_error } } + +fn should_defer_xdg_unfocused_exit( + is_xdg_window: bool, + stay_mode: bool, + has_keyboard_focus: bool, + explicit_xdg_close_requested: bool, +) -> bool { + is_xdg_window && stay_mode && !has_keyboard_focus && !explicit_xdg_close_requested +} + +#[cfg(test)] +mod tests { + use super::should_defer_xdg_unfocused_exit; + + #[test] + fn defers_exit_only_for_unfocused_xdg_stay_without_explicit_close() { + assert!(should_defer_xdg_unfocused_exit(true, true, false, false)); + assert!(!should_defer_xdg_unfocused_exit(true, true, true, false)); + assert!(!should_defer_xdg_unfocused_exit(true, false, false, false)); + assert!(!should_defer_xdg_unfocused_exit(false, true, false, false)); + assert!(!should_defer_xdg_unfocused_exit(true, true, false, true)); + } +} diff --git a/src/backend/wayland/backend/signals.rs b/src/backend/wayland/backend/signals.rs index 1d3c08e9..0238bd10 100644 --- a/src/backend/wayland/backend/signals.rs +++ b/src/backend/wayland/backend/signals.rs @@ -23,6 +23,10 @@ pub(super) fn setup_signal_handlers() -> (Option>, Option { + // SIGUSR1 is reserved for daemon toggle; ignore in overlay. + log::debug!("Overlay received SIGUSR1; ignoring"); + } SIGUSR2 => { log::debug!("Overlay received SIGUSR2 for tray action"); tray_action_flag_clone.store(true, Ordering::Release); diff --git a/src/backend/wayland/backend/surface.rs b/src/backend/wayland/backend/surface.rs index 88b5a444..fcba46d9 100644 --- a/src/backend/wayland/backend/surface.rs +++ b/src/backend/wayland/backend/surface.rs @@ -6,6 +6,8 @@ use smithay_client_toolkit::shell::{ xdg::window::WindowDecorations, }; +use crate::app_id::runtime_app_id; + use super::super::state::WaylandState; pub(super) fn create_overlay_surface( @@ -41,7 +43,8 @@ pub(super) fn create_overlay_surface( info!("Layer shell missing; creating xdg-shell window"); let window = xdg_shell.create_window(wl_surface, WindowDecorations::None, qh); window.set_title("wayscriber overlay"); - window.set_app_id("com.devmobasa.wayscriber"); + let app_id = runtime_app_id(); + window.set_app_id(&app_id); if state.xdg_fullscreen() { if let Some(output) = state.preferred_fullscreen_output() { info!("Requesting fullscreen on preferred output"); @@ -55,7 +58,9 @@ pub(super) fn create_overlay_surface( } window.commit(); state.surface.set_xdg_window(window); - state.request_xdg_activation(qh); + if !state.activate_xdg_window_with_startup_token_if_present() { + state.request_xdg_activation(qh); + } info!("xdg-shell window created"); } else { return Err(anyhow::anyhow!( diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs index 07e877cf..cbda40be 100644 --- a/src/backend/wayland/handlers/keyboard/mod.rs +++ b/src/backend/wayland/handlers/keyboard/mod.rs @@ -3,6 +3,7 @@ mod translate; use log::{debug, warn}; use smithay_client_toolkit::seat::keyboard::{KeyEvent, KeyboardHandler, Modifiers, RawModifiers}; +use std::time::Duration; use wayland_client::{ Connection, QueueHandle, protocol::{wl_keyboard, wl_surface}, @@ -27,6 +28,7 @@ impl KeyboardHandler for WaylandState { debug!("Keyboard focus entered"); self.set_keyboard_focus(true); self.clear_focus_exit_suppression(); + self.clear_xdg_close_guard(); self.set_last_activation_serial(Some(serial)); self.maybe_retry_activation(qh); if let Some(target) = self.toolbar.focus_target_for_surface(surface) { @@ -63,11 +65,19 @@ impl KeyboardHandler for WaylandState { if self.surface.is_xdg_window() && self.focus_exit_suppressed() { warn!("Keyboard focus lost in xdg fallback; suppressing exit after clipboard action"); + self.set_xdg_close_guard_for(Duration::from_millis(2500)); self.request_xdg_activation(qh); return; } if self.surface.is_xdg_window() { + if !self.xdg_focus_loss_exits_overlay() { + warn!( + "Keyboard focus lost in xdg fallback; keeping overlay open without auto-reactivation (ui.xdg_focus_loss_behavior=stay)" + ); + self.set_xdg_close_guard_for(Duration::from_millis(2500)); + return; + } warn!("Keyboard focus lost in xdg fallback; exiting overlay"); notification::send_notification_async( &self.tokio_handle, diff --git a/src/backend/wayland/handlers/pointer/mod.rs b/src/backend/wayland/handlers/pointer/mod.rs index d93ac280..148f4aa3 100644 --- a/src/backend/wayland/handlers/pointer/mod.rs +++ b/src/backend/wayland/handlers/pointer/mod.rs @@ -50,7 +50,8 @@ impl PointerHandler for WaylandState { PointerEventKind::Motion { .. } => { self.handle_pointer_motion(conn, event, on_toolbar, inline_active); } - PointerEventKind::Press { button, .. } => { + PointerEventKind::Press { button, serial, .. } => { + self.set_last_activation_serial(Some(serial)); self.handle_pointer_press(conn, qh, event, on_toolbar, inline_active, button); } PointerEventKind::Release { button, .. } => { diff --git a/src/backend/wayland/handlers/xdg.rs b/src/backend/wayland/handlers/xdg.rs index 22883a6e..2775eb83 100644 --- a/src/backend/wayland/handlers/xdg.rs +++ b/src/backend/wayland/handlers/xdg.rs @@ -2,6 +2,7 @@ // layer-shell is unavailable (e.g., GNOME). use log::{debug, info, warn}; use smithay_client_toolkit::shell::xdg::window::{Window, WindowConfigure, WindowHandler}; +use std::time::Instant; use wayland_client::{Connection, QueueHandle}; use super::super::state::WaylandState; @@ -9,7 +10,19 @@ use crate::session; impl WindowHandler for WaylandState { fn request_close(&mut self, _conn: &Connection, _qh: &QueueHandle, _window: &Window) { + if should_ignore_xdg_close_request( + !self.xdg_focus_loss_exits_overlay(), + self.has_keyboard_focus(), + self.xdg_close_guard_active(Instant::now()), + ) { + warn!( + "xdg window close requested while unfocused in stay mode; keeping overlay open without auto-reactivation" + ); + return; + } + info!("xdg window close requested by compositor"); + self.mark_xdg_explicit_close_requested(); self.input_state.should_exit = true; } @@ -156,3 +169,24 @@ impl WindowHandler for WaylandState { } } } + +fn should_ignore_xdg_close_request( + stay_mode: bool, + has_keyboard_focus: bool, + close_guard_active: bool, +) -> bool { + stay_mode && !has_keyboard_focus && close_guard_active +} + +#[cfg(test)] +mod tests { + use super::should_ignore_xdg_close_request; + + #[test] + fn ignores_close_only_for_unfocused_stay_with_active_guard() { + assert!(should_ignore_xdg_close_request(true, false, true)); + assert!(!should_ignore_xdg_close_request(true, true, true)); + assert!(!should_ignore_xdg_close_request(false, false, true)); + assert!(!should_ignore_xdg_close_request(true, false, false)); + } +} diff --git a/src/backend/wayland/state/activation.rs b/src/backend/wayland/state/activation.rs index 7dff4c06..88bb2f42 100644 --- a/src/backend/wayland/state/activation.rs +++ b/src/backend/wayland/state/activation.rs @@ -1,6 +1,32 @@ +use log::info; + +use crate::app_id::runtime_app_id; + use super::*; impl WaylandState { + pub(in crate::backend::wayland) fn activate_xdg_window_with_startup_token_if_present( + &mut self, + ) -> bool { + if !self.surface.is_xdg_window() { + return false; + } + + let Some(token) = self.take_startup_activation_token() else { + return false; + }; + let Some(activation) = self.activation.as_ref() else { + return false; + }; + let Some(wl_surface) = self.surface.wl_surface().cloned() else { + return false; + }; + + info!("Applying startup activation token for xdg fallback window"); + activation.activate::(&wl_surface, token); + true + } + pub(in crate::backend::wayland) fn request_xdg_activation(&mut self, qh: &QueueHandle) { if !self.surface.is_xdg_window() { return; @@ -20,10 +46,11 @@ impl WaylandState { .cloned() .zip(self.last_activation_serial()) { + let app_id = runtime_app_id(); activation.request_token::( qh, RequestData { - app_id: Some("com.devmobasa.wayscriber".to_string()), + app_id: Some(app_id), seat_and_serial: Some(seat_serial), surface: Some(wl_surface), }, diff --git a/src/backend/wayland/state/core/accessors.rs b/src/backend/wayland/state/core/accessors.rs index 49ef3915..b61b8ea5 100644 --- a/src/backend/wayland/state/core/accessors.rs +++ b/src/backend/wayland/state/core/accessors.rs @@ -96,6 +96,30 @@ impl WaylandState { self.data.suppress_focus_exit_until = None; } + pub(in crate::backend::wayland) fn set_xdg_close_guard_for(&mut self, duration: Duration) { + self.data.xdg_close_guard_until = Some(Instant::now() + duration); + } + + pub(in crate::backend::wayland) fn clear_xdg_close_guard(&mut self) { + self.data.xdg_close_guard_until = None; + } + + pub(in crate::backend::wayland) fn xdg_close_guard_active(&self, now: Instant) -> bool { + self.data + .xdg_close_guard_until + .is_some_and(|until| now <= until) + } + + pub(in crate::backend::wayland) fn mark_xdg_explicit_close_requested(&mut self) { + self.data.xdg_explicit_close_requested = true; + } + + pub(in crate::backend::wayland) fn take_xdg_explicit_close_requested(&mut self) -> bool { + let was_requested = self.data.xdg_explicit_close_requested; + self.data.xdg_explicit_close_requested = false; + was_requested + } + pub(in crate::backend::wayland) fn frozen_enabled(&self) -> bool { self.data.frozen_enabled } @@ -132,6 +156,10 @@ impl WaylandState { self.data.pending_activation_token = token; } + pub(in crate::backend::wayland) fn take_startup_activation_token(&mut self) -> Option { + self.data.startup_activation_token.take() + } + pub(in crate::backend::wayland) fn preferred_output_identity(&self) -> Option<&str> { self.data.preferred_output_identity.as_deref() } @@ -148,6 +176,13 @@ impl WaylandState { self.data.xdg_fullscreen } + pub(in crate::backend::wayland) fn xdg_focus_loss_exits_overlay(&self) -> bool { + matches!( + self.config.ui.xdg_focus_loss_behavior, + crate::config::XdgFocusLossBehavior::Exit + ) + } + #[allow(dead_code)] pub(in crate::backend::wayland) fn set_xdg_fullscreen(&mut self, value: bool) { self.data.xdg_fullscreen = value; diff --git a/src/backend/wayland/state/core/init.rs b/src/backend/wayland/state/core/init.rs index a91617cc..c4c73176 100644 --- a/src/backend/wayland/state/core/init.rs +++ b/src/backend/wayland/state/core/init.rs @@ -46,6 +46,11 @@ impl WaylandState { let mut data = StateData::new(); data.frozen_enabled = frozen_enabled; data.pending_freeze_on_start = pending_freeze_on_start; + let startup_activation_token = startup_activation_token_from_env(); + if startup_activation_token.is_some() { + info!("Received startup activation token from launcher environment"); + } + data.startup_activation_token = startup_activation_token; data.preferred_output_identity = preferred_output_identity; data.xdg_fullscreen = xdg_fullscreen; let force_inline_toolbars = force_inline_toolbars_requested(&config); @@ -148,3 +153,10 @@ impl WaylandState { } } } + +fn startup_activation_token_from_env() -> Option { + std::env::var("XDG_ACTIVATION_TOKEN") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} diff --git a/src/backend/wayland/state/data.rs b/src/backend/wayland/state/data.rs index bef73b2d..8248b25d 100644 --- a/src/backend/wayland/state/data.rs +++ b/src/backend/wayland/state/data.rs @@ -70,6 +70,7 @@ pub struct StateData { pub(super) toolbar_drag_pending_apply: bool, pub(super) last_toolbar_drag_apply: Option, pub(super) pending_activation_token: Option, + pub(super) startup_activation_token: Option, pub(super) pending_freeze_on_start: bool, pub(super) frozen_enabled: bool, pub(super) has_seen_surface_enter: bool, @@ -82,6 +83,11 @@ pub struct StateData { pub(super) suppress_next_release: bool, /// Suppress overlay exit on focus loss for a short window (e.g., clipboard helpers). pub(super) suppress_focus_exit_until: Option, + /// Short guard window after xdg focus loss where compositor close requests are ignored + /// in stay mode to avoid spurious GNOME close events. + pub(super) xdg_close_guard_until: Option, + /// Explicit compositor close request received for xdg fallback window. + pub(super) xdg_explicit_close_requested: bool, } impl StateData { @@ -127,6 +133,7 @@ impl StateData { toolbar_drag_pending_apply: false, last_toolbar_drag_apply: None, pending_activation_token: None, + startup_activation_token: None, pending_freeze_on_start: false, frozen_enabled: false, has_seen_surface_enter: false, @@ -136,6 +143,8 @@ impl StateData { overlay_ready: false, suppress_next_release: false, suppress_focus_exit_until: None, + xdg_close_guard_until: None, + xdg_explicit_close_requested: false, } } } diff --git a/src/config/enums.rs b/src/config/enums.rs index 3e9c0b49..9f96dc1d 100644 --- a/src/config/enums.rs +++ b/src/config/enums.rs @@ -33,6 +33,17 @@ pub enum RadialMenuMouseBinding { Disabled, } +/// Behavior when the GNOME/xdg fallback overlay loses keyboard focus. +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] +#[serde(rename_all = "kebab-case")] +pub enum XdgFocusLossBehavior { + /// Close the overlay when focus moves away (legacy/default behavior). + #[default] + Exit, + /// Keep the overlay open after focus loss and let users reactivate it manually. + Stay, +} + /// Color specification - either a named color or RGB values. /// /// # Examples diff --git a/src/config/mod.rs b/src/config/mod.rs index f294cb35..1aae9799 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -29,7 +29,7 @@ pub use action_meta::{ action_meta, action_meta_iter, action_short_label, }; pub use core::Config; -pub use enums::{RadialMenuMouseBinding, StatusPosition}; +pub use enums::{RadialMenuMouseBinding, StatusPosition, XdgFocusLossBehavior}; #[allow(unused_imports)] pub use io::{ConfigSource, LoadedConfig}; pub use keybindings::{Action, KeyBinding, KeybindingsConfig}; diff --git a/src/config/tests/load.rs b/src/config/tests/load.rs index 74e180bf..e1040374 100644 --- a/src/config/tests/load.rs +++ b/src/config/tests/load.rs @@ -17,3 +17,44 @@ fn load_prefers_primary_directory() { assert!(matches!(loaded.source, ConfigSource::Primary)); }); } + +#[test] +fn load_parses_xdg_focus_loss_behavior_stay() { + with_temp_config_home(|config_root| { + let primary_dir = config_root.join(PRIMARY_CONFIG_DIR); + fs::create_dir_all(&primary_dir).unwrap(); + fs::write( + primary_dir.join("config.toml"), + "[ui]\nxdg_focus_loss_behavior = 'stay'\n", + ) + .unwrap(); + + let loaded = Config::load().expect("load succeeds"); + assert_eq!( + loaded.config.ui.xdg_focus_loss_behavior, + XdgFocusLossBehavior::Stay + ); + }); +} + +#[test] +fn ui_defaults_follow_desktop_for_xdg_focus_loss() { + let desktop_like_gnome = [ + "XDG_CURRENT_DESKTOP", + "XDG_SESSION_DESKTOP", + "DESKTOP_SESSION", + ] + .iter() + .filter_map(|key| std::env::var(key).ok()) + .any(|value| { + let value = value.to_lowercase(); + value.contains("ubuntu") || value.contains("gnome") + }); + let expected = if cfg!(target_os = "linux") && desktop_like_gnome { + XdgFocusLossBehavior::Stay + } else { + XdgFocusLossBehavior::Exit + }; + + assert_eq!(Config::default().ui.xdg_focus_loss_behavior, expected); +} diff --git a/src/config/types/ui.rs b/src/config/types/ui.rs index 051d6b8d..936fd67c 100644 --- a/src/config/types/ui.rs +++ b/src/config/types/ui.rs @@ -1,6 +1,7 @@ -use crate::config::enums::{RadialMenuMouseBinding, StatusPosition}; +use crate::config::enums::{RadialMenuMouseBinding, StatusPosition, XdgFocusLossBehavior}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use std::env; use super::{ ClickHighlightConfig, ContextMenuUiConfig, HelpOverlayStyle, StatusBarStyle, ToolbarConfig, @@ -76,6 +77,12 @@ pub struct UiConfig { #[serde(default = "default_xdg_fullscreen")] pub xdg_fullscreen: bool, + /// Behavior when the xdg-shell fallback overlay loses keyboard focus. + /// + /// `exit` preserves legacy behavior; `stay` keeps the overlay open. + #[serde(default = "default_xdg_focus_loss_behavior")] + pub xdg_focus_loss_behavior: XdgFocusLossBehavior, + /// Mouse button used to toggle the radial menu. #[serde(default = "default_radial_menu_mouse_binding")] pub radial_menu_mouse_binding: RadialMenuMouseBinding, @@ -110,6 +117,7 @@ impl Default for UiConfig { active_output_badge: default_active_output_badge(), command_palette_toast_duration_ms: default_command_palette_toast_duration_ms(), xdg_fullscreen: default_xdg_fullscreen(), + xdg_focus_loss_behavior: default_xdg_focus_loss_behavior(), radial_menu_mouse_binding: default_radial_menu_mouse_binding(), click_highlight: ClickHighlightConfig::default(), context_menu: ContextMenuUiConfig::default(), @@ -142,6 +150,31 @@ fn default_xdg_fullscreen() -> bool { false } +fn default_xdg_focus_loss_behavior() -> XdgFocusLossBehavior { + if use_gnome_fallback_defaults() { + XdgFocusLossBehavior::Stay + } else { + XdgFocusLossBehavior::Exit + } +} + +fn use_gnome_fallback_defaults() -> bool { + if !cfg!(target_os = "linux") { + return false; + } + [ + "XDG_CURRENT_DESKTOP", + "XDG_SESSION_DESKTOP", + "DESKTOP_SESSION", + ] + .iter() + .filter_map(|key| env::var(key).ok()) + .any(|value| { + let value = value.to_lowercase(); + value.contains("ubuntu") || value.contains("gnome") + }) +} + fn default_help_overlay_context_filter() -> bool { true } diff --git a/src/daemon/core.rs b/src/daemon/core.rs index 2350b8bb..c25b62bb 100644 --- a/src/daemon/core.rs +++ b/src/daemon/core.rs @@ -7,6 +7,7 @@ use std::fs::OpenOptions; use std::io::ErrorKind; use std::process::{Child, Command}; use std::sync::Arc; +use std::sync::Mutex; use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, Ordering}; use std::thread; use std::thread::JoinHandle; @@ -18,6 +19,7 @@ use crate::paths::daemon_lock_file; use crate::session::try_lock_exclusive; use crate::{RESUME_SESSION_ENV, decode_session_override, encode_session_override}; +use super::global_shortcuts::start_global_shortcuts_listener; use super::tray::start_system_tray; #[cfg(feature = "tray")] use super::types::TrayStatusShared; @@ -31,8 +33,11 @@ pub struct Daemon { pub(super) tray_enabled: bool, pub(super) backend_runner: Option>, pub(super) tray_thread: Option>, + pub(super) global_shortcuts_thread: Option>, pub(super) overlay_child: Option, pub(super) overlay_pid: Arc, + pub(super) pending_activation_token: Option, + pub(super) portal_activation_token_slot: Arc>>, pub(super) session_resume_override: Arc, pub(super) lock_file: Option, pub(super) overlay_spawn_failures: u32, @@ -59,8 +64,11 @@ impl Daemon { tray_enabled, backend_runner: None, tray_thread: None, + global_shortcuts_thread: None, overlay_child: None, overlay_pid: Arc::new(AtomicU32::new(0)), + pending_activation_token: None, + portal_activation_token_slot: Arc::new(Mutex::new(None)), session_resume_override: override_state, lock_file: None, overlay_spawn_failures: 0, @@ -85,8 +93,11 @@ impl Daemon { tray_enabled: true, backend_runner: Some(backend_runner), tray_thread: None, + global_shortcuts_thread: None, overlay_child: None, overlay_pid: Arc::new(AtomicU32::new(0)), + pending_activation_token: None, + portal_activation_token_slot: Arc::new(Mutex::new(None)), session_resume_override: override_state, lock_file: None, overlay_spawn_failures: 0, @@ -152,8 +163,12 @@ impl Daemon { /// Run daemon with signal handling pub fn run(&mut self) -> Result<()> { info!("Starting wayscriber daemon"); - info!("Send SIGUSR1 to toggle overlay (e.g., pkill -SIGUSR1 wayscriber)"); - info!("Configure Hyprland: bind = SUPER, D, exec, pkill -SIGUSR1 wayscriber"); + info!( + "Send SIGUSR1 to toggle overlay (e.g., kill -USR1 $(pgrep -fo 'wayscriber --daemon'))" + ); + info!( + "Configure Hyprland: bind = SUPER, D, exec, bash -lc \"kill -USR1 $(pgrep -fo 'wayscriber --daemon')\"" + ); self.acquire_daemon_lock()?; @@ -222,6 +237,15 @@ 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"); + } + info!("Daemon ready - waiting for toggle signal"); // Main daemon loop @@ -239,10 +263,21 @@ impl Daemon { // Check for toggle request // Use Acquire ordering to ensure we see all memory operations // that happened before the flag was set - if self.toggle_requested.swap(false, Ordering::Acquire) - && let Err(err) = self.toggle_overlay() - { - warn!("Toggle overlay failed: {}", err); + if self.toggle_requested.swap(false, Ordering::Acquire) { + let pending_token = self + .portal_activation_token_slot + .lock() + .unwrap_or_else(|poisoned| { + warn!("portal activation token mutex poisoned; recovering"); + poisoned.into_inner() + }) + .take(); + self.pending_activation_token = pending_token; + + if let Err(err) = self.toggle_overlay() { + self.pending_activation_token = None; + warn!("Toggle overlay failed: {}", err); + } } // Small sleep to avoid busy-waiting @@ -261,6 +296,12 @@ impl Daemon { Err(err) => warn!("System tray thread panicked: {:?}", err), } } + if let Some(handle) = self.global_shortcuts_thread.take() { + match handle.join() { + Ok(()) => info!("Global shortcuts listener thread joined"), + Err(err) => warn!("Global shortcuts listener thread panicked: {:?}", err), + } + } Ok(()) } } diff --git a/src/daemon/global_shortcuts.rs b/src/daemon/global_shortcuts.rs new file mode 100644 index 00000000..a63c9e49 --- /dev/null +++ b/src/daemon/global_shortcuts.rs @@ -0,0 +1,456 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +#[cfg(feature = "portal")] +use anyhow::{Context, Result, anyhow}; +#[cfg(feature = "portal")] +use futures::StreamExt; +#[cfg(feature = "portal")] +use log::{debug, info, warn}; +#[cfg(feature = "portal")] +use std::collections::HashMap; +#[cfg(feature = "portal")] +use std::time::{SystemTime, UNIX_EPOCH}; +#[cfg(feature = "portal")] +use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; +#[cfg(feature = "portal")] +use zbus::{Connection, proxy}; + +const DEFAULT_PREFERRED_TRIGGER: &str = "g"; +const DEFAULT_PORTAL_APP_ID: &str = "wayscriber"; + +#[cfg(feature = "portal")] +const TOGGLE_SHORTCUT_ID: &str = "toggle-overlay"; +#[cfg(feature = "portal")] +const TOGGLE_SHORTCUT_DESCRIPTION: &str = "Toggle Wayscriber overlay"; +#[cfg(feature = "portal")] +const PORTAL_REQUEST_POLL_INTERVAL_MS: u64 = 100; + +pub(super) fn start_global_shortcuts_listener( + toggle_flag: Arc, + quit_flag: Arc, + activation_token_slot: Arc>>, +) -> Option> { + #[cfg(feature = "portal")] + { + let listener_quit_flag = quit_flag.clone(); + let preferred_trigger = std::env::var("WAYSCRIBER_PORTAL_SHORTCUT") + .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") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_PORTAL_APP_ID.to_string()); + + Some(std::thread::spawn(move || { + let runtime = match tokio::runtime::Runtime::new() { + Ok(runtime) => runtime, + Err(err) => { + warn!( + "Global shortcuts portal listener disabled: failed to create Tokio runtime: {}", + err + ); + return; + } + }; + + runtime.block_on(async move { + if let Err(err) = run_listener( + toggle_flag, + quit_flag, + activation_token_slot, + preferred_trigger, + portal_app_id, + ) + .await + { + if listener_quit_flag.load(Ordering::Acquire) { + info!( + "Global shortcuts portal listener stopped during shutdown: {}", + err + ); + } else { + warn!("Global shortcuts portal listener disabled: {}", err); + } + } + }); + })) + } + #[cfg(not(feature = "portal"))] + { + let _ = (toggle_flag, quit_flag, activation_token_slot); + None + } +} + +#[cfg(feature = "portal")] +#[proxy( + interface = "org.freedesktop.portal.GlobalShortcuts", + default_service = "org.freedesktop.portal.Desktop", + default_path = "/org/freedesktop/portal/desktop" +)] +trait GlobalShortcuts { + async fn create_session( + &self, + options: HashMap>, + ) -> zbus::Result; + + async fn bind_shortcuts( + &self, + session_handle: zbus::zvariant::ObjectPath<'_>, + shortcuts: Vec<(String, HashMap>)>, + parent_window: &str, + options: HashMap>, + ) -> zbus::Result; + + #[zbus(signal)] + fn activated( + &self, + session_handle: zbus::zvariant::ObjectPath<'_>, + shortcut_id: &str, + timestamp: u64, + options: HashMap, + ) -> zbus::Result<()>; + + #[zbus(property)] + fn version(&self) -> zbus::Result; +} + +#[cfg(feature = "portal")] +#[proxy( + interface = "org.freedesktop.host.portal.Registry", + default_service = "org.freedesktop.portal.Desktop", + default_path = "/org/freedesktop/portal/desktop" +)] +trait HostPortalRegistry { + async fn register( + &self, + app_id: &str, + options: HashMap>, + ) -> zbus::Result<()>; +} + +#[cfg(feature = "portal")] +#[proxy( + interface = "org.freedesktop.portal.Request", + default_service = "org.freedesktop.portal.Desktop" +)] +trait Request { + #[zbus(signal)] + fn response(&self, response: u32, results: HashMap) -> zbus::Result<()>; +} + +#[cfg(feature = "portal")] +#[proxy( + interface = "org.freedesktop.portal.Session", + default_service = "org.freedesktop.portal.Desktop" +)] +trait Session { + async fn close(&self) -> zbus::Result<()>; +} + +#[cfg(feature = "portal")] +async fn run_listener( + toggle_flag: Arc, + quit_flag: Arc, + activation_token_slot: Arc>>, + preferred_trigger: String, + portal_app_id: String, +) -> Result<()> { + let connection = Connection::session() + .await + .context("failed to connect to session D-Bus")?; + register_host_portal_app_id(&connection, &portal_app_id).await?; + let proxy = GlobalShortcutsProxy::new(&connection) + .await + .context("org.freedesktop.portal.GlobalShortcuts unavailable")?; + match proxy.version().await { + Ok(version) => { + debug!("GlobalShortcuts portal interface version {}", version); + if version < 2 { + warn!( + "GlobalShortcuts portal version {} lacks reliable activation token support; overlay focus may require notification click", + version + ); + } + } + Err(err) => { + warn!( + "Failed to read GlobalShortcuts portal interface version: {}", + err + ); + } + } + + let session_handle = + create_global_shortcuts_session(&connection, &proxy, &portal_app_id, quit_flag.as_ref()) + .await?; + bind_toggle_shortcut( + &connection, + &proxy, + &session_handle, + &preferred_trigger, + quit_flag.as_ref(), + ) + .await?; + + info!( + "Global shortcuts portal listener ready (app_id '{}', shortcut id '{}', preferred trigger '{}')", + portal_app_id, TOGGLE_SHORTCUT_ID, preferred_trigger + ); + + let mut activated_stream = proxy + .receive_activated() + .await + .context("failed to subscribe to GlobalShortcuts.Activated")?; + + loop { + tokio::select! { + maybe_signal = activated_stream.next() => { + let Some(signal) = maybe_signal else { + return Err(anyhow!("GlobalShortcuts.Activated stream ended unexpectedly")); + }; + let args = signal.args().context("failed to parse Activated signal args")?; + if args.shortcut_id != TOGGLE_SHORTCUT_ID { + continue; + } + + let activation_token = extract_activation_token(&args.options); + if let Some(token) = activation_token { + info!("Global shortcut activated; activation_token received"); + let mut slot = lock_token_slot(&activation_token_slot); + *slot = Some(token); + } else { + let option_keys: Vec<&str> = + args.options.keys().map(|key| key.as_str()).collect(); + warn!( + "Global shortcut activated without activation_token; focus may require manual click (options keys: {:?})", + option_keys + ); + } + toggle_flag.store(true, Ordering::Release); + } + _ = tokio::time::sleep(tokio::time::Duration::from_millis(100)) => { + if quit_flag.load(Ordering::Acquire) { + break; + } + } + } + } + + if let Err(err) = close_global_shortcuts_session(&connection, &session_handle).await { + warn!("Failed to close GlobalShortcuts session cleanly: {}", err); + } + + Ok(()) +} + +#[cfg(feature = "portal")] +async fn register_host_portal_app_id(connection: &Connection, app_id: &str) -> Result<()> { + let registry = HostPortalRegistryProxy::new(connection) + .await + .context("org.freedesktop.host.portal.Registry unavailable")?; + registry + .register(app_id, HashMap::new()) + .await + .with_context(|| format!("host portal app-id registration failed for '{}'", app_id))?; + debug!("Registered host portal app-id '{}'", app_id); + Ok(()) +} + +#[cfg(feature = "portal")] +async fn create_global_shortcuts_session( + connection: &Connection, + proxy: &GlobalShortcutsProxy<'_>, + portal_app_id: &str, + quit_flag: &AtomicBool, +) -> Result { + let mut options: HashMap> = HashMap::new(); + options.insert( + "handle_token".to_string(), + Value::from(make_handle_token("wayscribergsreq")), + ); + options.insert( + "session_handle_token".to_string(), + Value::from(make_handle_token("wayscribergssess")), + ); + options.insert("app_id".to_string(), Value::from(portal_app_id.to_string())); + + let request_path = proxy + .create_session(options) + .await + .context("GlobalShortcuts.CreateSession call failed")?; + + let (response, results) = + wait_for_request_response(connection, request_path, quit_flag).await?; + if response != 0 { + return Err(anyhow!( + "GlobalShortcuts.CreateSession denied by portal (response code {})", + response + )); + } + + let session_handle_value = results + .get("session_handle") + .ok_or_else(|| anyhow!("CreateSession response missing session_handle"))?; + parse_object_path(session_handle_value) + .context("failed to parse session_handle from CreateSession response") +} + +#[cfg(feature = "portal")] +async fn bind_toggle_shortcut( + connection: &Connection, + proxy: &GlobalShortcutsProxy<'_>, + session_handle: &OwnedObjectPath, + preferred_trigger: &str, + quit_flag: &AtomicBool, +) -> Result<()> { + let mut shortcut_options: HashMap> = HashMap::new(); + shortcut_options.insert( + "description".to_string(), + Value::from(TOGGLE_SHORTCUT_DESCRIPTION.to_string()), + ); + shortcut_options.insert( + "preferred_trigger".to_string(), + Value::from(preferred_trigger.to_string()), + ); + + let shortcuts = vec![(TOGGLE_SHORTCUT_ID.to_string(), shortcut_options)]; + + let mut bind_options: HashMap> = HashMap::new(); + bind_options.insert( + "handle_token".to_string(), + Value::from(make_handle_token("wayscribergsbind")), + ); + + let session_path = zbus::zvariant::ObjectPath::try_from(session_handle.as_str()) + .map_err(|err| anyhow!("invalid GlobalShortcuts session path: {}", err))?; + + let request_path = proxy + .bind_shortcuts(session_path, shortcuts, "", bind_options) + .await + .context("GlobalShortcuts.BindShortcuts call failed")?; + + let (response, _) = wait_for_request_response(connection, request_path, quit_flag).await?; + if response != 0 { + return Err(anyhow!( + "GlobalShortcuts.BindShortcuts denied by portal (response code {})", + response + )); + } + + Ok(()) +} + +#[cfg(feature = "portal")] +async fn wait_for_request_response( + connection: &Connection, + request_path: OwnedObjectPath, + quit_flag: &AtomicBool, +) -> Result<(u32, HashMap)> { + let request_proxy = RequestProxy::builder(connection) + .path(request_path) + .context("invalid portal request path")? + .build() + .await + .context("failed to build Request proxy")?; + + let mut response_stream = request_proxy + .receive_response() + .await + .context("failed to subscribe to Request.Response")?; + loop { + tokio::select! { + maybe_signal = response_stream.next() => { + let response_signal = maybe_signal + .ok_or_else(|| anyhow!("portal request completed without Response signal"))?; + let args = response_signal + .args() + .context("failed to parse Request.Response signal arguments")?; + return Ok((args.response, args.results.clone())); + } + _ = tokio::time::sleep(tokio::time::Duration::from_millis(PORTAL_REQUEST_POLL_INTERVAL_MS)) => { + if quit_flag.load(Ordering::Acquire) { + return Err(anyhow!( + "shutdown requested while waiting for portal Request.Response" + )); + } + } + } + } +} + +#[cfg(feature = "portal")] +async fn close_global_shortcuts_session( + connection: &Connection, + session_handle: &OwnedObjectPath, +) -> Result<()> { + let proxy = SessionProxy::builder(connection) + .path(session_handle.clone()) + .context("invalid GlobalShortcuts session path")? + .build() + .await + .context("failed to build Session proxy")?; + proxy + .close() + .await + .context("GlobalShortcuts session close failed") +} + +#[cfg(feature = "portal")] +fn parse_object_path(value: &OwnedValue) -> Result { + let path: &str = value + .downcast_ref() + .map_err(|err| anyhow!("session_handle is not a string/object-path: {}", err))?; + OwnedObjectPath::try_from(path.to_string()) + .map_err(|err| anyhow!("invalid object path string '{}': {}", path, err)) +} + +#[cfg(feature = "portal")] +fn extract_activation_token(options: &HashMap) -> Option { + const TOKEN_KEYS: [&str; 5] = [ + "activation_token", + "activation-token", + "activationToken", + "startup_id", + "desktop-startup-id", + ]; + + for key in TOKEN_KEYS { + if let Some(token) = options.get(key).and_then(owned_value_as_string) { + return Some(token); + } + } + + options.iter().find_map(|(key, value)| { + if !key.contains("token") { + return None; + } + owned_value_as_string(value) + }) +} + +#[cfg(feature = "portal")] +fn owned_value_as_string(value: &OwnedValue) -> Option { + let token: &str = value.downcast_ref().ok()?; + Some(token.to_string()) +} + +#[cfg(feature = "portal")] +fn make_handle_token(prefix: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + format!("{}_{}_{}", prefix, std::process::id(), nanos) +} + +#[cfg(feature = "portal")] +fn lock_token_slot(slot: &Arc>>) -> std::sync::MutexGuard<'_, Option> { + slot.lock().unwrap_or_else(|poisoned| { + warn!("portal activation token slot mutex poisoned; recovering"); + poisoned.into_inner() + }) +} diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 43944934..d1d9fc3d 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -1,6 +1,7 @@ //! Daemon mode implementation: background service with toggle activation mod core; +mod global_shortcuts; mod icons; mod overlay; mod tray; diff --git a/src/daemon/overlay/spawn.rs b/src/daemon/overlay/spawn.rs index 4312a025..441cc96f 100644 --- a/src/daemon/overlay/spawn.rs +++ b/src/daemon/overlay/spawn.rs @@ -127,6 +127,16 @@ impl Daemon { fn build_overlay_command(&self, program: &OsStr) -> Command { let mut command = Command::new(program); command.arg("--active"); + // Overlay children launched by daemon are already backgrounded and tracked. + // Prevent `--active` from spawning another detached grandchild process. + command.env("WAYSCRIBER_NO_DETACH", "1"); + if let Some(token) = self.pending_activation_token.as_deref() { + command.env("XDG_ACTIVATION_TOKEN", token); + command.env("DESKTOP_STARTUP_ID", token); + } else { + command.env_remove("XDG_ACTIVATION_TOKEN"); + command.env_remove("DESKTOP_STARTUP_ID"); + } self.apply_session_override_env(&mut command); if let Some(mode) = &self.initial_mode { command.arg("--mode").arg(mode); @@ -146,6 +156,7 @@ impl Daemon { let mut failures = Vec::new(); for candidate in candidates { + let had_activation_token = self.pending_activation_token.is_some(); debug!( "Attempting overlay spawn via {} ({})", candidate.source, @@ -158,9 +169,10 @@ impl Daemon { self.overlay_pid.store(pid, Ordering::Release); self.overlay_child = Some(child); self.overlay_state = OverlayState::Visible; + self.pending_activation_token = None; info!( - "Overlay process started via {} (pid {pid})", - candidate.source + "Overlay process started via {} (pid {pid}, startup_activation_token={})", + candidate.source, had_activation_token ); return Ok(()); } @@ -175,6 +187,7 @@ impl Daemon { } } + self.pending_activation_token = None; warn!("Overlay spawn attempts failed: {}", failures.join("; ")); Err(anyhow!( "Unable to launch overlay process (tried current_exe/argv0/PATH)" diff --git a/src/main.rs b/src/main.rs index 77cea0f1..2f1e83e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod about_window; mod app; +mod app_id; mod backend; mod build_info; mod capture; diff --git a/src/paths/mod.rs b/src/paths/mod.rs index 9a22c775..7059b217 100644 --- a/src/paths/mod.rs +++ b/src/paths/mod.rs @@ -87,5 +87,10 @@ pub fn daemon_lock_file() -> PathBuf { runtime_root().join("wayscriber.lock") } +/// Location for the active-overlay single-instance lock. +pub fn overlay_lock_file() -> PathBuf { + runtime_root().join("wayscriber-overlay.lock") +} + #[cfg(test)] mod tests; diff --git a/tools/set-portal-shortcut.sh b/tools/set-portal-shortcut.sh new file mode 100755 index 00000000..19762d9a --- /dev/null +++ b/tools/set-portal-shortcut.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +SHORTCUT="${1:-g}" +APP_ID="${2:-wayscriber}" +DROP_IN_DIR="${HOME}/.config/systemd/user/wayscriber.service.d" +DROP_IN_FILE="${DROP_IN_DIR}/shortcut.conf" + +ESCAPED_SHORTCUT="${SHORTCUT//\"/\\\"}" +ESCAPED_APP_ID="${APP_ID//\"/\\\"}" + +mkdir -p "${DROP_IN_DIR}" + +cat > "${DROP_IN_FILE}" < Date: Thu, 19 Feb 2026 09:22:32 +0100 Subject: [PATCH 2/2] docs: add daemon shortcut setup for GNOME KDE and WMs --- README.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7f9f79b7..41558240 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ Press F1 or F10 for help; Shift+F1 for quick re **3. Daemon mode (preferred for daily use, optional)** Daemon mode is faster and keeps session state between toggles. -For setup steps, see [Usage](#daemon-mode-preferred). +For setup steps, see [Usage](#daemon-mode-preferred). Ubuntu GNOME users: Super+G is a good default because Super+D is often reserved. Alternative (one-shot mode): ```bash @@ -359,7 +359,9 @@ Run wayscriber in the background and toggle with a keybind: systemctl --user enable --now wayscriber.service ``` -Add keybinding (Hyprland): +Add keybinding: + +Hyprland: ```conf bind = SUPER, D, exec, pkill -SIGUSR1 wayscriber ``` @@ -369,6 +371,23 @@ Reload your config: hyprctl reload ``` +GNOME (Ubuntu/Debian/Fedora): +1. Open `Settings -> Keyboard -> Keyboard Shortcuts`. +2. Scroll down to `Custom Shortcuts`, click `+`. +3. Name: `Wayscriber Toggle`. +4. Command: `pkill -SIGUSR1 wayscriber`. +5. Set a shortcut key (recommended on Ubuntu GNOME: Super+G; Super+D is often already in use). + +KDE Plasma: +1. Open `Settings -> Keyboard -> Shortcuts`. +2. Add new `Command or Script`. +3. Name: `Wayscriber Toggle`. +4. Command: `pkill -SIGUSR1 wayscriber`. +5. Assign a key (for example Meta+Shift+D). + +Other desktops/window managers: +- Bind `pkill -SIGUSR1 wayscriber` to any global shortcut key you prefer. + Use `--no-tray` or `WAYSCRIBER_NO_TRAY=1` if you don't have a system tray; otherwise right-click the tray icon for options: - Toggle overlay visibility - Freeze/unfreeze the current overlay