#!/usr/bin/env python3 # Libervia: an XMPP client # Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org) # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from twisted.internet import defer from twisted.words.protocols.jabber import jid from twisted.words.protocols.jabber.xmlstream import XMPPHandler from twisted.words.xish import domish from tx_xmpp import disco, itx_xmpp from zope.interface import implementer from libervia.backend.core import exceptions from libervia.backend.core.constants import Const as C from libervia.backend.core.core_types import SatXMPPEntity from libervia.backend.core.i18n import D_, _ from libervia.backend.core.log import getLogger from libervia.backend.tools import xml_tools from libervia.backend.tools.common import data_format from .plugin_xep_0166 import BaseApplicationHandler log = getLogger(__name__) NS_REMOTE_CONTROL = "urn:xmpp:jingle:apps:remote-control:0" PLUGIN_INFO = { C.PI_NAME: "Jingle Remove Control", C.PI_IMPORT_NAME: "RemoteControl", C.PI_TYPE: C.PLUG_TYPE_MISC, C.PI_PROTOCOLS: [], C.PI_DEPENDENCIES: ["XEP-0166", "XEP-0167"], C.PI_MAIN: "RemoteControl", C.PI_HANDLER: "yes", C.PI_DESCRIPTION: _("""Remote control devices with Jingle."""), } class RemoteControl(BaseApplicationHandler): def __init__(self, host): log.info(f'Plugin "{PLUGIN_INFO[C.PI_NAME]}" initialization') self.host = host # FIXME: to be removed once host is accessible from global var self._j = host.plugins["XEP-0166"] # We need higher priority than XEP-0167 application, as we have a RemoteControl # session and not a call one when this application is used. self._j.register_application(NS_REMOTE_CONTROL, self, priority=1000) self._rtp = host.plugins["XEP-0167"] host.register_namespace("remote-control", NS_REMOTE_CONTROL) host.bridge.add_method( "remote_control_start", ".plugin", in_sign="sss", out_sign="s", method=self._remote_control_start, async_=True, ) def get_handler(self, client): return RemoteControl_handler() # bridge methods def _remote_control_start( self, peer_jid_s: str, extra_s: str, profile: str, ) -> defer.Deferred[str]: client = self.host.get_client(profile) extra = data_format.deserialise(extra_s) d = defer.ensureDeferred( self.remote_control_start( client, jid.JID(peer_jid_s), extra, ) ) d.addCallback(data_format.serialise) return d async def remote_control_start( self, client: SatXMPPEntity, peer_jid: jid.JID, extra: dict ) -> dict: """Start a remote control session. @param peer_jid: destinee jid @return: progress id """ if not extra: raise exceptions.DataError('"extra" must be set.') # webrtc is always used for remote control extra["webrtc"] = True content = { "app_ns": NS_REMOTE_CONTROL, # XXX: for now only unidirectional device exist, but future extensions mays be # bidirectional, and which case "senders" would be set to "both" "senders": self._j.ROLE_INITIATOR, "app_kwargs": { "extra": extra, }, } try: call_data = content["app_kwargs"]["extra"]["call_data"] except KeyError: raise exceptions.DataError('"call_data" must be set in "extra".') metadata = self._rtp.parse_call_data(call_data, self._j.ROLE_INITIATOR) try: application_data = call_data["application"] except KeyError: raise exceptions.DataError('"call_data" must have an application media.') try: content["transport_data"] = { "sctp-port": metadata["sctp-port"], "max-message-size": metadata.get("max-message-size", 65536), "local_ice_data": { "ufrag": metadata["ice-ufrag"], "pwd": metadata["ice-pwd"], "candidates": application_data.pop("ice-candidates"), "fingerprint": application_data.pop("fingerprint", {}), }, } name = application_data.get("id") if name: content["name"] = name except KeyError as e: raise exceptions.DataError(f"Mandatory key is missing: {e}") contents = [content] contents.extend(self._rtp.get_contents(call_data, metadata)) session_id = await self._j.initiate( client, peer_jid, contents, call_type=C.META_SUBTYPE_CALL_REMOTE_CONTROL, metadata=metadata, peer_metadata={}, ) return {"session_id": session_id} # jingle callbacks def _get_confirm_msg( self, client: SatXMPPEntity, peer_jid: jid.JID, ) -> tuple[bool, str, str]: """Get confirmation message to display to user. This is the message to show when a remote-control request is received.""" if client.roster and peer_jid.userhostJID() not in client.roster: is_in_roster = False confirm_msg = D_( "Somebody not in your contact list ({peer_jid}) wants to control " "remotely this device. Accepting this will give full control of your " " device to this person, and leak your presence and probably your IP " "address. Do not accept if you don't trust this person!\n" "Do you accept?" ).format(peer_jid=peer_jid) confirm_title = D_("Remote Control Request From an Unknown Contact") else: is_in_roster = True confirm_msg = D_( "{peer_jid} wants to control your device. Accepting will give full " "control of your device, like if they were in front of your computer. " "Only accept if you absolute trust this person.\n" "Do you accept?" ).format(peer_jid=peer_jid) confirm_title = D_("Remote Control Request.") return (is_in_roster, confirm_msg, confirm_title) async def jingle_preflight( self, client: SatXMPPEntity, session: dict, description_elt: domish.Element ) -> None: """Perform preflight checks for an incoming call session. Check if the calls is audio only or audio/video, then, prompts the user for confirmation. @param client: The client instance. @param session: Jingle session. @param description_elt: The description element. It's parent attribute is used to determine check siblings to see if it's an audio only or audio/video call. @raises exceptions.CancelError: If the user doesn't accept the incoming call. """ session_id = session["id"] peer_jid = session["peer_jid"] is_in_roster, confirm_msg, confirm_title = self._get_confirm_msg(client, peer_jid) if is_in_roster: action_type = C.META_TYPE_CONFIRM else: action_type = C.META_TYPE_NOT_IN_ROSTER_LEAK action_extra = { "type": action_type, "session_id": session_id, "from_jid": peer_jid.full(), } action_extra["subtype"] = C.META_TYPE_REMOTE_CONTROL accepted = await xml_tools.defer_confirm( self.host, confirm_msg, confirm_title, profile=client.profile, action_extra=action_extra, ) if accepted: session["pre_accepted"] = True return accepted async def jingle_preflight_info( self, client: SatXMPPEntity, session: dict, info_type: str, info_data: dict | None = None, ) -> None: pass async def jingle_preflight_cancel( self, client: SatXMPPEntity, session: dict, cancel_error: exceptions.CancelError ) -> None: """The remote control has been rejected""" def jingle_session_init( self, client: SatXMPPEntity, session: dict, content_name: str, extra: dict ) -> domish.Element: """Initializes a jingle session. @param client: The client instance. @param session: Jingle session. @param content_name: Name of the content. @param extra: Extra data. @return: element. """ desc_elt = domish.Element((NS_REMOTE_CONTROL, "description")) devices = extra.get("devices") or {} for name, data in devices.items(): device_elt = desc_elt.addElement((NS_REMOTE_CONTROL, "device")) device_elt["type"] = name return desc_elt async def jingle_request_confirmation( self, client: SatXMPPEntity, action: str, session: dict, content_name: str, desc_elt: domish.Element, ) -> bool: """Requests confirmation from the user for a Jingle session's incoming call. This method checks the content type of the Jingle session (audio or video) based on the session's contents. Confirmation is requested only for the first content; subsequent contents are automatically accepted. This means, in practice, that the call confirmation is prompted only once for both audio and video contents. @param client: The client instance. @param action: The action type associated with the Jingle session. @param session: Jingle session. @param content_name: Name of the content being checked. @param desc_elt: The description element associated with the content. @return: True if the call is accepted by the user, False otherwise. """ content_data = session["contents"][content_name] role = session["role"] if role == self._j.ROLE_INITIATOR: raise NotImplementedError return True elif role == self._j.ROLE_RESPONDER: # We are the controlled entity. return await self._remote_control_request_conf( client, session, content_data, content_name ) else: raise exceptions.InternalError(f"Invalid role {role!r}") async def _remote_control_request_conf( self, client: SatXMPPEntity, session: dict, content_data: dict, content_name: str, ) -> bool: """Handle user permission.""" peer_jid = session["peer_jid"] pre_accepted = session.get("pre_accepted", False) __, confirm_msg, confirm_title = self._get_confirm_msg(client, peer_jid) contents = session["contents"] action_extra = { "pre_accepted": pre_accepted, "type": C.META_TYPE_REMOTE_CONTROL, "devices": content_data["application_data"]["devices"], "session_id": session["id"], "from_jid": peer_jid.full(), } for name, content in contents.items(): if name == content_name: continue if content["application"].namespace == self._rtp.namespace: media = content["application_data"]["media"] action_extra.setdefault("screenshare", {})[media] = {} return await xml_tools.defer_confirm( self.host, confirm_msg, confirm_title, profile=client.profile, action_extra=action_extra, ) async def jingle_handler(self, client, action, session, content_name, desc_elt): content_data = session["contents"][content_name] application_data = content_data["application_data"] if action == self._j.A_PREPARE_CONFIRMATION: devices = application_data["devices"] = {} for device_elt in desc_elt.elements(NS_REMOTE_CONTROL, "device"): try: device_type = device_elt.attributes["type"] except KeyError: log.warning(f"Invalide device element: {device_elt.toXml()}") else: # The dict holds data for current type of devices. For now it is unused # has the spec doesn't define any device data, but it may be used by # future extensions. devices[device_type] = {} elif action == self._j.A_SESSION_INITIATE: # FIXME: for now we automatically accept keyboard, mouse and wheel and nothing # else. Must actually reflect user choices and local devices. to_remove = [] for device_elt in desc_elt.elements(NS_REMOTE_CONTROL, "device"): if device_elt.getAttribute("type") not in ("keyboard", "wheel", "mouse"): to_remove.append(device_elt) for elt in to_remove: elt.parent.children.remove(elt) return desc_elt def jingle_terminate( self, client: SatXMPPEntity, action: str, session: dict, content_name: str, reason_elt: domish.Element, ) -> None: reason, text = self._j.parse_reason_elt(reason_elt) data = {"reason": reason} if text: data["text"] = text self.host.bridge.call_ended( session["id"], data_format.serialise(data), client.profile ) @implementer(itx_xmpp.IDisco) class RemoteControl_handler(XMPPHandler): def getDiscoInfo(self, requestor, target, nodeIdentifier=""): return [disco.DiscoFeature(NS_REMOTE_CONTROL)] def getDiscoItems(self, requestor, target, nodeIdentifier=""): return []