libervia-backend/libervia/frontends/tools/webrtc_remote_control.py
2026-03-31 12:57:17 +02:00

322 lines
12 KiB
Python

#!/usr/bin/env python3
# Libervia Remote-Control via WebRTC implementation.
# 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 <http://www.gnu.org/licenses/>.
import asyncio
from functools import partial
import logging
from typing import Any, Awaitable, Callable
import gi
from gi.overrides.Gst import GLib
from gi.repository import GstWebRTC
import cbor2
from libervia.backend.core.i18n import _
from libervia.backend.tools.common import data_format
from libervia.frontends.tools import aio, jid, webrtc
from libervia.frontends.tools.webrtc_models import (
CallData,
SourcesNone,
SourcesPipeline,
)
gi.require_versions({"Gst": "1.0", "GstWebRTC": "1.0"})
OnOpenCbType = Callable[["WebRTCRemoteController"], None | Awaitable[None]]
MOUSE_BTN_LEFT = 0x110
MOUSE_BTN_RIGHT = 0x111
MOUSE_BTN_MIDDLE = 0x112
MOUSE_BTN_FORWARD = 0x115
MOUSE_BTN_BACK = 0x116
log = logging.getLogger(__name__)
class WebRTCRemoteController:
def __init__(
self,
bridge,
profile: str,
on_call_start_cb: Callable[[dict], Any] | None = None,
end_call_cb: Callable[[], Any] | None = None,
) -> None:
"""Initializes the File Sender.
@param bridge: An async Bridge instance.
@param profile: The profile name to be used.
@param on_call_start_cb: A blocking or async callable that accepts a dict as its
only argument.
@param end_call_cb: A callable to be invoked at the end of a call.
"""
self.bridge = bridge
self.profile = profile
self.on_call_start_cb = on_call_start_cb
self.end_call_cb = end_call_cb
self.loop = asyncio.get_event_loop()
self.data_channel: GstWebRTC.WebRTCDataChannel | None = None
def send_input(self, input_data: dict) -> None:
"""Send an input data to controlled device
@param input_data: data of the input event.
"""
assert self.data_channel is not None
self.data_channel.send_data(GLib.Bytes(cbor2.dumps(input_data)))
async def _on_webrtc_call_start(
self,
options: dict,
callee: str,
call_data: dict,
profile: str,
) -> str:
rc_data = {
"webrtc": True,
"call_data": call_data,
}
rc_data.update(options)
remote_control_data_s = await self.bridge.remote_control_start(
str(callee),
data_format.serialise(rc_data),
profile,
)
remote_control_data = data_format.deserialise(remote_control_data_s)
if self.on_call_start_cb is not None:
await aio.maybe_async(self.on_call_start_cb(remote_control_data))
return remote_control_data["session_id"]
def _on_dc_close(self, webrtc_call, data_channel: GstWebRTC.WebRTCDataChannel):
if webrtc_call is not None:
aio.run_from_thread(self._end_call_and_quit, webrtc_call, loop=self.loop)
async def _end_call_and_quit(self, webrtc_call):
await webrtc_call.webrtc.end_call()
if self.end_call_cb is not None:
await aio.maybe_async(self.end_call_cb())
def _on_dc_open(
self, on_open_cb: OnOpenCbType, data_channel: GstWebRTC.WebRTCDataChannel
) -> None:
"""Called when datachannel is open"""
self.data_channel = data_channel
aio.run_from_thread(self.on_dc_opened, on_open_cb, loop=self.loop)
async def on_dc_opened(self, on_open_cb: OnOpenCbType) -> None:
await aio.maybe_async(on_open_cb(self))
async def start(
self, callee: jid.JID, options: dict, on_open_cb: OnOpenCbType
) -> None:
"""Start a remote control session with ``callee``
@param callee: The JID of the recipient to send the file to.
@param options: Options such as which devices to control.
@param on_open_cb: Method to call when the Data Channel is open.
The WebRTCRemoteController instance used as argument can then be used to send
input events to received.
"""
call_data = CallData(callee=callee)
self.webrtc_call = await webrtc.WebRTCCall.make_webrtc_call(
self.bridge,
self.profile,
call_data,
sources_data=webrtc.SourcesDataChannel(
dc_open_cb=partial(self._on_dc_open, on_open_cb),
),
call_start_cb=partial(self._on_webrtc_call_start, options),
)
class WebRTCRemoteControlReceiver:
def __init__(
self,
bridge,
profile: str,
on_close_cb: Callable[[], Any] | None = None,
verbose: bool = False,
) -> None:
"""Initializes the File Receiver.
@param bridge: An async Bridge instance.
@param profile: The profile name to be used.
@param on_close_cb: Called when the Data Channel is closed.
@param verbose: if True, print input events.
"""
self.bridge = bridge
self.profile = profile
self.on_close_cb = on_close_cb
self.loop = asyncio.get_event_loop()
self.desktop_portal = None
self.remote_desktop_data: dict | None = None
self.stream_node_id: int | None = None
self.verbose = verbose
async def do_input(self, data: dict) -> None:
assert self.desktop_portal is not None
try:
type_ = data["type"]
if type_.startswith("mouse"):
try:
try:
x, y = data["x"], data["y"]
except KeyError:
dx, dy = data["movementX"], data["movementY"]
await self.desktop_portal.notify_pointer_motion(dx, dy)
else:
assert self.stream_node_id is not None
await self.desktop_portal.notify_pointer_motion_absolute(
self.stream_node_id, x, y
)
except Exception:
log.exception("Can't send input")
if type_ in ("mouseup", "mousedown"):
buttons = data["buttons"]
state = 1 if type_ == "mousedown" else 0
# see https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons#value
if buttons & 1:
await self.desktop_portal.notify_pointer_button(
MOUSE_BTN_LEFT, state
)
if buttons & 2:
await self.desktop_portal.notify_pointer_button(
MOUSE_BTN_RIGHT, state
)
if buttons & 4:
await self.desktop_portal.notify_pointer_button(
MOUSE_BTN_MIDDLE, state
)
if buttons & 8:
await self.desktop_portal.notify_pointer_button(
MOUSE_BTN_BACK, state
)
if buttons & 16:
await self.desktop_portal.notify_pointer_button(
MOUSE_BTN_FORWARD, state
)
elif type_ == "wheel":
dx = data.get("deltaX", 0)
dy = data.get("deltaY", 0)
delta_mode = data["deltaMode"]
if delta_mode == 0:
# deltas are in pixels
await self.desktop_portal.notify_pointer_axis(dx, dy)
else:
# deltas are in steps (see
# https://developer.mozilla.org/en-US/docs/Web/API/Element/wheel_event#event_properties)
if dx:
await self.desktop_portal.notify_pointer_axis(1, dx)
if dy:
await self.desktop_portal.notify_pointer_axis(0, dy)
elif type_.startswith("key"):
# FIXME: this is a really naive implementation, it needs tot be improved.
key = data["key"]
if data.get("shiftKey", False):
key = key.upper()
await self.desktop_portal.notify_keyboard_keysym(
ord(key), 1 if type_ == "keydown" else 0
)
except Exception:
log.exception(f"Can't handle input {data}")
def _on_dc_message_data(self, data_channel, glib_data) -> None:
"""A data chunk of the file has been received."""
raw = glib_data.get_data()
data = cbor2.loads(raw)
if self.verbose:
print(data)
aio.run_from_thread(self.do_input, data, loop=self.loop)
def _on_dc_close(self, data_channel) -> None:
"""Data channel is closed
The file download should be complete, we close it.
"""
aio.run_from_thread(self._on_close, loop=self.loop)
async def _on_close(self) -> None:
if self.on_close_cb is not None:
await aio.maybe_async(self.on_close_cb())
def _on_data_channel(self, webrtcbin, data_channel) -> None:
"""The data channel has been opened."""
data_channel.connect("on-message-data", self._on_dc_message_data)
data_channel.connect("on-close", self._on_dc_close)
async def request_remote_desktop(self, with_screen_sharing: bool) -> None:
"""Request autorisation to remote control desktop.
@param with_screen_sharing: True if screen must be shared.
"""
from .portal_desktop import DesktopPortal
self.desktop_portal = DesktopPortal()
self.remote_desktop_data = await self.desktop_portal.request_remote_desktop(
with_screen_sharing
)
print(self.remote_desktop_data)
async def start_receiving(
self, from_jid: jid.JID, session_id: str, screenshare: dict
) -> None:
"""Receives a file via WebRTC and saves it to the specified path.
@param from_jid: The JID of the entity sending the file.
@param session_id: The Jingle FT Session ID.
@param file_path: The local path where the received file will be saved.
If a file already exists at this path, it will be overwritten.
@param file_data: Additional data about the file being transferred.
"""
call_data = CallData(callee=from_jid, sid=session_id)
if "video" in screenshare and self.remote_desktop_data:
try:
stream_data = self.remote_desktop_data["stream_data"]
log.debug(f"{stream_data=}")
self.stream_node_id = stream_data["node_id"]
sources_data = SourcesPipeline(
video_pipeline="pipewiresrc",
audio_pipeline="",
video_properties={
"path": str(self.stream_node_id),
"do-timestamp": 1,
},
)
except KeyError:
sources_data = SourcesNone()
else:
sources_data = SourcesNone()
await webrtc.WebRTCCall.make_webrtc_call(
self.bridge,
self.profile,
call_data,
sources_data=sources_data,
sinks_data=webrtc.SinksNone(),
dc_data_list=[
webrtc.SinksDataChannel(
dc_on_data_channel=self._on_data_channel,
)
],
)