Mercurial > libervia-backend
view libervia/frontends/tools/portal_desktop.py @ 4326:5fd6a4dc2122
cli (output/std): use `rich` to output JSON.
author | Goffi <goffi@goffi.org> |
---|---|
date | Wed, 20 Nov 2024 11:38:44 +0100 |
parents | 0d7bb4df2343 |
children |
line wrap: on
line source
#!/usr/bin/env python3 # Libervia freedesktop portal management module # 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 typing import Callable, Literal, overload from libervia.backend.core import exceptions import asyncio import logging from random import randint import dbus from dbus.mainloop.glib import DBusGMainLoop log = logging.getLogger(__name__) class PortalError(Exception): pass class DesktopPortal: def __init__(self, on_session_closed_cb: Callable | None = None): # we want monitors + windows, see https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.ScreenCast.html#org-freedesktop-portal-screencast-availablesourcetypes self.dbus = dbus self.on_session_closed_cb = on_session_closed_cb self.sources_type = dbus.UInt32(7) DBusGMainLoop(set_as_default=True) self.session_bus = dbus.SessionBus() portal_object = self.session_bus.get_object( "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop" ) self.screencast_interface = dbus.Interface( portal_object, "org.freedesktop.portal.ScreenCast" ) self.remote_desktop_interface = dbus.Interface( portal_object, "org.freedesktop.portal.RemoteDesktop" ) self.session_interface = None self.session_signal = None self.handle_counter = 0 self.session_handle = None @property def handle_token(self): self.handle_counter += 1 return f"libervia{self.handle_counter}" def on_session_closed(self, details: dict) -> None: if self.session_interface is not None: self.session_interface = None if self.on_session_closed_cb is not None: self.on_session_closed_cb() if self.session_signal is not None: self.session_signal.remove() self.session_signal = None @overload async def dbus_call( self, interface: dbus.Interface, method_name: str, response: Literal[False], **kwargs, ) -> None: ... @overload async def dbus_call( self, interface: dbus.Interface, method_name: str, response: Literal[True], **kwargs, ) -> dict: ... async def dbus_call( self, interface: dbus.Interface, method_name: str, response: bool, **kwargs, ) -> dict | None: """Call a portal method This method handle the signal response. @param method_name: method to call @param response: True if the method expect a response. If True, the method will await responde from ``org.freedesktop.portal.Request``'s ``Response`` signal. @param kwargs: method args. ``handle_token`` will be automatically added to ``options`` dict. @return: method result """ method = getattr(interface, method_name) try: options = kwargs["options"] except KeyError: raise exceptions.InternalError('"options" key must be present.') reply_fut = asyncio.Future() signal_fut = asyncio.Future() # cf. https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html handle_token = self.handle_token sender = self.session_bus.get_unique_name().replace(".", "_")[1:] path = f"/org/freedesktop/portal/desktop/request/{sender}/{handle_token}" signal_match = None def on_signal(response, results): print(f"on_signal responde {response=}") assert signal_match is not None signal_match.remove() if response == 0: signal_fut.set_result(results) elif response == 1: signal_fut.set_exception(exceptions.CancelError("Cancelled by user.")) else: signal_fut.set_exception(PortalError("Can't get signal result")) if response: signal_match = self.session_bus.add_signal_receiver( on_signal, signal_name="Response", dbus_interface="org.freedesktop.portal.Request", path=path, ) options["handle_token"] = handle_token method( *kwargs.values(), reply_handler=lambda ret=None: reply_fut.set_result(ret), error_handler=reply_fut.set_exception, ) try: await reply_fut except Exception as e: raise PortalError(f"Can't ask portal permission: {e}") if response: return await signal_fut async def create_session( self, interface: dbus.Interface, ) -> dict: """Create a new session and store its handle. This method creates a new session using the freedesktop portal's CreateSession dbus call. It then registers the session handle in this object for further use. @param None @return: A dictionary containing the session data, including the session handle. @raise PortalError: If there is an error getting the session handle. """ if self.session_handle is not None: self.end_session() session_data = await self.dbus_call( interface, "CreateSession", response=True, options={ "session_handle_token": str(randint(1, 2**32)), }, ) try: session_handle = session_data["session_handle"] except KeyError: raise PortalError("Can't get session handle") self.session_handle = session_handle return session_data def parse_streams(self, session_handle, screenshare_data: dict) -> dict: """Fill and returns stream_data from screenshare_data""" try: node_id, shared_stream_data = screenshare_data["streams"][0] source_type = int(shared_stream_data["source_type"]) except (IndexError, KeyError): raise exceptions.NotFound("No stream data found.") stream_data = { "session_handle": session_handle, "node_id": node_id, "source_type": source_type, } try: height = int(shared_stream_data["size"][0]) weight = int(shared_stream_data["size"][1]) except (IndexError, KeyError): pass else: stream_data["size"] = (height, weight) return stream_data async def request_screenshare(self) -> dict: await self.create_session(self.screencast_interface) session_handle = self.session_handle await self.dbus_call( self.screencast_interface, "SelectSources", response=True, session_handle=session_handle, options={"multiple": True, "types": self.sources_type}, ) screenshare_data = await self.dbus_call( self.screencast_interface, "Start", response=True, session_handle=session_handle, parent_window="", options={}, ) session_object = self.session_bus.get_object( "org.freedesktop.portal.Desktop", session_handle ) self.session_interface = self.dbus.Interface( session_object, "org.freedesktop.portal.Session" ) self.session_signal = self.session_bus.add_signal_receiver( self.on_session_closed, signal_name="Closed", dbus_interface="org.freedesktop.portal.Session", path=session_handle, ) try: return self.parse_streams(session_handle, screenshare_data) except exceptions.NotFound: raise PortalError("Can't parse stream data") async def request_remote_desktop(self, with_screen_sharing: bool = True) -> dict: """Request autorisation to remote control desktop. @param with_screen_sharing: True if screen must be shared. """ await self.create_session(self.remote_desktop_interface) session_handle = self.session_handle if with_screen_sharing: await self.dbus_call( self.screencast_interface, "SelectSources", response=False, session_handle=session_handle, options={ "multiple": True, "types": self.sources_type, # hidden cursor (should be the default, but cursor appears during # tests)) "cursor_mode": dbus.UInt32(1), }, ) await self.dbus_call( self.remote_desktop_interface, "SelectDevices", response=False, session_handle=session_handle, options={ "types": dbus.UInt32(3), # "persist_mode": dbus.UInt32(1), }, ) remote_desktop_data = await self.dbus_call( self.remote_desktop_interface, "Start", response=True, session_handle=session_handle, parent_window="", options={}, ) try: stream_data = self.parse_streams(session_handle, remote_desktop_data) except exceptions.NotFound: pass else: remote_desktop_data["stream_data"] = stream_data session_object = self.session_bus.get_object( "org.freedesktop.portal.Desktop", session_handle ) self.session_interface = self.dbus.Interface( session_object, "org.freedesktop.portal.Session" ) self.session_signal = self.session_bus.add_signal_receiver( self.on_session_closed, signal_name="Closed", dbus_interface="org.freedesktop.portal.Session", path=session_handle, ) return remote_desktop_data def end_session(self) -> None: """Close a running screenshare session, if any.""" if self.session_interface is None: return self.session_interface.Close() self.on_session_closed({}) async def notify_pointer_motion(self, dx: int, dy: int) -> None: """ Notify about a new relative pointer motion event. @param dx: Relative movement on the x axis @param dy: Relative movement on the y axis """ await self.dbus_call( self.remote_desktop_interface, "NotifyPointerMotion", response=False, session_handle=self.session_handle, options={}, dx=dx, dy=dy, ) async def notify_pointer_motion_absolute( self, stream: int, x: float, y: float ) -> None: """ Notify about a new absolute pointer motion event. @param stream: The PipeWire stream node the coordinate is relative to @param x: Pointer motion x coordinate @param y: Pointer motion y coordinate """ await self.dbus_call( self.remote_desktop_interface, "NotifyPointerMotionAbsolute", response=False, session_handle=self.session_handle, options={}, stream=stream, x=x, y=y, ) async def notify_pointer_button(self, button: int, state: int) -> None: """ Notify about a new pointer button event. @param button: The pointer button was pressed or released @param state: The new state of the button """ await self.dbus_call( self.remote_desktop_interface, "NotifyPointerButton", response=False, session_handle=self.session_handle, options={}, button=button, state=state, ) async def notify_pointer_axis(self, dx: float, dy: float) -> None: """ Notify about a new pointer axis event. @param dx: Relative axis movement on the x axis @param dy: Relative axis movement on the y axis """ await self.dbus_call( self.remote_desktop_interface, "NotifyPointerAxis", response=False, session_handle=self.session_handle, options={}, dx=dx, dy=dy, ) async def notify_pointer_axis_discrete(self, axis: int, steps: int) -> None: """ Notify about a new pointer axis discrete event. @param axis: The axis that was scrolled 0 for vertical 1 for horizontal @param steps: The number of steps scrolled """ await self.dbus_call( self.remote_desktop_interface, "NotifyPointerAxisDiscrete", response=False, session_handle=self.session_handle, options={}, axis=axis, steps=steps, ) async def notify_keyboard_keycode(self, keycode: int, state: int) -> None: """ Notify about a new keyboard keycode event. @param keycode: Keyboard code that was pressed or released @param state: New state of keyboard keycode """ await self.dbus_call( self.remote_desktop_interface, "NotifyKeyboardKeycode", response=False, session_handle=self.session_handle, options={}, keycode=keycode, state=state, ) async def notify_keyboard_keysym(self, keysym: int, state: int) -> None: """ Notify about a new keyboard keysym event. @param keysym: Keyboard symbol that was pressed or released @param state: New state of keyboard keysym """ await self.dbus_call( self.remote_desktop_interface, "NotifyKeyboardKeysym", response=False, session_handle=self.session_handle, options={}, keysym=keysym, state=state, ) async def notify_touch_down(self, stream: int, slot: int, x: int, y: int) -> None: """ Notify about a new touch down event. @param stream: The PipeWire stream node the coordinate is relative to @param slot: Touch slot where touch point appeared @param x: Touch down x coordinate @param y: Touch down y coordinate """ await self.dbus_call( self.remote_desktop_interface, "NotifyTouchDown", response=False, session_handle=self.session_handle, options={}, stream=stream, slot=slot, x=x, y=y, ) async def notify_touch_motion(self, stream: int, slot: int, x: int, y: int) -> None: """ Notify about a new touch motion event. @param stream: The PipeWire stream node the coordinate is relative to @param slot: Touch slot where touch point appeared @param x: Touch motion x coordinate @param y: Touch motion y coordinate """ await self.dbus_call( self.remote_desktop_interface, "NotifyTouchMotion", response=False, session_handle=self.session_handle, options={}, stream=stream, slot=slot, x=x, y=y, ) async def notify_touch_up(self, slot: int) -> None: """ Notify about a new touch up event. @param slot: Touch slot where touch point appeared """ await self.dbus_call( self.remote_desktop_interface, "NotifyTouchUp", response=False, session_handle=self.session_handle, options={}, slot=slot, )