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:
parent
ec5b406909
commit
94aafa90fd
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue