comparison libervia/frontends/tools/webrtc_remote_control.py @ 4242:8acf46ed7f36

frontends: remote control implementation: This is the frontends common part of remote control implementation. It handle the creation of WebRTC session, and management of inputs. For now the reception use freedesktop.org Desktop portal, and works mostly with Wayland based Desktop Environments. rel 436
author Goffi <goffi@goffi.org>
date Sat, 11 May 2024 13:52:43 +0200
parents
children 0d7bb4df2343
comparison
equal deleted inserted replaced
4241:898db6daf0d0 4242:8acf46ed7f36
1 #!/usr/bin/env python3
2
3 # Libervia Remote-Control via WebRTC implementation.
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
20 import asyncio
21 from functools import partial
22 import logging
23 from typing import Any, Awaitable, Callable
24
25 import gi
26 from gi.overrides.Gst import GLib
27 from gi.repository import GstWebRTC
28
29 import cbor2
30 from libervia.backend.core.i18n import _
31 from libervia.backend.tools.common import data_format
32 from libervia.frontends.tools import aio, jid, webrtc
33 from libervia.frontends.tools.webrtc_models import (
34 CallData,
35 SourcesNone,
36 SourcesPipeline,
37 )
38
39 gi.require_versions({"Gst": "1.0", "GstWebRTC": "1.0"})
40 OnOpenCbType = Callable[["WebRTCRemoteController"], None|Awaitable[None]]
41 MOUSE_BTN_LEFT = 0x110
42 MOUSE_BTN_RIGHT = 0x111
43 MOUSE_BTN_MIDDLE = 0x112
44 MOUSE_BTN_FORWARD = 0x115
45 MOUSE_BTN_BACK = 0x116
46
47
48 log = logging.getLogger(__name__)
49
50
51 class WebRTCRemoteController:
52
53 def __init__(
54 self,
55 bridge,
56 profile: str,
57 on_call_start_cb: Callable[[dict], Any] | None = None,
58 end_call_cb: Callable[[], Any] | None = None,
59 ) -> None:
60 """Initializes the File Sender.
61
62 @param bridge: An async Bridge instance.
63 @param profile: The profile name to be used.
64 @param on_call_start_cb: A blocking or async callable that accepts a dict as its
65 only argument.
66 @param end_call_cb: A callable to be invoked at the end of a call.
67 """
68
69 self.bridge = bridge
70 self.profile = profile
71 self.on_call_start_cb = on_call_start_cb
72 self.end_call_cb = end_call_cb
73 self.loop = asyncio.get_event_loop()
74 self.data_channel: GstWebRTC.WebRTCDataChannel|None = None
75
76 def send_input(self, input_data: dict) -> None:
77 """Send an input data to controlled device
78
79 @param input_data: data of the input event.
80 """
81 assert self.data_channel is not None
82 self.data_channel.send_data(GLib.Bytes(cbor2.dumps(input_data)))
83
84 async def _on_webrtc_call_start(
85 self,
86 options: dict,
87 callee: str,
88 call_data: dict,
89 profile: str,
90 ) -> str:
91 rc_data = {
92 "webrtc": True,
93 "call_data": call_data,
94 }
95 rc_data.update(options)
96 remote_control_data_s = await self.bridge.remote_control_start(
97 str(callee),
98 data_format.serialise(
99 rc_data
100 ),
101 profile,
102 )
103 remote_control_data = data_format.deserialise(remote_control_data_s)
104
105 if self.on_call_start_cb is not None:
106 await aio.maybe_async(self.on_call_start_cb(remote_control_data))
107 return remote_control_data["session_id"]
108
109 def _on_dc_close(self, webrtc_call, data_channel: GstWebRTC.WebRTCDataChannel):
110 if webrtc_call is not None:
111 aio.run_from_thread(self._end_call_and_quit, webrtc_call, loop=self.loop)
112
113 async def _end_call_and_quit(self, webrtc_call):
114 await webrtc_call.webrtc.end_call()
115 if self.end_call_cb is not None:
116 await aio.maybe_async(self.end_call_cb())
117
118 def _on_dc_open(
119 self, on_open_cb: OnOpenCbType, data_channel: GstWebRTC.WebRTCDataChannel
120 ) -> None:
121 """Called when datachannel is open"""
122 self.data_channel = data_channel
123 aio.run_from_thread(self.on_dc_opened, on_open_cb, loop=self.loop)
124
125 async def on_dc_opened(self, on_open_cb: OnOpenCbType) -> None:
126 await aio.maybe_async(on_open_cb(self))
127
128 async def start(
129 self,
130 callee: jid.JID,
131 options: dict,
132 on_open_cb: OnOpenCbType
133 ) -> None:
134 """Start a remote control session with ``callee``
135
136 @param callee: The JID of the recipient to send the file to.
137 @param options: Options such as which devices to control.
138 @param on_open_cb: Method to call when the Data Channel is open.
139 The WebRTCRemoteController instance used as argument can then be used to send
140 input events to received.
141 """
142 call_data = CallData(callee=callee)
143 self.webrtc_call = await webrtc.WebRTCCall.make_webrtc_call(
144 self.bridge,
145 self.profile,
146 call_data,
147 sources_data=webrtc.SourcesDataChannel(
148 dc_open_cb=partial(self._on_dc_open, on_open_cb),
149 ),
150 call_start_cb=partial(self._on_webrtc_call_start, options),
151 )
152
153
154 class WebRTCRemoteControlReceiver:
155
156 def __init__(
157 self, bridge, profile: str, on_close_cb: Callable[[], Any] | None = None,
158 verbose: bool = False
159 ) -> None:
160 """Initializes the File Receiver.
161
162 @param bridge: An async Bridge instance.
163 @param profile: The profile name to be used.
164 @param on_close_cb: Called when the Data Channel is closed.
165 @param verbose: if True, print input events.
166 """
167 self.bridge = bridge
168 self.profile = profile
169 self.on_close_cb = on_close_cb
170 self.loop = asyncio.get_event_loop()
171 self.desktop_portal = None
172 self.remote_desktop_data: dict|None = None
173 self.stream_node_id: int|None = None
174 self.verbose = verbose
175
176 async def do_input(self, data: dict) -> None:
177 assert self.desktop_portal is not None
178 try:
179 type_ = data["type"]
180 if type_.startswith("mouse"):
181 try:
182 try:
183 x, y = data["x"], data["y"]
184 except KeyError:
185 dx, dy = data["movementX"], data["movementY"]
186 await self.desktop_portal.notify_pointer_motion(
187 dx, dy
188 )
189 else:
190 assert self.stream_node_id is not None
191 await self.desktop_portal.notify_pointer_motion_absolute(
192 self.stream_node_id, x, y
193 )
194 except Exception:
195 log.exception("Can't send input")
196
197 if type_ in ("mouseup", "mousedown"):
198 buttons = data["buttons"]
199 state = 1 if type_ == "mousedown" else 0
200 # see https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons#value
201 if buttons & 1:
202 await self.desktop_portal.notify_pointer_button(
203 MOUSE_BTN_LEFT,
204 state
205 )
206 if buttons & 2:
207 await self.desktop_portal.notify_pointer_button(
208 MOUSE_BTN_RIGHT,
209 state
210 )
211 if buttons & 4:
212 await self.desktop_portal.notify_pointer_button(
213 MOUSE_BTN_MIDDLE,
214 state
215 )
216 if buttons & 8:
217 await self.desktop_portal.notify_pointer_button(
218 MOUSE_BTN_BACK,
219 state
220 )
221 if buttons & 16:
222 await self.desktop_portal.notify_pointer_button(
223 MOUSE_BTN_FORWARD,
224 state
225 )
226 elif type_ == "wheel":
227 dx = data.get("deltaX", 0)
228 dy = data.get("deltaY", 0)
229 delta_mode = data["deltaMode"]
230 if delta_mode == 0:
231 # deltas are in pixels
232 await self.desktop_portal.notify_pointer_axis(
233 dx,
234 dy
235 )
236 else:
237 # deltas are in steps (see
238 # https://developer.mozilla.org/en-US/docs/Web/API/Element/wheel_event#event_properties)
239 if dx:
240 await self.desktop_portal.notify_pointer_axis(
241 1,
242 dx
243 )
244 if dy:
245 await self.desktop_portal.notify_pointer_axis(
246 0,
247 dy
248 )
249 elif type_.startswith("key"):
250 # FIXME: this is a really naive implementation, it needs tot be improved.
251 key = data["key"]
252 if data.get("shiftKey", False):
253 key = key.upper()
254 await self.desktop_portal.notify_keyboard_keysym(
255 ord(key), 1 if type_ == "keydown" else 0
256 )
257
258 except Exception:
259 log.exception(f"Can't handle input {data}")
260
261
262 def _on_dc_message_data(self, data_channel, glib_data) -> None:
263 """A data chunk of the file has been received."""
264 raw = glib_data.get_data()
265 data = cbor2.loads(raw)
266 if self.verbose:
267 print(data)
268 aio.run_from_thread(
269 self.do_input,
270 data,
271 loop=self.loop
272 )
273
274 def _on_dc_close(self, data_channel) -> None:
275 """Data channel is closed
276
277 The file download should be complete, we close it.
278 """
279 aio.run_from_thread(self._on_close, loop=self.loop)
280
281 async def _on_close(self) -> None:
282 if self.on_close_cb is not None:
283 await aio.maybe_async(self.on_close_cb())
284
285 def _on_data_channel(self, webrtcbin, data_channel) -> None:
286 """The data channel has been opened."""
287 data_channel.connect(
288 "on-message-data", self._on_dc_message_data
289 )
290 data_channel.connect("on-close", self._on_dc_close)
291
292 async def request_remote_desktop(self, with_screen_sharing: bool) -> None:
293 """Request autorisation to remote control desktop.
294
295 @param with_screen_sharing: True if screen must be shared.
296 """
297 from .portal_desktop import DesktopPortal
298 self.desktop_portal = DesktopPortal()
299 self.remote_desktop_data = await self.desktop_portal.request_remote_desktop(
300 with_screen_sharing
301 )
302 print(self.remote_desktop_data)
303
304 async def start_receiving(
305 self,
306 from_jid: jid.JID,
307 session_id: str,
308 screenshare: dict
309 ) -> None:
310 """Receives a file via WebRTC and saves it to the specified path.
311
312 @param from_jid: The JID of the entity sending the file.
313 @param session_id: The Jingle FT Session ID.
314 @param file_path: The local path where the received file will be saved.
315 If a file already exists at this path, it will be overwritten.
316 @param file_data: Additional data about the file being transferred.
317 """
318 call_data = CallData(callee=from_jid, sid=session_id)
319 if "video" in screenshare and self.remote_desktop_data:
320 try:
321 stream_data = self.remote_desktop_data["stream_data"]
322 log.debug(f"{stream_data=}")
323 self.stream_node_id = stream_data["node_id"]
324
325 sources_data = SourcesPipeline(
326 video_pipeline="pipewiresrc",
327 audio_pipeline="",
328 video_properties={
329 "path": str(self.stream_node_id),
330 "do-timestamp": 1,
331 }
332 )
333 except KeyError:
334 sources_data = SourcesNone()
335 else:
336 sources_data = SourcesNone()
337
338 await webrtc.WebRTCCall.make_webrtc_call(
339 self.bridge,
340 self.profile,
341 call_data,
342 sources_data = sources_data,
343 sinks_data=webrtc.SinksNone(),
344 dc_data_list=[webrtc.SinksDataChannel(
345 dc_on_data_channel=self._on_data_channel,
346 )],
347 )