1818
1919# This file is heavily inspired by https://github.com/juienpro/easyland/blob/efc26a0b22d7bdbb0f8436183428f7036da4662a/src/easyland/idle.py
2020
21+ from dataclasses import dataclass
22+ import logging
2123import threading
22- import datetime
2324import os
2425import select
26+ import typing
2527
2628from pywayland .client import Display
2729from pywayland .protocol .wayland .wl_seat import WlSeat
28- from pywayland .protocol .ext_idle_notify_v1 import ExtIdleNotifierV1
30+ from pywayland .protocol .ext_idle_notify_v1 import (
31+ ExtIdleNotifierV1 ,
32+ ExtIdleNotificationV1 ,
33+ )
2934
35+ from .interface import IdleMonitorInterface
36+ from safeeyes import utility
3037
31- class ExtIdleNotify :
32- _idle_notifier = None
33- _seat = None
34- _notification = None
35- _notifier_set = False
36- _running = True
37- _thread = None
38- _r_channel = None
39- _w_channel = None
4038
41- _idle_since = None
39+ @dataclass
40+ class IdleConfig :
41+ on_idle : typing .Callable [[], None ]
42+ on_resumed : typing .Callable [[], None ]
43+ idle_time : float
4244
43- def __init__ (self ):
45+
46+ class IdleMonitorExtIdleNotify (IdleMonitorInterface ):
47+ _ext_idle_notify_internal : typing .Optional ["ExtIdleNotifyInternal" ] = None
48+ _thread : typing .Optional [threading .Thread ] = None
49+
50+ _r_channel_started : int
51+ _w_channel_started : int
52+
53+ _r_channel_stop : int
54+ _w_channel_stop : int
55+
56+ _r_channel_listen : int
57+ _w_channel_listen : int
58+
59+ _idle_config : typing .Optional [IdleConfig ] = None
60+
61+ def init (self ) -> None :
62+ # we spawn one wayland client once
63+ # when the monitor is not running, it should be quite idle
64+ self ._r_channel_started , self ._w_channel_started = os .pipe ()
65+ self ._r_channel_stop , self ._w_channel_stop = os .pipe ()
66+ self ._r_channel_listen , self ._w_channel_listen = os .pipe ()
67+ os .set_blocking (self ._r_channel_listen , False )
68+
69+ self ._thread = threading .Thread (
70+ target = self ._run , name = "ExtIdleNotify" , daemon = False
71+ )
72+ self ._thread .start ()
73+
74+ result = os .read (self ._r_channel_started , 1 )
75+
76+ if result == b"0" :
77+ self ._thread .join ()
78+ self ._thread = None
79+ raise Exception ("ext-idle-notify-v1 not supported" )
80+
81+ def start_monitor (
82+ self ,
83+ on_idle : typing .Callable [[], None ],
84+ on_resumed : typing .Callable [[], None ],
85+ idle_time : float ,
86+ ) -> None :
87+ self ._idle_config = IdleConfig (
88+ on_idle = on_idle ,
89+ on_resumed = on_resumed ,
90+ idle_time = idle_time ,
91+ )
92+
93+ # 1 means start listening, or that the configuration changed
94+ os .write (self ._w_channel_listen , b"1" )
95+
96+ def configuration_changed (
97+ self ,
98+ on_idle : typing .Callable [[], None ],
99+ on_resumed : typing .Callable [[], None ],
100+ idle_time : float ,
101+ ) -> None :
102+ self ._idle_config = IdleConfig (
103+ on_idle = on_idle ,
104+ on_resumed = on_resumed ,
105+ idle_time = idle_time ,
106+ )
107+
108+ # 1 means start listening, or that the configuration changed
109+ os .write (self ._w_channel_listen , b"1" )
110+
111+ def is_monitor_running (self ) -> bool :
112+ return self ._idle_config is not None
113+
114+ def _run (self ) -> None :
44115 # Note that this creates a new connection to the wayland compositor.
45116 # This is not an issue per se, but does mean that the compositor sees this as
46117 # a new, separate client, that just happens to run in the same process as
@@ -54,80 +125,196 @@ def __init__(self):
54125 # https://lists.freedesktop.org/archives/wayland-devel/2019-March/040344.html
55126 # The best thing would be, of course, for gtk to gain native support for
56127 # ext-idle-notify-v1.
57- self ._display = Display ()
58- self ._display .connect ()
59- self ._r_channel , self ._w_channel = os .pipe ()
128+ with Display () as display :
129+ self ._ext_idle_notify_internal = ExtIdleNotifyInternal (
130+ display ,
131+ self ._r_channel_stop ,
132+ self ._w_channel_started ,
133+ self ._r_channel_listen ,
134+ self ._on_idle ,
135+ self ._on_resumed ,
136+ self ._get_idle_time ,
137+ )
138+ self ._ext_idle_notify_internal .run ()
139+ self ._ext_idle_notify_internal = None
140+
141+ def _on_idle (self ) -> None :
142+ if self ._idle_config is not None :
143+ self ._idle_config .on_idle ()
144+
145+ def _on_resumed (self ) -> None :
146+ if self ._idle_config is not None :
147+ self ._idle_config .on_resumed ()
60148
61- def stop (self ):
62- self ._running = False
149+ def _get_idle_time (self ) -> typing .Optional [float ]:
150+ if self ._idle_config is not None :
151+ return self ._idle_config .idle_time
152+ else :
153+ return None
154+
155+ def stop_monitor (self ) -> None :
156+ # 0 means to stop listening
157+ # It's not an issue to write to the channel if we're not listening anymore
158+ # already
159+ os .write (self ._w_channel_listen , b"0" )
160+
161+ self ._idle_config = None
162+
163+ def stop (self ) -> None :
63164 # write anything, just to wake up the channel
64- os .write (self ._w_channel , b"!" )
65- self ._notification .destroy ()
66- self ._notification = None
67- self ._seat = None
68- self ._thread .join ()
69- os .close (self ._r_channel )
70- os .close (self ._w_channel )
165+ if self ._thread is not None :
166+ os .write (self ._w_channel_stop , b"!" )
167+ self ._thread .join ()
168+ self ._thread = None
169+ os .close (self ._r_channel_stop )
170+ os .close (self ._w_channel_stop )
71171
72- def run (self ):
73- self ._thread = threading .Thread (
74- target = self ._run , name = "ExtIdleNotify" , daemon = False
75- )
76- self ._thread .start ()
172+ os .close (self ._r_channel_started )
173+ os .close (self ._w_channel_started )
77174
78- def _run (self ):
175+ os .close (self ._r_channel_listen )
176+ os .close (self ._w_channel_listen )
177+
178+
179+ class ExtIdleNotifyInternal :
180+ """This runs in the thread, and is only alive while the display exists.
181+
182+ Split out into a separate object to simplify lifetime handling.
183+ """
184+
185+ _idle_notifier : typing .Optional [ExtIdleNotifierV1 ] = None
186+ _notification : typing .Optional [ExtIdleNotificationV1 ] = None
187+ _display : Display
188+ _r_channel_stop : int
189+ _w_channel_started : int
190+ _r_channel_listen : int
191+ _seat : typing .Optional [WlSeat ] = None
192+
193+ _on_idle : typing .Callable [[], None ]
194+ _on_resumed : typing .Callable [[], None ]
195+ _get_idle_time : typing .Callable [[], typing .Optional [float ]]
196+
197+ def __init__ (
198+ self ,
199+ display : Display ,
200+ r_channel_stop : int ,
201+ w_channel_started : int ,
202+ r_channel_listen : int ,
203+ on_idle : typing .Callable [[], None ],
204+ on_resumed : typing .Callable [[], None ],
205+ get_idle_time : typing .Callable [[], typing .Optional [float ]],
206+ ) -> None :
207+ self ._display = display
208+ self ._r_channel_stop = r_channel_stop
209+ self ._w_channel_started = w_channel_started
210+ self ._r_channel_listen = r_channel_listen
211+ self ._on_idle = on_idle
212+ self ._on_resumed = on_resumed
213+ self ._get_idle_time = get_idle_time
214+
215+ def run (self ) -> None :
216+ """Run the wayland client.
217+
218+ This will block until it's stopped by the channel.
219+ When this stops, self should no longer be used.
220+ """
79221 reg = self ._display .get_registry ()
80222 reg .dispatcher ["global" ] = self ._global_handler
81223
224+ self ._display .roundtrip ()
225+
226+ while self ._seat is None :
227+ self ._display .dispatch (block = True )
228+
229+ if self ._idle_notifier is None :
230+ self ._seat = None
231+
232+ self ._display .roundtrip ()
233+
234+ # communicate to the outer thread that the compositor does not
235+ # implement the ext-idle-notify-v1 protocol
236+ os .write (self ._w_channel_started , b"0" )
237+
238+ return
239+
240+ os .write (self ._w_channel_started , b"1" )
241+
82242 display_fd = self ._display .get_fd ()
83243
84- while self . _running :
244+ while True :
85245 self ._display .flush ()
86246
87247 # this blocks until either there are new events in self._display
88248 # (retrieved using dispatch())
89- # or until there are events in self._r_channel - which means that stop()
90- # was called
249+ # or until there are events in self._r_channel_stop - which means that
250+ # stop() was called
91251 # unfortunately, this seems like the best way to make sure that dispatch
92252 # doesn't block potentially forever (up to multiple seconds in my usage)
93- read , _w , _x = select .select ((display_fd , self ._r_channel ), (), ())
253+ read , _w , _x = select .select (
254+ (display_fd , self ._r_channel_stop , self ._r_channel_listen ), (), ()
255+ )
94256
95- if self ._r_channel in read :
257+ if self ._r_channel_listen in read :
258+ # r_channel_listen is nonblocking
259+ # if there is nothing to read here, result should just be b""
260+ result = os .read (self ._r_channel_listen , 1 )
261+ if result == b"1" :
262+ self ._listen ()
263+ elif result == b"0" :
264+ if self ._notification is not None :
265+ self ._notification .destroy () # type: ignore[attr-defined]
266+ self ._notification = None
267+
268+ if self ._r_channel_stop in read :
96269 # the channel was written to, which means stop() was called
97- # at this point, self._running should be false as well
98270 break
99271
100272 if display_fd in read :
101273 self ._display .dispatch (block = True )
102274
103- self ._display .disconnect ()
275+ self ._display .roundtrip ()
276+
277+ if self ._notification is not None :
278+ self ._notification .destroy () # type: ignore[attr-defined]
279+ self ._notification = None
280+
281+ self ._display .roundtrip ()
282+
283+ self ._seat = None
284+ self ._idle_notifier = None
285+
286+ def _listen (self ):
287+ """Create a new idle notification listener.
104288
105- def _global_handler (self , reg , id_num , iface_name , version ):
289+ If one already exists, throw it away and recreate it with the new
290+ idle time.
291+ """
292+ # note that the typing doesn't work correctly here - it always says that
293+ # get_idle_notification is not defined
294+ # so just don't check this method
295+ if self ._notification is not None :
296+ self ._notification .destroy ()
297+ self ._notification = None
298+
299+ timeout_sec = self ._get_idle_time ()
300+ if timeout_sec is None :
301+ logging .debug (
302+ "this should not happen. _listen() was called but idle time was not set"
303+ )
304+ self ._notification = self ._idle_notifier .get_idle_notification (
305+ int (timeout_sec * 1000 ), self ._seat
306+ )
307+ self ._notification .dispatcher ["idled" ] = self ._idle_notifier_handler
308+ self ._notification .dispatcher ["resumed" ] = self ._idle_notifier_resume_handler
309+
310+ def _global_handler (self , reg , id_num , iface_name , version ) -> None :
106311 if iface_name == "wl_seat" :
107312 self ._seat = reg .bind (id_num , WlSeat , version )
108313 if iface_name == "ext_idle_notifier_v1" :
109314 self ._idle_notifier = reg .bind (id_num , ExtIdleNotifierV1 , version )
110315
111- if self ._idle_notifier and self ._seat and not self ._notifier_set :
112- self ._notifier_set = True
113- timeout_sec = 1
114- self ._notification = self ._idle_notifier .get_idle_notification (
115- timeout_sec * 1000 , self ._seat
116- )
117- self ._notification .dispatcher ["idled" ] = self ._idle_notifier_handler
118- self ._notification .dispatcher ["resumed" ] = (
119- self ._idle_notifier_resume_handler
120- )
121-
122- def _idle_notifier_handler (self , notification ):
123- self ._idle_since = datetime .datetime .now ()
124-
125- def _idle_notifier_resume_handler (self , notification ):
126- self ._idle_since = None
127-
128- def get_idle_time_seconds (self ):
129- if self ._idle_since is None :
130- return 0
316+ def _idle_notifier_handler (self , notification ) -> None :
317+ utility .execute_main_thread (self ._on_idle )
131318
132- result = datetime . datetime . now ( ) - self . _idle_since
133- return result . total_seconds ( )
319+ def _idle_notifier_resume_handler ( self , notification ) -> None :
320+ utility . execute_main_thread ( self . _on_resumed )
0 commit comments