From 94aafa90fdf2d9c8d5921f239f3bf83405231614 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:54:50 +0000 Subject: [PATCH] Expose DBus Interface to Notify Solaar of Active Window Changes This commit adds a DBus interface that allows external tools, such as window managers or scripts, to notify Solaar about active window changes. This enhancement is particularly useful for implementing workarounds on non-GNOME Wayland desktops, where adding methods to exist DBus session may be unavailable. For example, the active window class can now be passed from a KWin script to Solaar via the new DBus interface. --- docs/external_window_tracking.md | 116 ++++++++++++++++++++++ lib/logitech_receiver/diversion.py | 113 ++++++++++++++++++--- lib/solaar/dbus.py | 81 +++++++++++++++ lib/solaar/ui/__init__.py | 9 ++ tests/logitech_receiver/test_diversion.py | 110 ++++++++++++++++++++ 5 files changed, 415 insertions(+), 14 deletions(-) create mode 100644 docs/external_window_tracking.md diff --git a/docs/external_window_tracking.md b/docs/external_window_tracking.md new file mode 100644 index 00000000..926e4e6f --- /dev/null +++ b/docs/external_window_tracking.md @@ -0,0 +1,116 @@ +# External Window Tracking + +Starting from this version, Solaar provides an alternative method for window tracking that allows external services to notify Solaar about active windows and windows under the pointer. + +## Background + +Previously, Solaar could only track windows in two ways: +1. Using X11 APIs (when running under X11) +2. Using the Solaar GNOME Shell extension (when running under Wayland with GNOME) + +This limitation made it difficult to use window-based rules in other desktop environments like KDE Plasma on Wayland. + +## New DBus Methods + +Solaar now exposes two DBus methods that external services can call to provide window information: + +### UpdateActiveWindow + +Updates the active window information. + +**Interface**: `io.github.pwr_solaar.solaar` +**Object Path**: `/io/github/pwr_solaar/solaar` +**Method**: `UpdateActiveWindow(s wm_class)` +**Parameters**: +- `wm_class` (string): The WM_CLASS or application identifier of the active window + +### UpdatePointerOverWindow + +Updates the window under the pointer. + +**Interface**: `io.github.pwr_solaar.solaar` +**Object Path**: `/io/github/pwr_solaar/solaar` +**Method**: `UpdatePointerOverWindow(s wm_class)` +**Parameters**: +- `wm_class` (string): The WM_CLASS or application identifier of the window under the pointer + +## Usage Examples + +### From Command Line + +You can test the functionality using `dbus-send`: + +```bash +# Update active window +dbus-send --session --dest=io.github.pwr_solaar.solaar \ + --type=method_call \ + /io/github/pwr_solaar/solaar \ + io.github.pwr_solaar.solaar.UpdateActiveWindow \ + string:"firefox" + +# Update pointer-over window +dbus-send --session --dest=io.github.pwr_solaar.solaar \ + --type=method_call \ + /io/github/pwr_solaar/solaar \ + io.github.pwr_solaar.solaar.UpdatePointerOverWindow \ + string:"konsole" +``` + +### From KDE/KWin Script + +You can create a KWin script to automatically notify Solaar about window changes: + +```javascript +// KWin Script to notify Solaar about active window changes +workspace.clientActivated.connect(function(client) { + if (client) { + // Get the window class + var wmClass = client.resourceClass.toString(); + + // Call Solaar's DBus method + callDBus( + "io.github.pwr_solaar.solaar", + "/io/github/pwr_solaar/solaar", + "io.github.pwr_solaar.solaar", + "UpdateActiveWindow", + wmClass + ); + } +}); +``` + +### From Python Script + +```python +import dbus + +# Connect to session bus +bus = dbus.SessionBus() + +# Get Solaar service +solaar = bus.get_object( + 'io.github.pwr_solaar.solaar', + '/io/github/pwr_solaar/solaar' +) + +# Update active window +solaar.UpdateActiveWindow('firefox', dbus_interface='io.github.pwr_solaar.solaar') + +# Update pointer-over window +solaar.UpdatePointerOverWindow('konsole', dbus_interface='io.github.pwr_solaar.solaar') +``` + +## How It Works + +When you call `UpdateActiveWindow` or `UpdatePointerOverWindow`, Solaar caches the provided window information. When rules with `Process` or `MouseProcess` conditions are evaluated, Solaar checks for cached values first before trying other methods (X11 or GNOME Shell extension). + +This means: +1. External services have priority - if they provide window information, Solaar uses it +2. If no cached value is available, Solaar falls back to X11 (if not on Wayland) +3. As a last resort, Solaar tries the GNOME Shell extension (if on Wayland) + +## Notes + +- The cached window information persists until it's updated again or Solaar is restarted +- External services should update the window information whenever the active window or pointer position changes +- The `wm_class` parameter should match the application identifier used in your Solaar rules diff --git a/lib/logitech_receiver/diversion.py b/lib/logitech_receiver/diversion.py index 5974334f..d75600bf 100644 --- a/lib/logitech_receiver/diversion.py +++ b/lib/logitech_receiver/diversion.py @@ -145,6 +145,10 @@ thumb_wheel_displacement = 0 _dbus_interface = None +# Cached window information for alternative window tracking +_cached_active_window = None +_cached_pointer_over_window = None + class XkbDisplay(ctypes.Structure): """opaque struct""" @@ -202,14 +206,47 @@ def gnome_dbus_interface_setup(): remote_object = bus.get_object("org.gnome.Shell", "/io/github/pwr_solaar/solaar") _dbus_interface = dbus.Interface(remote_object, "io.github.pwr_solaar.solaar") except dbus.exceptions.DBusException: - logger.warning( - "Solaar Gnome extension not installed - some rule capabilities inoperable", - exc_info=sys.exc_info(), - ) + logger.info("Solaar Gnome extension not available - using alternative window tracking methods") + _dbus_interface = False + except Exception as e: + logger.warning("Failed to setup GNOME D-Bus interface: %s", e) _dbus_interface = False return _dbus_interface +def update_active_window(wm_class): + """Update the cached active window information. + + This method allows external services to notify Solaar about the active window + instead of Solaar querying the window manager. This is useful in environments + like KDE where merging methods into the GNOME Shell service is not possible. + + Args: + wm_class: The WM_CLASS of the active window, typically a string. + """ + global _cached_active_window + _cached_active_window = wm_class + if logger.isEnabledFor(logging.DEBUG): + logger.debug("updated cached active window: %s", wm_class) + + +def update_pointer_over_window(wm_class): + """Update the cached pointer-over window information. + + This method allows external services to notify Solaar about the window under + the pointer instead of Solaar querying the window manager. This is useful in + environments like KDE where merging methods into the GNOME Shell service is + not possible. + + Args: + wm_class: The WM_CLASS of the window under the pointer, typically a string. + """ + global _cached_pointer_over_window + _cached_pointer_over_window = wm_class + if logger.isEnabledFor(logging.DEBUG): + logger.debug("updated cached pointer-over window: %s", wm_class) + + def xkb_setup(): global X11Lib, Xkbdisplay if Xkbdisplay is not None: @@ -677,14 +714,60 @@ def gnome_dbus_pointer_prog(): return (wm_class,) if wm_class else None +def get_active_window_info(): + """Get active window information using available methods. + + This function tries multiple approaches in the following order: + 1. Check if external service has provided cached value via UpdateActiveWindow + 2. Use X11 if available and not in Wayland + 3. Use GNOME Shell extension if in Wayland + + Returns: + Tuple of window information or None if not available. + """ + global _cached_active_window + # First, check if external service has provided cached value + if _cached_active_window is not None: + return (_cached_active_window,) + # Try X11 if not in Wayland + if not wayland: + return x11_focus_prog() + # Otherwise try GNOME Shell extension + return gnome_dbus_focus_prog() + + +def get_pointer_window_info(): + """Get pointer-over window information using available methods. + + This function tries multiple approaches in the following order: + 1. Check if external service has provided cached value via UpdatePointerOverWindow + 2. Use X11 if available and not in Wayland + 3. Use GNOME Shell extension if in Wayland + + Returns: + Tuple of window information or None if not available. + """ + global _cached_pointer_over_window + # First, check if external service has provided cached value + if _cached_pointer_over_window is not None: + return (_cached_pointer_over_window,) + # Try X11 if not in Wayland + if not wayland: + return x11_pointer_prog() + # Otherwise try GNOME Shell extension + return gnome_dbus_pointer_prog() + + class Process(Condition): def __init__(self, process, warn=True): self.process = process + # Only warn if neither X11 nor GNOME extension is available + # External services can still provide window information via UpdateActiveWindow DBus method if (not wayland and not x11_setup()) or (wayland and not gnome_dbus_interface_setup()): - if warn: - logger.warning( - "rules can only access active process in X11 or in Wayland under GNOME with Solaar Gnome " - "extension - %s", + if warn and logger.isEnabledFor(logging.INFO): + logger.info( + "rules will rely on external service calling UpdateActiveWindow DBus method " + "(X11 and GNOME Shell extension not available) - %s", self, ) if not isinstance(process, str): @@ -700,7 +783,7 @@ class Process(Condition): logger.debug("evaluate condition: %s", self) if not isinstance(self.process, str): return False - focus = x11_focus_prog() if not wayland else gnome_dbus_focus_prog() + focus = get_active_window_info() result = any(bool(s and s.startswith(self.process)) for s in focus) if focus else None return result @@ -711,11 +794,13 @@ class Process(Condition): class MouseProcess(Condition): def __init__(self, process, warn=True): self.process = process + # Only warn if neither X11 nor GNOME extension is available + # External services can still provide window information via UpdatePointerOverWindow DBus method if (not wayland and not x11_setup()) or (wayland and not gnome_dbus_interface_setup()): - if warn: - logger.warning( - "rules cannot access active mouse process " - "in X11 or in Wayland under GNOME with Solaar Extension for GNOME - %s", + if warn and logger.isEnabledFor(logging.INFO): + logger.info( + "rules will rely on external service calling UpdatePointerOverWindow DBus method " + "(X11 and GNOME Shell extension not available) - %s", self, ) if not isinstance(process, str): @@ -731,7 +816,7 @@ class MouseProcess(Condition): logger.debug("evaluate condition: %s", self) if not isinstance(self.process, str): return False - pointer_focus = x11_pointer_prog() if not wayland else gnome_dbus_pointer_prog() + pointer_focus = get_pointer_window_info() result = any(bool(s and s.startswith(self.process)) for s in pointer_focus) if pointer_focus else None return result diff --git a/lib/solaar/dbus.py b/lib/solaar/dbus.py index 142b5904..57c69eaa 100644 --- a/lib/solaar/dbus.py +++ b/lib/solaar/dbus.py @@ -85,3 +85,84 @@ def watch_bluez_connect(serial, callback=None): _bluetooth_callbacks[serial] = bus.add_signal_receiver( callback, "PropertiesChanged", path=path, dbus_interface=_BLUETOOTH_INTERFACE ) + + +# Solaar DBus service registration ID +_solaar_service_id = None + +# DBus interface XML for window tracking methods +SOLAAR_DBUS_INTERFACE = """ + + + + + + + + + + +""" + + +def setup_solaar_dbus_service(connection): + """Setup DBus service for Solaar to allow external services to notify about window changes. + + This service exposes methods UpdateActiveWindow and UpdatePointerOverWindow that can be + called by external services (e.g., KDE scripts) to notify Solaar about window changes. + + Args: + connection: The DBus connection from GTK.Application.get_dbus_connection() + """ + global _solaar_service_id + if _solaar_service_id is not None: + return _solaar_service_id + + if connection is None: + logger.warning("no DBus connection available, window tracking methods not registered") + _solaar_service_id = False + return False + + try: + from gi.repository import Gio + from logitech_receiver import diversion + + # Parse the interface XML + node_info = Gio.DBusNodeInfo.new_for_xml(SOLAAR_DBUS_INTERFACE) + interface_info = node_info.interfaces[0] + + def handle_method_call(connection, sender, object_path, interface_name, method_name, parameters, invocation): + """Handle DBus method calls.""" + try: + if method_name == "UpdateActiveWindow": + wm_class = parameters[0] + diversion.update_active_window(wm_class) + invocation.return_value(None) + elif method_name == "UpdatePointerOverWindow": + wm_class = parameters[0] + diversion.update_pointer_over_window(wm_class) + invocation.return_value(None) + else: + invocation.return_error_literal( + Gio.dbus_error_quark(), Gio.DBusError.UNKNOWN_METHOD, f"Unknown method: {method_name}" + ) + except Exception as e: + logger.error("error handling DBus method call %s: %s", method_name, e) + invocation.return_error_literal(Gio.dbus_error_quark(), Gio.DBusError.FAILED, f"Internal error: {str(e)}") + + # Register the object on the connection + _solaar_service_id = connection.register_object( + "/io/github/pwr_solaar/solaar", interface_info, handle_method_call, None, None + ) + + if _solaar_service_id: + logger.info("Solaar DBus service methods registered at /io/github/pwr_solaar/solaar") + else: + logger.warning("failed to register Solaar DBus service methods") + _solaar_service_id = False + + return _solaar_service_id + except Exception as e: + logger.warning("failed to set up Solaar DBus service: %s", e) + _solaar_service_id = False + return False diff --git a/lib/solaar/ui/__init__.py b/lib/solaar/ui/__init__.py index c97104cc..0123b1bb 100644 --- a/lib/solaar/ui/__init__.py +++ b/lib/solaar/ui/__init__.py @@ -64,6 +64,15 @@ def _startup(app, startup_hook, use_tray, show_window): window.init(show_window, use_tray) startup_hook() + # Setup DBus service for external window tracking after app is registered + try: + from solaar import dbus as solaar_dbus + + connection = app.get_dbus_connection() + solaar_dbus.setup_solaar_dbus_service(connection) + except Exception as e: + logger.warning("failed to setup DBus service for window tracking: %s", e) + def _activate(app): logger.debug("activate") diff --git a/tests/logitech_receiver/test_diversion.py b/tests/logitech_receiver/test_diversion.py index 70aba163..760748f2 100644 --- a/tests/logitech_receiver/test_diversion.py +++ b/tests/logitech_receiver/test_diversion.py @@ -125,3 +125,113 @@ def test_process_notification(feature, data): ) diversion.process_notification(device_mock, notification, feature) + + +def test_update_active_window(): + """Test that update_active_window caches the window information.""" + # Clear any cached value + diversion._cached_active_window = None + + # Update the cached active window + test_wm_class = "test_application" + diversion.update_active_window(test_wm_class) + + # Verify it was cached + assert diversion._cached_active_window == test_wm_class + + +def test_update_pointer_over_window(): + """Test that update_pointer_over_window caches the window information.""" + # Clear any cached value + diversion._cached_pointer_over_window = None + + # Update the cached pointer-over window + test_wm_class = "test_pointer_application" + diversion.update_pointer_over_window(test_wm_class) + + # Verify it was cached + assert diversion._cached_pointer_over_window == test_wm_class + + +def test_get_active_window_info_with_cache(): + """Test that get_active_window_info returns cached value when available.""" + # Set a cached value + test_wm_class = "cached_app" + diversion._cached_active_window = test_wm_class + + # Get window info + result = diversion.get_active_window_info() + + # Should return the cached value as a tuple + assert result == (test_wm_class,) + + # Clean up + diversion._cached_active_window = None + + +def test_get_pointer_window_info_with_cache(): + """Test that get_pointer_window_info returns cached value when available.""" + # Set a cached value + test_wm_class = "cached_pointer_app" + diversion._cached_pointer_over_window = test_wm_class + + # Get window info + result = diversion.get_pointer_window_info() + + # Should return the cached value as a tuple + assert result == (test_wm_class,) + + # Clean up + diversion._cached_pointer_over_window = None + + +def test_process_condition_with_cached_window(): + """Test that Process condition works with cached window information.""" + # Set up cached window + test_wm_class = "firefox" + diversion._cached_active_window = test_wm_class + + # Create Process condition + process_condition = diversion.Process("fire", warn=False) + + # Create mock notification and device + notification = mock.Mock() + device = mock.Mock() + + # Evaluate - should match because "firefox" starts with "fire" + result = process_condition.evaluate(None, notification, device, None) + assert result is True + + # Test non-matching case + process_condition2 = diversion.Process("chrome", warn=False) + result2 = process_condition2.evaluate(None, notification, device, None) + assert result2 is False + + # Clean up + diversion._cached_active_window = None + + +def test_mouse_process_condition_with_cached_window(): + """Test that MouseProcess condition works with cached window information.""" + # Set up cached window + test_wm_class = "konsole" + diversion._cached_pointer_over_window = test_wm_class + + # Create MouseProcess condition + mouse_process_condition = diversion.MouseProcess("kon", warn=False) + + # Create mock notification and device + notification = mock.Mock() + device = mock.Mock() + + # Evaluate - should match because "konsole" starts with "kon" + result = mouse_process_condition.evaluate(None, notification, device, None) + assert result is True + + # Test non-matching case + mouse_process_condition2 = diversion.MouseProcess("term", warn=False) + result2 = mouse_process_condition2.evaluate(None, notification, device, None) + assert result2 is False + + # Clean up + diversion._cached_pointer_over_window = None