view libervia/frontends/tools/webrtc_remote_control.py @ 4268:51d004e50786

docker (backend): set `+use_local_shared_tmp` in conf.
author Goffi <goffi@goffi.org>
date Thu, 13 Jun 2024 13:22:41 +0200
parents 8acf46ed7f36
children 0d7bb4df2343
line wrap: on
line source

#!/usr/bin/env python3

# Libervia Remote-Control via WebRTC implementation.
# 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/>.


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,
            )],
        )