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
This commit is contained in:
parent
de7260bfb9
commit
ed7a2fa132
2 changed files with 398 additions and 0 deletions
|
|
@ -354,10 +354,12 @@ class Const(object):
|
|||
META_TYPE_CONFIRM = "confirm"
|
||||
META_TYPE_FILE = "file"
|
||||
META_TYPE_CALL = "call"
|
||||
META_TYPE_REMOTE_CONTROL = "remote-control"
|
||||
META_TYPE_OVERWRITE = "overwrite"
|
||||
META_TYPE_NOT_IN_ROSTER_LEAK = "not_in_roster_leak"
|
||||
META_SUBTYPE_CALL_AUDIO = "audio"
|
||||
META_SUBTYPE_CALL_VIDEO = "video"
|
||||
META_SUBTYPE_CALL_REMOTE_CONTROL = "remote-control"
|
||||
|
||||
## HARD-CODED ACTIONS IDS (generated with uuid.uuid4) ##
|
||||
AUTHENTICATE_PROFILE_ID = "b03bbfa8-a4ae-4734-a248-06ce6c7cf562"
|
||||
|
|
|
|||
396
libervia/backend/plugins/plugin_misc_remote_control.py
Normal file
396
libervia/backend/plugins/plugin_misc_remote_control.py
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# Libervia: an XMPP client
|
||||
# Copyright (C) 2009-2024 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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 wokkel import disco, iwokkel
|
||||
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)
|
||||
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: <description> 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(iwokkel.IDisco)
|
||||
class RemoteControl_handler(XMPPHandler):
|
||||
def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
|
||||
return [disco.DiscoFeature(NS_REMOTE_CONTROL)]
|
||||
|
||||
def getDiscoItems(self, requestor, target, nodeIdentifier=""):
|
||||
return []
|
||||
Loading…
Reference in a new issue