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 []