comparison libervia/frontends/tools/portal_desktop.py @ 4240:79c8a70e1813

backend, frontend: prepare remote control: This is a series of changes necessary to prepare the implementation of remote control feature: - XEP-0166: add a `priority` attribute to `ApplicationData`: this is needed when several applications are working in a same session, to know which one must be handled first. Will be used to make Remote Control have precedence over Call content. - XEP-0166: `_call_plugins` is now async and is not used with `DeferredList` anymore: the benefit to have methods called in parallels is very low, and it cause a lot of trouble as we can't predict order. Methods are now called sequentially so workflow can be predicted. - XEP-0167: fix `senders` XMPP attribute <=> SDP mapping - XEP-0234: preflight acceptance key is now `pre-accepted` instead of `file-accepted`, so the same key can be used with other jingle applications. - XEP-0167, XEP-0343: move some method to XEP-0167 - XEP-0353: use new `priority` feature to call preflight methods of applications according to it. - frontend (webrtc): refactor the sources/sink handling with a more flexible mechanism based on Pydantic models. It is now possible to have has many Data Channel as necessary, to have them in addition to A/V streams, to specify manually GStreamer sources and sinks, etc. - frontend (webrtc): rework of the pipeline to reduce latency. - frontend: new `portal_desktop` method. Screenshare portal handling has been moved there, and RemoteDesktop portal has been added. - frontend (webrtc): fix `extract_ufrag_pwd` method. rel 436
author Goffi <goffi@goffi.org>
date Sat, 11 May 2024 13:52:41 +0200
parents libervia/frontends/tools/webrtc_screenshare.py@d01b8d002619
children 0d7bb4df2343
comparison
equal deleted inserted replaced
4239:a38559e6d6e2 4240:79c8a70e1813
1 #!/usr/bin/env python3
2
3 # Libervia freedesktop portal management module
4 # Copyright (C) 2009-2024 Jérôme Poisson (goffi@goffi.org)
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19 from typing import Callable, Literal, overload
20 from libervia.backend.core import exceptions
21
22 import asyncio
23 import logging
24 from random import randint
25 import dbus
26 from dbus.mainloop.glib import DBusGMainLoop
27
28
29 log = logging.getLogger(__name__)
30
31
32 class PortalError(Exception):
33 pass
34
35
36 class DesktopPortal:
37
38 def __init__(self, on_session_closed_cb: Callable | None = None):
39 # we want monitors + windows, see https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.ScreenCast.html#org-freedesktop-portal-screencast-availablesourcetypes
40 self.dbus = dbus
41 self.on_session_closed_cb = on_session_closed_cb
42 self.sources_type = dbus.UInt32(7)
43 DBusGMainLoop(set_as_default=True)
44 self.session_bus = dbus.SessionBus()
45 portal_object = self.session_bus.get_object(
46 "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop"
47 )
48 self.screencast_interface = dbus.Interface(
49 portal_object, "org.freedesktop.portal.ScreenCast"
50 )
51 self.remote_desktop_interface = dbus.Interface(
52 portal_object, "org.freedesktop.portal.RemoteDesktop"
53 )
54 self.session_interface = None
55 self.session_signal = None
56 self.handle_counter = 0
57 self.session_handle = None
58
59 @property
60 def handle_token(self):
61 self.handle_counter += 1
62 return f"libervia{self.handle_counter}"
63
64 def on_session_closed(self, details: dict) -> None:
65 if self.session_interface is not None:
66 self.session_interface = None
67 if self.on_session_closed_cb is not None:
68 self.on_session_closed_cb()
69 if self.session_signal is not None:
70 self.session_signal.remove()
71 self.session_signal = None
72
73 @overload
74 async def dbus_call(
75 self,
76 interface: dbus.Interface,
77 method_name: str,
78 response: Literal[False],
79 **kwargs,
80 ) -> None:
81 ...
82
83 @overload
84 async def dbus_call(
85 self,
86 interface: dbus.Interface,
87 method_name: str,
88 response: Literal[True],
89 **kwargs,
90 ) -> dict:
91 ...
92
93
94 async def dbus_call(
95 self,
96 interface: dbus.Interface,
97 method_name: str,
98 response: bool,
99 **kwargs,
100 ) -> dict|None:
101 """Call a portal method
102
103 This method handle the signal response.
104 @param method_name: method to call
105 @param response: True if the method expect a response.
106 If True, the method will await responde from
107 ``org.freedesktop.portal.Request``'s ``Response`` signal.
108 @param kwargs: method args.
109 ``handle_token`` will be automatically added to ``options`` dict.
110 @return: method result
111 """
112 method = getattr(interface, method_name)
113 try:
114 options = kwargs["options"]
115 except KeyError:
116 raise exceptions.InternalError('"options" key must be present.')
117 reply_fut = asyncio.Future()
118 signal_fut = asyncio.Future()
119 # cf. https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html
120 handle_token = self.handle_token
121 sender = self.session_bus.get_unique_name().replace(".", "_")[1:]
122 path = f"/org/freedesktop/portal/desktop/request/{sender}/{handle_token}"
123 signal_match = None
124
125 def on_signal(response, results):
126 print(f"on_signal responde {response=}")
127 assert signal_match is not None
128 signal_match.remove()
129 if response == 0:
130 signal_fut.set_result(results)
131 elif response == 1:
132 signal_fut.set_exception(exceptions.CancelError("Cancelled by user."))
133 else:
134 signal_fut.set_exception(PortalError("Can't get signal result"))
135
136 if response:
137 signal_match = self.session_bus.add_signal_receiver(
138 on_signal,
139 signal_name="Response",
140 dbus_interface="org.freedesktop.portal.Request",
141 path=path,
142 )
143
144 options["handle_token"] = handle_token
145
146 method(
147 *kwargs.values(),
148 reply_handler=lambda ret=None: reply_fut.set_result(ret),
149 error_handler=reply_fut.set_exception,
150 )
151 try:
152 await reply_fut
153 except Exception as e:
154 raise PortalError(f"Can't ask portal permission: {e}")
155 if response:
156 return await signal_fut
157
158 async def create_session(
159 self,
160 interface: dbus.Interface,
161 ) -> dict:
162 """Create a new session and store its handle.
163
164 This method creates a new session using the freedesktop portal's CreateSession
165 dbus call. It then registers the session handle in this object for further use.
166
167 @param None
168 @return: A dictionary containing the session data, including the session handle.
169 @raise PortalError: If there is an error getting the session handle.
170 """
171 if self.session_handle is not None:
172 self.end_session()
173 session_data = await self.dbus_call(
174 interface,
175 "CreateSession",
176 response=True,
177 options={
178 "session_handle_token": str(randint(1, 2**32)),
179 },
180 )
181 try:
182 session_handle = session_data["session_handle"]
183 except KeyError:
184 raise PortalError("Can't get session handle")
185 self.session_handle = session_handle
186 return session_data
187
188 def parse_streams(self, session_handle, screenshare_data: dict) -> dict:
189 """Fill and returns stream_data from screenshare_data"""
190 try:
191 node_id, shared_stream_data = screenshare_data["streams"][0]
192 source_type = int(shared_stream_data["source_type"])
193 except (IndexError, KeyError):
194 raise exceptions.NotFound("No stream data found.")
195 stream_data = {
196 "session_handle": session_handle,
197 "node_id": node_id,
198 "source_type": source_type,
199 }
200 try:
201 height = int(shared_stream_data["size"][0])
202 weight = int(shared_stream_data["size"][1])
203 except (IndexError, KeyError):
204 pass
205 else:
206 stream_data["size"] = (height, weight)
207 return stream_data
208
209 async def request_screenshare(self) -> dict:
210 await self.create_session(self.screencast_interface)
211 session_handle = self.session_handle
212
213 await self.dbus_call(
214 self.screencast_interface,
215 "SelectSources",
216 response=True,
217 session_handle=session_handle,
218 options={"multiple": True, "types": self.sources_type},
219 )
220 screenshare_data = await self.dbus_call(
221 self.screencast_interface,
222 "Start",
223 response=True,
224 session_handle=session_handle,
225 parent_window="",
226 options={},
227 )
228
229 session_object = self.session_bus.get_object(
230 "org.freedesktop.portal.Desktop", session_handle
231 )
232 self.session_interface = self.dbus.Interface(
233 session_object, "org.freedesktop.portal.Session"
234 )
235
236 self.session_signal = self.session_bus.add_signal_receiver(
237 self.on_session_closed,
238 signal_name="Closed",
239 dbus_interface="org.freedesktop.portal.Session",
240 path=session_handle,
241 )
242
243 try:
244 return self.parse_streams(session_handle, screenshare_data)
245 except exceptions.NotFound:
246 raise PortalError("Can't parse stream data")
247
248 async def request_remote_desktop(self, with_screen_sharing: bool = True) -> dict:
249 """Request autorisation to remote control desktop.
250
251 @param with_screen_sharing: True if screen must be shared.
252 """
253 await self.create_session(self.remote_desktop_interface)
254 session_handle = self.session_handle
255
256 if with_screen_sharing:
257 await self.dbus_call(
258 self.screencast_interface,
259 "SelectSources",
260 response=False,
261 session_handle=session_handle,
262 options={
263 "multiple": True,
264 "types": self.sources_type,
265 # hidden cursor (should be the default, but cursor appears during
266 # tests))
267 "cursor_mode": dbus.UInt32(1)
268 },
269 )
270
271 await self.dbus_call(
272 self.remote_desktop_interface,
273 "SelectDevices",
274 response=False,
275 session_handle=session_handle,
276 options={
277 "types": dbus.UInt32(3),
278 # "persist_mode": dbus.UInt32(1),
279 },
280 )
281
282 remote_desktop_data = await self.dbus_call(
283 self.remote_desktop_interface,
284 "Start",
285 response=True,
286 session_handle=session_handle,
287 parent_window="",
288 options={},
289 )
290 try:
291 stream_data = self.parse_streams(session_handle, remote_desktop_data)
292 except exceptions.NotFound:
293 pass
294 else:
295 remote_desktop_data["stream_data"] = stream_data
296
297 session_object = self.session_bus.get_object(
298 "org.freedesktop.portal.Desktop", session_handle
299 )
300 self.session_interface = self.dbus.Interface(
301 session_object, "org.freedesktop.portal.Session"
302 )
303
304 self.session_signal = self.session_bus.add_signal_receiver(
305 self.on_session_closed,
306 signal_name="Closed",
307 dbus_interface="org.freedesktop.portal.Session",
308 path=session_handle,
309 )
310
311 return remote_desktop_data
312
313 def end_session(self) -> None:
314 """Close a running screenshare session, if any."""
315 if self.session_interface is None:
316 return
317 self.session_interface.Close()
318 self.on_session_closed({})
319
320 async def notify_pointer_motion(self, dx: int, dy: int) -> None:
321 """
322 Notify about a new relative pointer motion event.
323
324 @param dx: Relative movement on the x axis
325 @param dy: Relative movement on the y axis
326 """
327 await self.dbus_call(
328 self.remote_desktop_interface,
329 "NotifyPointerMotion",
330 response=False,
331 session_handle=self.session_handle,
332 options={},
333 dx=dx,
334 dy=dy,
335 )
336
337 async def notify_pointer_motion_absolute(
338 self, stream: int, x: float, y: float
339 ) -> None:
340 """
341 Notify about a new absolute pointer motion event.
342
343 @param stream: The PipeWire stream node the coordinate is relative to
344 @param x: Pointer motion x coordinate
345 @param y: Pointer motion y coordinate
346 """
347 await self.dbus_call(
348 self.remote_desktop_interface,
349 "NotifyPointerMotionAbsolute",
350 response=False,
351 session_handle=self.session_handle,
352 options={},
353 stream=stream,
354 x=x,
355 y=y,
356 )
357
358 async def notify_pointer_button(self, button: int, state: int) -> None:
359 """
360 Notify about a new pointer button event.
361
362 @param button: The pointer button was pressed or released
363 @param state: The new state of the button
364 """
365 await self.dbus_call(
366 self.remote_desktop_interface,
367 "NotifyPointerButton",
368 response=False,
369 session_handle=self.session_handle,
370 options={},
371 button=button,
372 state=state,
373 )
374
375 async def notify_pointer_axis(self, dx: float, dy: float) -> None:
376 """
377 Notify about a new pointer axis event.
378
379 @param dx: Relative axis movement on the x axis
380 @param dy: Relative axis movement on the y axis
381 """
382 await self.dbus_call(
383 self.remote_desktop_interface,
384 "NotifyPointerAxis",
385 response=False,
386 session_handle=self.session_handle,
387 options={},
388 dx=dx,
389 dy=dy,
390 )
391
392 async def notify_pointer_axis_discrete(self, axis: int, steps: int) -> None:
393 """
394 Notify about a new pointer axis discrete event.
395
396 @param axis: The axis that was scrolled
397 0 for vertical
398 1 for horizontal
399 @param steps: The number of steps scrolled
400 """
401 await self.dbus_call(
402 self.remote_desktop_interface,
403 "NotifyPointerAxisDiscrete",
404 response=False,
405 session_handle=self.session_handle,
406 options={},
407 axis=axis,
408 steps=steps,
409 )
410
411 async def notify_keyboard_keycode(self, keycode: int, state: int) -> None:
412 """
413 Notify about a new keyboard keycode event.
414
415 @param keycode: Keyboard code that was pressed or released
416 @param state: New state of keyboard keycode
417 """
418 await self.dbus_call(
419 self.remote_desktop_interface,
420 "NotifyKeyboardKeycode",
421 response=False,
422 session_handle=self.session_handle,
423 options={},
424 keycode=keycode,
425 state=state,
426 )
427
428 async def notify_keyboard_keysym(self, keysym: int, state: int) -> None:
429 """
430 Notify about a new keyboard keysym event.
431
432 @param keysym: Keyboard symbol that was pressed or released
433 @param state: New state of keyboard keysym
434 """
435 await self.dbus_call(
436 self.remote_desktop_interface,
437 "NotifyKeyboardKeysym",
438 response=False,
439 session_handle=self.session_handle,
440 options={},
441 keysym=keysym,
442 state=state,
443 )
444
445 async def notify_touch_down(self, stream: int, slot: int, x: int, y: int) -> None:
446 """
447 Notify about a new touch down event.
448
449 @param stream: The PipeWire stream node the coordinate is relative to
450 @param slot: Touch slot where touch point appeared
451 @param x: Touch down x coordinate
452 @param y: Touch down y coordinate
453 """
454 await self.dbus_call(
455 self.remote_desktop_interface,
456 "NotifyTouchDown",
457 response=False,
458 session_handle=self.session_handle,
459 options={},
460 stream=stream,
461 slot=slot,
462 x=x,
463 y=y,
464 )
465
466 async def notify_touch_motion(self, stream: int, slot: int, x: int, y: int) -> None:
467 """
468 Notify about a new touch motion event.
469
470 @param stream: The PipeWire stream node the coordinate is relative to
471 @param slot: Touch slot where touch point appeared
472 @param x: Touch motion x coordinate
473 @param y: Touch motion y coordinate
474 """
475 await self.dbus_call(
476 self.remote_desktop_interface,
477 "NotifyTouchMotion",
478 response=False,
479 session_handle=self.session_handle,
480 options={},
481 stream=stream,
482 slot=slot,
483 x=x,
484 y=y,
485 )
486
487 async def notify_touch_up(self, slot: int) -> None:
488 """
489 Notify about a new touch up event.
490
491 @param slot: Touch slot where touch point appeared
492 """
493 await self.dbus_call(
494 self.remote_desktop_interface,
495 "NotifyTouchUp",
496 response=False,
497 session_handle=self.session_handle,
498 options={},
499 slot=slot,
500 )