diff --git a/normcap/gui/dbus.py b/normcap/gui/dbus.py new file mode 100644 index 000000000..d04678419 --- /dev/null +++ b/normcap/gui/dbus.py @@ -0,0 +1,277 @@ +import json +import logging +import os +import sys +import tempfile +from typing import Any, cast + +from jeepney.io.blocking import Proxy, open_dbus_connection +from jeepney.wrappers import Message, MessageGenerator, new_method_call + +from normcap.gui.models import Rect + +try: + from PySide6 import QtDBus + +except ImportError: + QtDBus = cast(Any, None) + + +logger = logging.getLogger(__name__) + + +class DBusShell(MessageGenerator): + interface = "org.gnome.Shell" + + def __init__( + self, object_path: str = "/org/gnome/Shell", bus_name: str = "org.gnome.Shell" + ) -> None: + super().__init__(object_path=object_path, bus_name=bus_name) + + def eval_(self, script: str) -> Message: + return new_method_call(self, "Eval", "s", (script,)) + + +class DBusWindowCalls(MessageGenerator): + interface = "org.gnome.Shell.Extensions.Windows" + + def __init__( + self, + object_path: str = "/org/gnome/Shell/Extensions/Windows", + bus_name: str = "org.gnome.Shell", + ) -> None: + super().__init__(object_path=object_path, bus_name=bus_name) + + def list_(self) -> Message: + return new_method_call(self, "List") + + def get_title(self, win_id: int) -> Message: + return new_method_call(self, "GetTitle", "u", (win_id,)) + + def move_resize( # noqa: PLR0913 + self, win_id: int, x: int, y: int, width: int, height: int + ) -> Message: + return new_method_call( + self, "MoveResize", "uiiuu", (win_id, x, y, width, height) + ) + + +def move_window_via_gnome_shell_eval(title_id: str, position: Rect) -> bool: + """Move currently active window to a certain position. + + This is a workaround for not being able to reposition windows on wayland. + It only works on Gnome Shell. + + Args: + title_id: Window title (has to be unique) + position: Target geometry + + Returns: + If call was successful + """ + logger.debug( + "Moving window '%s' to %s via org.gnome.Shell.Eval", title_id, position + ) + js_code = f""" + const GLib = imports.gi.GLib; + global.get_window_actors().forEach(function (w) {{ + var mw = w.meta_window; + if (mw.get_title() == "{title_id}") {{ + mw.move_resize_frame( + 0, + {position.left}, + {position.top}, + {position.width}, + {position.height} + ); + }} + }}); + """ + try: + with open_dbus_connection() as router: + proxy = Proxy(DBusShell(), router) + response = proxy.eval_(script=js_code) + if not response[0]: + raise RuntimeError("DBus response was not OK!") # noqa: TRY301 + except Exception: + logger.warning("Failed to move window via org.gnome.Shell.Eval!") + return False + else: + return True + + +def move_window_via_gnome_shell_eval_qtdbus(title_id: str, position: Rect) -> bool: + """Move currently active window to a certain position. + + TODO: Deprecated. Remove, once jeepney is confirmed working! + + This is a workaround for not being able to reposition windows on wayland. + It only works on Gnome Shell. + + Args: + title_id: Window title (has to be unique) + position: Target geometry + + Returns: + If call was successful + """ + if sys.platform != "linux" or not QtDBus: + raise TypeError("QtDBus should only be called on Linux systems!") + + logger.debug( + "Moving window '%s' to %s via org.gnome.Shell.Eval", title_id, position + ) + js_code = f""" + const GLib = imports.gi.GLib; + global.get_window_actors().forEach(function (w) {{ + var mw = w.meta_window; + if (mw.get_title() == "{title_id}") {{ + mw.move_resize_frame( + 0, + {position.left}, + {position.top}, + {position.width}, + {position.height} + ); + }} + }}); + """ + item = "org.gnome.Shell" + interface = "org.gnome.Shell" + path = "/org/gnome/Shell" + + bus = QtDBus.QDBusConnection.sessionBus() + if not bus.isConnected(): + logger.error("Not connected to dbus!") + + shell_interface = QtDBus.QDBusInterface(item, path, interface, bus) + if not shell_interface.isValid(): + logger.warning("Invalid dbus interface on Gnome") + return False + + response = shell_interface.call("Eval", js_code) + success = response.arguments()[0] if response.arguments() else False + if response.errorName() or not success: + logger.error("Failed to move Window via org.gnome.Shell.Eval!") + logger.error("Error: %s", response.errorMessage()) + logger.error("Response arguments: %s", response.arguments()) + return False + + return True + + +def move_window_via_kde_kwin_scripting_qtdbus(title_id: str, position: Rect) -> bool: + """Move currently active window to a certain position. + + TODO: Migrate to Jeepney. + TODO: Deprecated. Remove, once jeepney is confirmed working! + + This is a workaround for not being able to reposition windows on wayland. + It only works on KDE. + + Args: + title_id: Window title (has to be unique) + position: Target geometry + + Returns: + If call was successful + """ + if sys.platform != "linux" or not QtDBus: + raise TypeError("QtDBus should only be called on Linux systems!") + + logger.debug( + "Moving window '%s' to %s via org.kde.kwin.Scripting", title_id, position + ) + js_code = f""" + const clients = workspace.clientList(); + for (var i = 0; i < clients.length; i++) {{ + if (clients[i].caption() == "{title_id}" ) {{ + clients[i].geometry = {{ + "x": {position.left}, + "y": {position.top}, + "width": {position.width}, + "height": {position.height} + }}; + }} + }} + """ + with tempfile.NamedTemporaryFile(delete=True, suffix=".js") as script_file: + script_file.write(js_code.encode()) + + bus = QtDBus.QDBusConnection.sessionBus() + if not bus.isConnected(): + logger.error("Not connected to dbus!") + return False + + item = "org.kde.KWin" + interface = "org.kde.kwin.Scripting" + path = "/Scripting" + shell_interface = QtDBus.QDBusInterface(item, path, interface, bus) + + # FIXME: shell_interface is not valid on latest KDE in Fedora 36. + if not shell_interface.isValid(): + logger.warning("Invalid dbus interface on KDE") + return False + + x = shell_interface.call("loadScript", script_file.name) + y = shell_interface.call("start") + logger.debug("KWin loadScript response: %s", x.arguments()) + logger.debug("KWin start response: %s", y.arguments()) + if x.errorName() or y.errorName(): + logger.error("Failed to move Window via org.kde.kwin.Scripting!") + logger.error(x.errorMessage(), y.errorMessage()) + + return True + + +def move_windows_via_window_calls_extension(title_id: str, position: Rect) -> bool: + """Move currently active window to a certain position. + + This is a workaround for not being able to reposition windows on wayland. + It only works on Gnome and requires the Gnome Shell Extension 'Window Calls' + https://github.com/ickyicky/window-calls + + Args: + title_id: Window title (has to be unique) + position: Target geometry + + Returns: + If call was successful + """ + logger.debug( + "Moving window '%s' to %s via org.gnome.Shell.extensions.windows", + title_id, + position, + ) + try: + with open_dbus_connection() as router: + proxy = Proxy(DBusWindowCalls(), router) + + response = proxy.list_() + all_windows = json.loads(response[0]) + normcap_windows = [w for w in all_windows if w["pid"] == os.getpid()] + + window_id = None + for window in normcap_windows: + response = proxy.get_title(window["id"]) + window_title = response[0] + if window_title == title_id: + window_id = window["id"] + + response = proxy.move_resize( + window_id, + position.left, + position.top, + position.width, + position.height, + ) + except Exception: + logger.warning("Failed to move window via org.gnome.Shell.extensions.windows!") + logger.warning( + "If you experience issues with NormCap's in a multi monitor setting, " + "try installing the Gnome Shell Extension 'Window Calls' " + "from https://extensions.gnome.org/extension/4724/window-calls/" + ) + return False + else: + return True diff --git a/normcap/gui/models.py b/normcap/gui/models.py index b38787d0c..1a2ccc371 100644 --- a/normcap/gui/models.py +++ b/normcap/gui/models.py @@ -73,7 +73,7 @@ def pypi_json(self) -> str: @dataclass() class Rect: - """Rectangular selection on screen. + """Rectangle to represent section of screen. All points are inclusive (are part of the rectangle). """ diff --git a/normcap/gui/window.py b/normcap/gui/window.py index a476606f1..fe9ec4da3 100644 --- a/normcap/gui/window.py +++ b/normcap/gui/window.py @@ -12,24 +12,15 @@ import logging -import sys -import tempfile from dataclasses import dataclass -from typing import Any, Callable, Optional, cast +from typing import Callable, Optional, cast from PySide6 import QtCore, QtGui, QtWidgets -from normcap.gui import system_info +from normcap.gui import dbus, system_info from normcap.gui.models import CaptureMode, DesktopEnvironment, Rect, Screen from normcap.gui.settings import Settings -try: - from PySide6 import QtDBus - -except ImportError: - QtDBus = cast(Any, None) - - logger = logging.getLogger(__name__) @@ -116,124 +107,22 @@ def _move_to_position_on_wayland(self) -> None: client itself can't do this. However, there are DE dependent workarounds. """ if system_info.desktop_environment() == DesktopEnvironment.GNOME: - self._move_to_position_via_gnome_shell_eval() + result = dbus.move_windows_via_window_calls_extension( + title_id=self.windowTitle(), position=self.screen_ + ) + if not result: + dbus.move_window_via_gnome_shell_eval( + title_id=self.windowTitle(), position=self.screen_ + ) elif system_info.desktop_environment() == DesktopEnvironment.KDE: - self._move_to_position_via_kde_kwin_scripting() + dbus.move_window_via_kde_kwin_scripting_qtdbus( + title_id=self.windowTitle(), position=self.screen_ + ) else: logger.warning( "No window move method for %s", system_info.desktop_environment() ) - # TODO: Implement move method via window calls instead of Eval - # https://github.com/ickyicky/window-calls - - def _move_to_position_via_gnome_shell_eval(self) -> bool: - """Move currently active window to a certain position. - - This is a workaround for not being able to reposition windows on wayland. - It only works on Gnome Shell. - """ - if sys.platform != "linux" or not QtDBus: - raise TypeError("QtDBus should only be called on Linux systems!") - - logger.debug( - "Move window '%s' to %s via org.gnome.Shell.Eval", - self.windowTitle(), - self.screen_, - ) - js_code = f""" - const GLib = imports.gi.GLib; - global.get_window_actors().forEach(function (w) {{ - var mw = w.meta_window; - if (mw.get_title() == "{self.windowTitle()}") {{ - mw.move_resize_frame( - 0, - {self.screen_.left}, - {self.screen_.top}, - {self.screen_.width}, - {self.screen_.height} - ); - }} - }}); - """ - item = "org.gnome.Shell" - interface = "org.gnome.Shell" - path = "/org/gnome/Shell" - - bus = QtDBus.QDBusConnection.sessionBus() - if not bus.isConnected(): - logger.error("Not connected to dbus!") - - shell_interface = QtDBus.QDBusInterface(item, path, interface, bus) - if not shell_interface.isValid(): - logger.warning("Invalid dbus interface on Gnome") - return False - - response = shell_interface.call("Eval", js_code) - success = response.arguments()[0] if response.arguments() else False - if response.errorName() or not success: - logger.error("Failed to move Window via org.gnome.Shell.Eval!") - logger.error("Error: %s", response.errorMessage()) - logger.error("Response arguments: %s", response.arguments()) - return False - - return True - - def _move_to_position_via_kde_kwin_scripting(self) -> bool: - """Move currently active window to a certain position. - - This is a workaround for not being able to reposition windows on wayland. - It only works on KDE. - """ - if sys.platform != "linux" or not QtDBus: - raise TypeError("QtDBus should only be called on Linux systems!") - - logger.debug( - "Move window '%s' to %s via org.kde.kwin.Scripting", - self.windowTitle(), - self.screen_, - ) - js_code = f""" - const clients = workspace.clientList(); - for (var i = 0; i < clients.length; i++) {{ - if (clients[i].caption() == "{self.windowTitle()}" ) {{ - clients[i].geometry = {{ - "x": {self.screen_.left}, - "y": {self.screen_.top}, - "width": {self.screen_.width}, - "height": {self.screen_.height} - }}; - }} - }} - """ - with tempfile.NamedTemporaryFile(delete=True, suffix=".js") as script_file: - script_file.write(js_code.encode()) - - bus = QtDBus.QDBusConnection.sessionBus() - if not bus.isConnected(): - logger.error("Not connected to dbus!") - return False - - item = "org.kde.KWin" - interface = "org.kde.kwin.Scripting" - path = "/Scripting" - shell_interface = QtDBus.QDBusInterface(item, path, interface, bus) - - # FIXME: shell_interface is not valid on latest KDE in Fedora 36. - if not shell_interface.isValid(): - logger.warning("Invalid dbus interface on KDE") - return False - - x = shell_interface.call("loadScript", script_file.name) - y = shell_interface.call("start") - logger.debug("KWin loadScript response: %s", x.arguments()) - logger.debug("KWin start response: %s", y.arguments()) - if x.errorName() or y.errorName(): - logger.error("Failed to move Window via org.kde.kwin.Scripting!") - logger.error(x.errorMessage(), y.errorMessage()) - - return True - def set_fullscreen(self) -> None: """Set window to full screen using platform specific methods.""" logger.debug("Set window of screen %s to fullscreen", self.screen_.index) diff --git a/pyproject.toml b/pyproject.toml index ac6ce81dd..24cf2fac6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,11 @@ classifiers = [ "Operating System :: POSIX :: Linux", "Operating System :: MacOS", ] -dependencies = ["shiboken6==6.6.1", "PySide6-Essentials==6.6.1"] +dependencies = [ + "shiboken6==6.6.1", + "PySide6-Essentials==6.6.1", + "jeepney==0.8.0", +] [project.urls] Homepage = "https://dynobo.github.io/normcap/" @@ -100,7 +104,7 @@ version = "tbump {args:current-version}" bundle = ["locales-compile", "python bundle/build.py {args}"] [[tool.hatch.envs.all.matrix]] -python = ["3.9", "3.10", "3.11"] +python = ["3.9", "3.10", "3.11", "3.12"] [tool.hatch.envs.analysis] template = "analysis" @@ -230,7 +234,7 @@ sources = ["normcap"] icon = "bundle/imgs/normcap" installer_icon = "bundle/imgs/normcap_install" installer_background = "bundle/imgs/normcap_install_bg" -requires = ["PySide6-Essentials==6.6.1", "shiboken6==6.6.1"] +requires = ["PySide6-Essentials==6.6.1", "shiboken6==6.6.1", "jeepney==0.8.0"] cleanup_paths = [ # Globs "**/[pP]y[sS]ide6/*[qQ]t*[oO]pen[gG][lL]*",