Created
October 15, 2025 15:20
-
-
Save swick/40443d49cb65f7f1dd4ce703dc393184 to your computer and use it in GitHub Desktop.
global shortcuts activation token test
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/env python3 | |
| import dbus | |
| import dbus.mainloop.glib | |
| from dbus.mainloop.glib import DBusGMainLoop | |
| from typing import Any, Dict, Optional, NamedTuple, Callable, List | |
| from gi.repository import GLib | |
| import logging | |
| from itertools import count | |
| _counter = count() | |
| ASV = Dict[str, Any] | |
| def init_logger(name: str) -> logging.Logger: | |
| """ | |
| Common logging setup for tests. Use as: | |
| >>> import tests.xdp_utils as xdp | |
| >>> logger = xdp.init_logger(__name__) | |
| >>> logger.debug("foo") | |
| """ | |
| logging.basicConfig( | |
| format="%(levelname).1s|%(name)s: %(message)s", level=logging.DEBUG | |
| ) | |
| logger = logging.getLogger(f"xdp.{name}") | |
| logger.setLevel(logging.DEBUG) | |
| return logger | |
| logger = init_logger("utils") | |
| class Response(NamedTuple): | |
| """ | |
| Response as returned by a completed :class:`Request` | |
| """ | |
| response: int | |
| results: ASV | |
| class ResponseTimeout(Exception): | |
| """ | |
| Exception raised by :meth:`Request.call` if the Request did not receive a | |
| Response in time. | |
| """ | |
| pass | |
| class Closable: | |
| """ | |
| Parent class for both Session and Request. Both of these have a Close() | |
| method. | |
| """ | |
| def __init__(self, bus: dbus.Bus, objpath: str): | |
| self.objpath = objpath | |
| # GLib makes assertions in callbacks impossible, so we wrap all | |
| # callbacks into a try: except and store the error on the request to | |
| # be raised later when we're back in the main context | |
| self.error: Optional[Exception] = None | |
| self._mainloop: Optional[GLib.MainLoop] = None | |
| self._impl_closed = False | |
| self._bus = bus | |
| self._closable = type(self).__name__ | |
| assert self._closable in ("Request", "Session") | |
| proxy = bus.get_object("org.freedesktop.portal.Desktop", objpath) | |
| self._closable_interface = dbus.Interface( | |
| proxy, f"org.freedesktop.portal.{self._closable}" | |
| ) | |
| @property | |
| def bus(self) -> dbus.Bus: | |
| return self._bus | |
| @property | |
| def closed(self) -> bool: | |
| """ | |
| True if the impl.portal was closed | |
| """ | |
| return self._impl_closed | |
| def close(self) -> None: | |
| signal_match = None | |
| def cb_impl_closed_by_portal(handle) -> None: | |
| if handle == self.objpath: | |
| logger.debug(f"Impl{self._closable} {self.objpath} was closed") | |
| signal_match.remove() # type: ignore | |
| self._impl_closed = True | |
| if self.closed and self._mainloop: | |
| self._mainloop.quit() | |
| # See :class:`ImplRequest`, this signal is a side-channel for the | |
| # impl.portal template to notify us when the impl.Request was really | |
| # closed by the portal. | |
| signal_match = self._bus.add_signal_receiver( | |
| cb_impl_closed_by_portal, | |
| f"{self._closable}Closed", | |
| dbus_interface="org.freedesktop.impl.portal.Mock", | |
| ) | |
| logger.debug(f"Closing {self._closable} {self.objpath}") | |
| self._closable_interface.Close() | |
| def schedule_close(self, timeout_ms=300): | |
| """ | |
| Schedule an automatic Close() on the given timeout in milliseconds. | |
| """ | |
| GLib.timeout_add(timeout_ms) | |
| class Request(Closable): | |
| """ | |
| Helper class for executing methods that use Requests. This calls takes | |
| care of subscribing to the signals and invokes the method on the | |
| interface with the expected behaviors. A typical invocation is: | |
| >>> response = Request(connection, interface).call("Foo", bar="bar") | |
| >>> assert response.response == 0 | |
| Requests can only be used once, to call a second method you must | |
| instantiate a new Request object. | |
| """ | |
| def __init__(self, bus: dbus.Bus, interface: dbus.Interface): | |
| def sanitize(name): | |
| return name.lstrip(":").replace(".", "_") | |
| sender_token = sanitize(bus.get_unique_name()) | |
| self._handle_token = f"request{next(_counter)}" | |
| self.handle = f"/org/freedesktop/portal/desktop/request/{sender_token}/{self._handle_token}" | |
| self.timeout = -1 | |
| # The Closable | |
| super().__init__(bus, self.handle) | |
| self.interface = interface | |
| self.response: Optional[Response] = None | |
| self.used = False | |
| # GLib makes assertions in callbacks impossible, so we wrap all | |
| # callbacks into a try: except and store the error on the request to | |
| # be raised later when we're back in the main context | |
| self.error: Optional[Exception] = None | |
| proxy = bus.get_object("org.freedesktop.portal.Desktop", self.handle) | |
| def cb_response(response: int, results: ASV) -> None: | |
| try: | |
| logger.debug(f"Response received on {self.handle}") | |
| assert self.response is None | |
| self.response = Response(response, results) | |
| if self._mainloop: | |
| self._mainloop.quit() | |
| except Exception as e: | |
| self.error = e | |
| self.request_interface = dbus.Interface(proxy, "org.freedesktop.portal.Request") | |
| self.request_interface.connect_to_signal("Response", cb_response) | |
| @property | |
| def handle_token(self) -> dbus.String: | |
| """ | |
| Returns the dbus-ready handle_token, ready to be put into the options | |
| """ | |
| return dbus.String(self._handle_token, variant_level=1) | |
| def call(self, methodname: str, **kwargs) -> Optional[Response]: | |
| """ | |
| Semi-synchronously call method ``methodname`` on the interface given | |
| in the Request's constructor. The kwargs must be specified in the | |
| order the DBus method takes them but the handle_token is automatically | |
| filled in. | |
| >>> response = Request(connection, interface).call("Foo", bar="bar") | |
| >>> if response.response != 0: | |
| ... print("some error occured") | |
| The DBus call itself is asynchronous (required for signals to work) | |
| but this method does not return until the Response is received, the | |
| Request is closed or an error occurs. If the Request is closed, the | |
| Response is None. | |
| If the "reply_handler" and "error_handler" keywords are present, those | |
| callbacks are called just like they would be as dbus.service.ProxyObject. | |
| """ | |
| assert not self.used | |
| self.used = True | |
| # Make sure options exists and has the handle_token set | |
| try: | |
| options = kwargs["options"] | |
| except KeyError: | |
| options = dbus.Dictionary({}, signature="sv") | |
| if "handle_token" not in options: | |
| options["handle_token"] = self.handle_token | |
| # Anything that takes longer than 5s needs to fail | |
| self._mainloop = GLib.MainLoop() | |
| if self.timeout >= 0: | |
| GLib.timeout_add(self.timeout, self._mainloop.quit) | |
| method = getattr(self.interface, methodname) | |
| assert method | |
| reply_handler = kwargs.pop("reply_handler", None) | |
| error_handler = kwargs.pop("error_handler", None) | |
| # Handle the normal method reply which returns is the Request object | |
| # path. We don't exit the mainloop here, we're waiting for either the | |
| # Response signal on the Request itself or the Close() handling | |
| def reply_cb(handle): | |
| try: | |
| logger.debug(f"Reply to {methodname} with {self.handle}") | |
| assert handle == self.handle | |
| if reply_handler: | |
| reply_handler(handle) | |
| except Exception as e: | |
| self.error = e | |
| # Handle any exceptions during the actual method call (not the Request | |
| # handling itself). Can exit the mainloop if that happens | |
| def error_cb(error): | |
| try: | |
| logger.debug(f"Error after {methodname} with {error}") | |
| if error_handler: | |
| error_handler(error) | |
| self.error = error | |
| except Exception as e: | |
| self.error = e | |
| finally: | |
| if self._mainloop: | |
| self._mainloop.quit() | |
| # Method is invoked async, otherwise we can't mix and match signals | |
| # and other calls. It's still sync as seen by the caller in that we | |
| # have a mainloop that waits for us to finish though. | |
| method( | |
| *list(kwargs.values()), | |
| reply_handler=reply_cb, | |
| error_handler=error_cb, | |
| ) | |
| self._mainloop.run() | |
| if self.error: | |
| raise self.error | |
| elif not self.closed and self.response is None: | |
| raise ResponseTimeout(f"Timed out waiting for response from {methodname}") | |
| return self.response | |
| class Session(Closable): | |
| """ | |
| Helper class for a Session created by a portal. This class takes care of | |
| subscribing to the `Closed` signals. A typical invocation is: | |
| >>> response = Request(connection, interface).call("CreateSession") | |
| >>> session = Session.from_response(response) | |
| # Now run the main loop and do other stuff | |
| # Check if the session was closed | |
| >>> if session.closed: | |
| ... pass | |
| # or close the session explicitly | |
| >>> session.close() # to close the session or | |
| """ | |
| def __init__(self, bus: dbus.Bus, handle: str): | |
| assert handle | |
| super().__init__(bus, handle) | |
| self.handle = handle | |
| self.details = None | |
| # GLib makes assertions in callbacks impossible, so we wrap all | |
| # callbacks into a try: except and store the error on the request to | |
| # be raised later when we're back in the main context | |
| self.error = None | |
| self._closed_sig_received = False | |
| def cb_closed(details: ASV) -> None: | |
| try: | |
| logger.debug(f"Session.Closed received on {self.handle}") | |
| assert not self._closed_sig_received | |
| self._closed_sig_received = True | |
| self.details = details | |
| if self._mainloop: | |
| self._mainloop.quit() | |
| except Exception as e: | |
| self.error = e | |
| proxy = bus.get_object("org.freedesktop.portal.Desktop", handle) | |
| self.session_interface = dbus.Interface(proxy, "org.freedesktop.portal.Session") | |
| self.session_interface.connect_to_signal("Closed", cb_closed) | |
| @property | |
| def closed(self): | |
| """ | |
| Returns True if the session was closed by the backend | |
| """ | |
| return self._closed_sig_received or super().closed | |
| @classmethod | |
| def from_response(cls, bus: dbus.Bus, response: Response) -> "Session": | |
| return cls(bus, response.results["session_handle"]) | |
| # This sets up a global shortcuts session, registers a shortcut, listens for it to activate | |
| # It then tries to activate org.gnome.TextEditor | |
| # If the activation token made it all the way to org.gnome.TextEditor, it should become focused | |
| # Otherwise, gnome-shell will show the "An app is ready" notification instead | |
| loop = GLib.MainLoop() | |
| dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) | |
| bus = dbus.SessionBus() | |
| portal = bus.get_object("org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop") | |
| registry_iface = dbus.Interface(portal, "org.freedesktop.host.portal.Registry") | |
| registry_iface.Register("org.gnome.Tecla", {}) | |
| globalshortcuts_iface = dbus.Interface(portal, "org.freedesktop.portal.GlobalShortcuts") | |
| request = Request(bus, globalshortcuts_iface) | |
| response = request.call("CreateSession", options={ | |
| "session_handle_token": "test", | |
| }) | |
| assert response.response == 0 | |
| session = Session.from_response(bus, response) | |
| request = Request(bus, globalshortcuts_iface) | |
| response = request.call("BindShortcuts", | |
| session_handle=session.handle, | |
| shortcuts=[ | |
| ("s2", {"description": "test shortcut", "preferred_trigger": "<Ctrl>h"}), | |
| ], | |
| parent_window="", | |
| options={}, | |
| ) | |
| assert response.response == 0 | |
| def activated_handler(session_handle, shortcut_id, timestamp, options): | |
| print("got activation") | |
| print(options) | |
| platform_data = {} | |
| if "activation-token" in options: | |
| platform_data["activation-token"] = options["activation-token"] | |
| text_editor = bus.get_object("org.gnome.TextEditor", "/org/gnome/TextEditor") | |
| application_iface = dbus.Interface(text_editor, "org.freedesktop.Application") | |
| application_iface.Activate(platform_data) | |
| loop.quit() | |
| globalshortcuts_iface.connect_to_signal("Activated", activated_handler) | |
| loop.run() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment