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.
This commit is contained in:
copilot-swe-agent[bot] 2025-11-05 14:54:50 +00:00 committed by SR_team
parent ec5b406909
commit 94aafa90fd
No known key found for this signature in database
GPG Key ID: EDAFEEC8A693407D
5 changed files with 415 additions and 14 deletions

View File

@ -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

View File

@ -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

View File

@ -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 = """
<node>
<interface name='io.github.pwr_solaar.solaar'>
<method name='UpdateActiveWindow'>
<arg type='s' name='wm_class' direction='in'/>
</method>
<method name='UpdatePointerOverWindow'>
<arg type='s' name='wm_class' direction='in'/>
</method>
</interface>
</node>
"""
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

View File

@ -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")

View File

@ -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