Mercurial > libervia-backend
comparison libervia/backend/plugins/plugin_misc_remote_control.py @ 4241:898db6daf0d0
core: Jingle Remote Control implementation:
This is an implementation of the protoXEP that will be submitted to XSF. It handle
establishment of a remote control session, and the management of A/V calls with XEP-0167.
rel 436
author | Goffi <goffi@goffi.org> |
---|---|
date | Sat, 11 May 2024 13:52:43 +0200 |
parents | |
children | 0d7bb4df2343 |
comparison
equal
deleted
inserted
replaced
4240:79c8a70e1813 | 4241:898db6daf0d0 |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 # Libervia: an XMPP client | |
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 twisted.internet import defer | |
20 from twisted.words.protocols.jabber import jid | |
21 from twisted.words.protocols.jabber.xmlstream import XMPPHandler | |
22 from twisted.words.xish import domish | |
23 from wokkel import disco, iwokkel | |
24 from zope.interface import implementer | |
25 | |
26 from libervia.backend.core import exceptions | |
27 from libervia.backend.core.constants import Const as C | |
28 from libervia.backend.core.core_types import SatXMPPEntity | |
29 from libervia.backend.core.i18n import D_, _ | |
30 from libervia.backend.core.log import getLogger | |
31 from libervia.backend.tools import xml_tools | |
32 from libervia.backend.tools.common import data_format | |
33 | |
34 from .plugin_xep_0166 import BaseApplicationHandler | |
35 | |
36 log = getLogger(__name__) | |
37 | |
38 NS_REMOTE_CONTROL = "urn:xmpp:jingle:apps:remote-control:0" | |
39 | |
40 PLUGIN_INFO = { | |
41 C.PI_NAME: "Jingle Remove Control", | |
42 C.PI_IMPORT_NAME: "RemoteControl", | |
43 C.PI_TYPE: C.PLUG_TYPE_MISC, | |
44 C.PI_PROTOCOLS: [], | |
45 C.PI_DEPENDENCIES: ["XEP-0166", "XEP-0167"], | |
46 C.PI_MAIN: "RemoteControl", | |
47 C.PI_HANDLER: "yes", | |
48 C.PI_DESCRIPTION: _("""Remote control devices with Jingle."""), | |
49 } | |
50 | |
51 | |
52 | |
53 class RemoteControl(BaseApplicationHandler): | |
54 | |
55 def __init__(self, host): | |
56 log.info(f'Plugin "{PLUGIN_INFO[C.PI_NAME]}" initialization') | |
57 self.host = host | |
58 # FIXME: to be removed once host is accessible from global var | |
59 self._j = host.plugins["XEP-0166"] | |
60 # We need higher priority than XEP-0167 application, as we have a RemoteControl | |
61 # session and not a call one when this application is used. | |
62 self._j.register_application(NS_REMOTE_CONTROL, self, priority=1000) | |
63 self._rtp = host.plugins["XEP-0167"] | |
64 host.register_namespace("remote-control", NS_REMOTE_CONTROL) | |
65 host.bridge.add_method( | |
66 "remote_control_start", | |
67 ".plugin", | |
68 in_sign="sss", | |
69 out_sign="s", | |
70 method=self._remote_control_start, | |
71 async_=True, | |
72 ) | |
73 | |
74 def get_handler(self, client): | |
75 return RemoteControl_handler() | |
76 | |
77 # bridge methods | |
78 | |
79 def _remote_control_start( | |
80 self, | |
81 peer_jid_s: str, | |
82 extra_s: str, | |
83 profile: str, | |
84 ) -> defer.Deferred[str]: | |
85 client = self.host.get_client(profile) | |
86 extra = data_format.deserialise(extra_s) | |
87 d = defer.ensureDeferred(self.remote_control_start( | |
88 client, | |
89 jid.JID(peer_jid_s), | |
90 extra, | |
91 )) | |
92 d.addCallback(data_format.serialise) | |
93 return d | |
94 | |
95 async def remote_control_start( | |
96 self, | |
97 client: SatXMPPEntity, | |
98 peer_jid: jid.JID, | |
99 extra: dict | |
100 ) -> dict: | |
101 """Start a remote control session. | |
102 | |
103 @param peer_jid: destinee jid | |
104 @return: progress id | |
105 """ | |
106 if not extra: | |
107 raise exceptions.DataError( | |
108 '"extra" must be set.' | |
109 ) | |
110 # webrtc is always used for remote control | |
111 extra["webrtc"] = True | |
112 content = { | |
113 "app_ns": NS_REMOTE_CONTROL, | |
114 # XXX: for now only unidirectional device exist, but future extensions mays be | |
115 # bidirectional, and which case "senders" would be set to "both" | |
116 "senders": self._j.ROLE_INITIATOR, | |
117 "app_kwargs": { | |
118 "extra": extra, | |
119 }, | |
120 } | |
121 try: | |
122 call_data = content["app_kwargs"]["extra"]["call_data"] | |
123 except KeyError: | |
124 raise exceptions.DataError('"call_data" must be set in "extra".') | |
125 | |
126 metadata = self._rtp.parse_call_data(call_data) | |
127 try: | |
128 application_data = call_data["application"] | |
129 except KeyError: | |
130 raise exceptions.DataError( | |
131 '"call_data" must have an application media.' | |
132 ) | |
133 try: | |
134 content["transport_data"] = { | |
135 "sctp-port": metadata["sctp-port"], | |
136 "max-message-size": metadata.get("max-message-size", 65536), | |
137 "local_ice_data": { | |
138 "ufrag": metadata["ice-ufrag"], | |
139 "pwd": metadata["ice-pwd"], | |
140 "candidates": application_data.pop("ice-candidates"), | |
141 "fingerprint": application_data.pop("fingerprint", {}), | |
142 } | |
143 } | |
144 name = application_data.get("id") | |
145 if name: | |
146 content["name"] = name | |
147 except KeyError as e: | |
148 raise exceptions.DataError(f"Mandatory key is missing: {e}") | |
149 contents = [content] | |
150 contents.extend(self._rtp.get_contents(call_data, metadata)) | |
151 session_id = await self._j.initiate( | |
152 client, | |
153 peer_jid, | |
154 contents, | |
155 call_type=C.META_SUBTYPE_CALL_REMOTE_CONTROL, | |
156 metadata=metadata, | |
157 peer_metadata={}, | |
158 ) | |
159 return {"session_id": session_id} | |
160 | |
161 # jingle callbacks | |
162 | |
163 def _get_confirm_msg( | |
164 self, | |
165 client: SatXMPPEntity, | |
166 peer_jid: jid.JID, | |
167 ) -> tuple[bool, str, str]: | |
168 """Get confirmation message to display to user. | |
169 | |
170 This is the message to show when a remote-control request is received.""" | |
171 if client.roster and peer_jid.userhostJID() not in client.roster: | |
172 is_in_roster = False | |
173 confirm_msg = D_( | |
174 "Somebody not in your contact list ({peer_jid}) wants to control " | |
175 "remotely this device. Accepting this will give full control of your " | |
176 " device to this person, and leak your presence and probably your IP " | |
177 "address. Do not accept if you don't trust this person!\n" | |
178 "Do you accept?" | |
179 ).format(peer_jid=peer_jid) | |
180 confirm_title = D_("Remote Control Request From an Unknown Contact") | |
181 else: | |
182 is_in_roster = True | |
183 confirm_msg = D_( | |
184 "{peer_jid} wants to control your device. Accepting will give full " | |
185 "control of your device, like if they were in front of your computer. " | |
186 "Only accept if you absolute trust this person.\n" | |
187 "Do you accept?" | |
188 ).format(peer_jid=peer_jid) | |
189 confirm_title = D_("Remote Control Request.") | |
190 | |
191 return (is_in_roster, confirm_msg, confirm_title) | |
192 | |
193 async def jingle_preflight( | |
194 self, client: SatXMPPEntity, session: dict, description_elt: domish.Element | |
195 ) -> None: | |
196 """Perform preflight checks for an incoming call session. | |
197 | |
198 Check if the calls is audio only or audio/video, then, prompts the user for | |
199 confirmation. | |
200 | |
201 @param client: The client instance. | |
202 @param session: Jingle session. | |
203 @param description_elt: The description element. It's parent attribute is used to | |
204 determine check siblings to see if it's an audio only or audio/video call. | |
205 | |
206 @raises exceptions.CancelError: If the user doesn't accept the incoming call. | |
207 """ | |
208 session_id = session["id"] | |
209 peer_jid = session["peer_jid"] | |
210 | |
211 is_in_roster, confirm_msg, confirm_title = self._get_confirm_msg( | |
212 client, peer_jid | |
213 ) | |
214 if is_in_roster: | |
215 action_type = C.META_TYPE_CONFIRM | |
216 else: | |
217 action_type = C.META_TYPE_NOT_IN_ROSTER_LEAK | |
218 | |
219 action_extra = { | |
220 "type": action_type, | |
221 "session_id": session_id, | |
222 "from_jid": peer_jid.full(), | |
223 } | |
224 action_extra["subtype"] = C.META_TYPE_REMOTE_CONTROL | |
225 accepted = await xml_tools.defer_confirm( | |
226 self.host, | |
227 confirm_msg, | |
228 confirm_title, | |
229 profile=client.profile, | |
230 action_extra=action_extra | |
231 ) | |
232 if accepted: | |
233 session["pre_accepted"] = True | |
234 return accepted | |
235 | |
236 async def jingle_preflight_info( | |
237 self, | |
238 client: SatXMPPEntity, | |
239 session: dict, | |
240 info_type: str, | |
241 info_data: dict | None = None, | |
242 ) -> None: | |
243 pass | |
244 | |
245 async def jingle_preflight_cancel( | |
246 self, client: SatXMPPEntity, session: dict, cancel_error: exceptions.CancelError | |
247 ) -> None: | |
248 """The remote control has been rejected""" | |
249 | |
250 def jingle_session_init( | |
251 self, | |
252 client: SatXMPPEntity, | |
253 session: dict, | |
254 content_name: str, | |
255 extra: dict | |
256 ) -> domish.Element: | |
257 """Initializes a jingle session. | |
258 | |
259 @param client: The client instance. | |
260 @param session: Jingle session. | |
261 @param content_name: Name of the content. | |
262 @param extra: Extra data. | |
263 @return: <description> element. | |
264 """ | |
265 desc_elt = domish.Element((NS_REMOTE_CONTROL, "description")) | |
266 devices = extra.get("devices") or {} | |
267 for name, data in devices.items(): | |
268 device_elt = desc_elt.addElement((NS_REMOTE_CONTROL, "device")) | |
269 device_elt["type"] = name | |
270 return desc_elt | |
271 | |
272 async def jingle_request_confirmation( | |
273 self, | |
274 client: SatXMPPEntity, | |
275 action: str, | |
276 session: dict, | |
277 content_name: str, | |
278 desc_elt: domish.Element, | |
279 ) -> bool: | |
280 """Requests confirmation from the user for a Jingle session's incoming call. | |
281 | |
282 This method checks the content type of the Jingle session (audio or video) | |
283 based on the session's contents. Confirmation is requested only for the first | |
284 content; subsequent contents are automatically accepted. This means, in practice, | |
285 that the call confirmation is prompted only once for both audio and video | |
286 contents. | |
287 | |
288 @param client: The client instance. | |
289 @param action: The action type associated with the Jingle session. | |
290 @param session: Jingle session. | |
291 @param content_name: Name of the content being checked. | |
292 @param desc_elt: The description element associated with the content. | |
293 | |
294 @return: True if the call is accepted by the user, False otherwise. | |
295 """ | |
296 content_data = session["contents"][content_name] | |
297 role = session["role"] | |
298 | |
299 if role == self._j.ROLE_INITIATOR: | |
300 raise NotImplementedError | |
301 return True | |
302 elif role == self._j.ROLE_RESPONDER: | |
303 # We are the controlled entity. | |
304 return await self._remote_control_request_conf( | |
305 client, session, content_data, content_name | |
306 ) | |
307 else: | |
308 raise exceptions.InternalError( | |
309 f"Invalid role {role!r}" | |
310 ) | |
311 | |
312 async def _remote_control_request_conf( | |
313 self, | |
314 client: SatXMPPEntity, | |
315 session: dict, | |
316 content_data: dict, | |
317 content_name: str, | |
318 ) -> bool: | |
319 """Handle user permission.""" | |
320 peer_jid = session["peer_jid"] | |
321 pre_accepted = session.get("pre_accepted", False) | |
322 __, confirm_msg, confirm_title = self._get_confirm_msg(client, peer_jid) | |
323 contents = session["contents"] | |
324 action_extra = { | |
325 "pre_accepted": pre_accepted, | |
326 "type": C.META_TYPE_REMOTE_CONTROL, | |
327 "devices": content_data["application_data"]["devices"], | |
328 "session_id": session["id"], | |
329 "from_jid": peer_jid.full(), | |
330 } | |
331 for name, content in contents.items(): | |
332 if name == content_name: | |
333 continue | |
334 if content["application"].namespace == self._rtp.namespace: | |
335 media = content["application_data"]["media"] | |
336 action_extra.setdefault("screenshare", {})[media] = {} | |
337 | |
338 return await xml_tools.defer_confirm( | |
339 self.host, | |
340 confirm_msg, | |
341 confirm_title, | |
342 profile=client.profile, | |
343 action_extra=action_extra | |
344 ) | |
345 | |
346 async def jingle_handler(self, client, action, session, content_name, desc_elt): | |
347 content_data = session["contents"][content_name] | |
348 application_data = content_data["application_data"] | |
349 if action == self._j.A_PREPARE_CONFIRMATION: | |
350 devices = application_data["devices"] = {} | |
351 for device_elt in desc_elt.elements(NS_REMOTE_CONTROL, "device"): | |
352 try: | |
353 device_type = device_elt.attributes["type"] | |
354 except KeyError: | |
355 log.warning(f"Invalide device element: {device_elt.toXml()}") | |
356 else: | |
357 # The dict holds data for current type of devices. For now it is unused | |
358 # has the spec doesn't define any device data, but it may be used by | |
359 # future extensions. | |
360 devices[device_type] = {} | |
361 elif action == self._j.A_SESSION_INITIATE: | |
362 # FIXME: for now we automatically accept keyboard, mouse and wheel and nothing | |
363 # else. Must actually reflect user choices and local devices. | |
364 to_remove = [] | |
365 for device_elt in desc_elt.elements(NS_REMOTE_CONTROL, "device"): | |
366 if device_elt.getAttribute("type") not in ("keyboard", "wheel", "mouse"): | |
367 to_remove.append(device_elt) | |
368 for elt in to_remove: | |
369 elt.parent.children.remove(elt) | |
370 | |
371 return desc_elt | |
372 | |
373 def jingle_terminate( | |
374 self, | |
375 client: SatXMPPEntity, | |
376 action: str, | |
377 session: dict, | |
378 content_name: str, | |
379 reason_elt: domish.Element, | |
380 ) -> None: | |
381 reason, text = self._j.parse_reason_elt(reason_elt) | |
382 data = {"reason": reason} | |
383 if text: | |
384 data["text"] = text | |
385 self.host.bridge.call_ended( | |
386 session["id"], data_format.serialise(data), client.profile | |
387 ) | |
388 | |
389 | |
390 @implementer(iwokkel.IDisco) | |
391 class RemoteControl_handler(XMPPHandler): | |
392 def getDiscoInfo(self, requestor, target, nodeIdentifier=""): | |
393 return [disco.DiscoFeature(NS_REMOTE_CONTROL)] | |
394 | |
395 def getDiscoItems(self, requestor, target, nodeIdentifier=""): | |
396 return [] |