@@ -10,7 +10,7 @@ use std::sync::Mutex;
1010use std:: sync:: atomic:: { AtomicBool , AtomicU8 , AtomicU32 , Ordering } ;
1111use std:: thread;
1212use std:: thread:: JoinHandle ;
13- use std:: time:: Duration ;
13+ use std:: time:: { Duration , Instant } ;
1414
1515#[ cfg( test) ]
1616use crate :: SESSION_OVERRIDE_FOLLOW_CONFIG ;
@@ -32,6 +32,12 @@ use super::tray::start_system_tray;
3232use super :: types:: TrayStatusShared ;
3333use super :: types:: { AlreadyRunningError , BackendRunner , OverlayState } ;
3434
35+ // Some desktop custom shortcut runners, observed on KDE, can launch the same
36+ // plain `--daemon-toggle` command twice about 400-600ms apart from one key press.
37+ // Suppress only duplicate plain toggles after a successful toggle completes, so
38+ // typed requests still run.
39+ const DUPLICATE_SHORTCUT_SUPPRESSION_WINDOW : Duration = Duration :: from_millis ( 700 ) ;
40+
3541pub struct Daemon {
3642 pub ( super ) overlay_state : OverlayState ,
3743 pub ( super ) should_quit : Arc < AtomicBool > ,
@@ -56,6 +62,7 @@ pub struct Daemon {
5662 pub ( super ) overlay_spawn_failures : u32 ,
5763 pub ( super ) overlay_spawn_next_retry : Option < std:: time:: Instant > ,
5864 pub ( super ) overlay_spawn_backoff_logged : bool ,
65+ pub ( super ) last_plain_visibility_toggle_completed_at : Option < Instant > ,
5966 #[ cfg( feature = "tray" ) ]
6067 pub ( super ) tray_status : Arc < TrayStatusShared > ,
6168}
@@ -94,6 +101,7 @@ impl Daemon {
94101 overlay_spawn_failures : 0 ,
95102 overlay_spawn_next_retry : None ,
96103 overlay_spawn_backoff_logged : false ,
104+ last_plain_visibility_toggle_completed_at : None ,
97105 #[ cfg( feature = "tray" ) ]
98106 tray_status : Arc :: new ( TrayStatusShared :: new ( ) ) ,
99107 }
@@ -129,6 +137,7 @@ impl Daemon {
129137 overlay_spawn_failures : 0 ,
130138 overlay_spawn_next_retry : None ,
131139 overlay_spawn_backoff_logged : false ,
140+ last_plain_visibility_toggle_completed_at : None ,
132141 #[ cfg( feature = "tray" ) ]
133142 tray_status : Arc :: new ( TrayStatusShared :: new ( ) ) ,
134143 }
@@ -189,6 +198,20 @@ impl Daemon {
189198 } )
190199 . transpose ( ) ?;
191200 self . ensure_visible_overlay_can_accept_request ( request. as_ref ( ) ) ?;
201+ let plain_visibility_toggle_requested =
202+ request. as_ref ( ) . is_none_or ( DaemonToggleRequest :: is_empty) ;
203+ if plain_visibility_toggle_requested {
204+ let now = Instant :: now ( ) ;
205+ if self
206+ . last_plain_visibility_toggle_completed_at
207+ . is_some_and ( |previous| {
208+ now. saturating_duration_since ( previous) < DUPLICATE_SHORTCUT_SUPPRESSION_WINDOW
209+ } )
210+ {
211+ info ! ( "Ignoring duplicate plain daemon visibility toggle" ) ;
212+ return Ok ( false ) ;
213+ }
214+ }
192215 if let Some ( action) = request. as_ref ( ) . and_then ( |request| request. overlay_action ) {
193216 self . pending_activation_token = activation_token;
194217 self . pending_toggle_request = request. filter ( |request| !request. is_empty ( ) ) ;
@@ -218,6 +241,9 @@ impl Daemon {
218241 self . pending_toggle_request = None ;
219242 return Err ( err) ;
220243 }
244+ if plain_visibility_toggle_requested {
245+ self . last_plain_visibility_toggle_completed_at = Some ( Instant :: now ( ) ) ;
246+ }
221247 Ok ( false )
222248 }
223249
@@ -682,4 +708,140 @@ mod tests {
682708 assert_eq ! ( called. load( AtomicOrdering :: SeqCst ) , 0 ) ;
683709 assert_eq ! ( daemon. test_state( ) , OverlayState :: Hidden ) ;
684710 }
711+
712+ #[ test]
713+ fn duplicate_plain_toggle_requests_are_debounced ( ) {
714+ let called = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
715+ let called_clone = Arc :: clone ( & called) ;
716+ let runner: Arc < BackendRunner > = Arc :: new ( move |_| {
717+ called_clone. fetch_add ( 1 , AtomicOrdering :: SeqCst ) ;
718+ Ok ( ( ) )
719+ } ) ;
720+ let mut daemon = Daemon :: with_backend_runner ( None , runner) ;
721+
722+ daemon
723+ . process_single_toggle ( Some ( DaemonToggleRequest :: default ( ) ) , None , false )
724+ . unwrap ( ) ;
725+ daemon
726+ . process_single_toggle ( Some ( DaemonToggleRequest :: default ( ) ) , None , false )
727+ . unwrap ( ) ;
728+
729+ assert_eq ! ( called. load( AtomicOrdering :: SeqCst ) , 1 ) ;
730+ assert_eq ! ( daemon. test_state( ) , OverlayState :: Hidden ) ;
731+ }
732+
733+ #[ test]
734+ fn typed_visibility_toggle_request_is_not_debounced ( ) {
735+ let modes = Arc :: new ( std:: sync:: Mutex :: new ( Vec :: new ( ) ) ) ;
736+ let modes_clone = Arc :: clone ( & modes) ;
737+ let runner: Arc < BackendRunner > = Arc :: new ( move |mode| {
738+ modes_clone
739+ . lock ( )
740+ . unwrap_or_else ( |poisoned| poisoned. into_inner ( ) )
741+ . push ( mode) ;
742+ Ok ( ( ) )
743+ } ) ;
744+ let mut daemon = Daemon :: with_backend_runner ( None , runner) ;
745+
746+ daemon
747+ . process_single_toggle ( Some ( DaemonToggleRequest :: default ( ) ) , None , false )
748+ . unwrap ( ) ;
749+ daemon
750+ . process_single_toggle (
751+ Some ( DaemonToggleRequest {
752+ mode : Some ( "whiteboard" . to_string ( ) ) ,
753+ ..Default :: default ( )
754+ } ) ,
755+ None ,
756+ false ,
757+ )
758+ . unwrap ( ) ;
759+
760+ let modes = modes
761+ . lock ( )
762+ . unwrap_or_else ( |poisoned| poisoned. into_inner ( ) ) ;
763+ assert_eq ! ( modes. as_slice( ) , & [ None , Some ( "whiteboard" . to_string( ) ) ] ) ;
764+ assert_eq ! ( daemon. test_state( ) , OverlayState :: Hidden ) ;
765+ }
766+
767+ #[ cfg( unix) ]
768+ #[ test]
769+ fn duplicate_plain_toggle_after_slow_hide_is_debounced ( ) {
770+ let mut daemon = Daemon :: new ( None , false , None , None ) ;
771+ let child = std:: process:: Command :: new ( "sleep" )
772+ . arg ( "10" )
773+ . spawn ( )
774+ . expect ( "spawn slow-terminating test process" ) ;
775+ let child_pid = child. id ( ) ;
776+ assert_eq ! ( unsafe { libc:: kill( child_pid as i32 , libc:: SIGSTOP ) } , 0 ) ;
777+ let mut stopped = false ;
778+ for _ in 0 ..20 {
779+ let mut status = 0 ;
780+ let result = unsafe {
781+ libc:: waitpid (
782+ child_pid as i32 ,
783+ & mut status,
784+ libc:: WNOHANG | libc:: WUNTRACED ,
785+ )
786+ } ;
787+ if result == child_pid as i32 && libc:: WIFSTOPPED ( status) {
788+ stopped = true ;
789+ break ;
790+ }
791+ thread:: sleep ( Duration :: from_millis ( 10 ) ) ;
792+ }
793+ assert ! ( stopped, "test child should stop before hide starts" ) ;
794+ daemon
795+ . overlay_pid
796+ . store ( child. id ( ) , std:: sync:: atomic:: Ordering :: Release ) ;
797+ daemon. overlay_child = Some ( child) ;
798+ daemon. overlay_state = OverlayState :: Visible ;
799+
800+ let hide_started = Instant :: now ( ) ;
801+ daemon
802+ . process_single_toggle ( Some ( DaemonToggleRequest :: default ( ) ) , None , false )
803+ . unwrap ( ) ;
804+ assert ! (
805+ hide_started. elapsed( ) >= DUPLICATE_SHORTCUT_SUPPRESSION_WINDOW ,
806+ "test setup should keep hide slow enough to cross the debounce window"
807+ ) ;
808+ assert_eq ! ( daemon. test_state( ) , OverlayState :: Hidden ) ;
809+
810+ let called = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
811+ let called_clone = Arc :: clone ( & called) ;
812+ daemon. backend_runner = Some ( Arc :: new ( move |_| {
813+ called_clone. fetch_add ( 1 , AtomicOrdering :: SeqCst ) ;
814+ Ok ( ( ) )
815+ } ) ) ;
816+
817+ daemon
818+ . process_single_toggle ( Some ( DaemonToggleRequest :: default ( ) ) , None , false )
819+ . unwrap ( ) ;
820+
821+ assert_eq ! ( called. load( AtomicOrdering :: SeqCst ) , 0 ) ;
822+ assert_eq ! ( daemon. test_state( ) , OverlayState :: Hidden ) ;
823+ }
824+
825+ #[ test]
826+ fn plain_toggle_after_debounce_window_is_processed ( ) {
827+ let called = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
828+ let called_clone = Arc :: clone ( & called) ;
829+ let runner: Arc < BackendRunner > = Arc :: new ( move |_| {
830+ called_clone. fetch_add ( 1 , AtomicOrdering :: SeqCst ) ;
831+ Ok ( ( ) )
832+ } ) ;
833+ let mut daemon = Daemon :: with_backend_runner ( None , runner) ;
834+
835+ daemon
836+ . process_single_toggle ( Some ( DaemonToggleRequest :: default ( ) ) , None , false )
837+ . unwrap ( ) ;
838+ daemon. last_plain_visibility_toggle_completed_at =
839+ Some ( Instant :: now ( ) - DUPLICATE_SHORTCUT_SUPPRESSION_WINDOW - Duration :: from_millis ( 1 ) ) ;
840+ daemon
841+ . process_single_toggle ( Some ( DaemonToggleRequest :: default ( ) ) , None , false )
842+ . unwrap ( ) ;
843+
844+ assert_eq ! ( called. load( AtomicOrdering :: SeqCst ) , 2 ) ;
845+ assert_eq ! ( daemon. test_state( ) , OverlayState :: Hidden ) ;
846+ }
685847}
0 commit comments