linux observer
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add KDE Plasma Wayland monitor labeling via KScreen DBus

On KDE Plasma, GDK is typically unavailable so screencast segments
would fall through to generic monitor-N labels. Add KScreen DBus
backend (org.kde.kscreen.Backend) as a fallback for monitor connector
names and positions. Add size-based stream matching for portals that
report (0,0) positions.

+442 -15
+81 -4
src/solstone_linux/activity.py
··· 12 12 import logging 13 13 import os 14 14 15 + from dbus_next import Variant 15 16 from dbus_next.aio import MessageBus 16 17 17 18 logger = logging.getLogger(__name__) ··· 48 49 KDE_POWER_PATH = "/org/kde/Solid/PowerManagement" 49 50 KDE_POWER_IFACE = "org.kde.Solid.PowerManagement" 50 51 52 + # DBus service constants — monitor geometry (KDE) 53 + KSCREEN_BUS = "org.kde.KScreen" 54 + KSCREEN_PATH = "/backend" 55 + KSCREEN_IFACE = "org.kde.kscreen.Backend" 56 + 51 57 52 58 async def probe_activity_services(bus: MessageBus) -> dict[str, bool]: 53 59 """Check which activity DBus services are reachable.""" ··· 56 62 "gnome_screensaver": GNOME_SCREENSAVER_BUS, 57 63 "gnome_display_config": DISPLAY_CONFIG_BUS, 58 64 "kde_power": KDE_POWER_BUS, 65 + "kscreen": KSCREEN_BUS, 59 66 } 60 67 results = {} 61 68 for name, bus_name in services.items(): ··· 68 75 # Log grouped by function 69 76 lock_backends = ["fdo_screensaver", "gnome_screensaver"] 70 77 power_backends = ["gnome_display_config", "kde_power"] 78 + monitor_backends = ["kscreen"] 79 + results["gtk4"] = _HAS_GTK 71 80 72 81 def _status(keys): 73 82 return ", ".join(f"{k} [{'ok' if results[k] else 'missing'}]" for k in keys) 74 83 75 84 logger.info("Screen lock backends: %s", _status(lock_backends)) 76 85 logger.info("Power save backends: %s", _status(power_backends)) 86 + logger.info( 87 + "Monitor backends: %s, gtk4 [%s]", 88 + _status(monitor_backends), 89 + "ok" if results["gtk4"] else "missing", 90 + ) 77 91 78 92 any_lock = any(results[k] for k in lock_backends) 79 93 any_power = any(results[k] for k in power_backends) ··· 81 95 logger.warning( 82 96 "No activity backends available — running in always-capture mode" 83 97 ) 84 - 85 - results["gtk4"] = _HAS_GTK 86 - if not _HAS_GTK: 87 - logger.warning("GTK4 not available — monitor geometry labels will be missing") 88 98 89 99 return results 90 100 ··· 183 193 184 194 # Assign position labels using shared algorithm 185 195 return assign_monitor_positions(geometries) 196 + 197 + 198 + def _unwrap_variants(obj): 199 + """Recursively unwrap dbus-next Variants in nested DBus structures.""" 200 + if isinstance(obj, Variant): 201 + return _unwrap_variants(obj.value) 202 + if isinstance(obj, dict): 203 + return {key: _unwrap_variants(value) for key, value in obj.items()} 204 + if isinstance(obj, list): 205 + return [_unwrap_variants(value) for value in obj] 206 + if isinstance(obj, tuple): 207 + return tuple(_unwrap_variants(value) for value in obj) 208 + return obj 209 + 210 + 211 + async def get_monitor_geometries_kscreen(bus: MessageBus) -> list[dict]: 212 + """ 213 + Get monitor geometry information from KDE KScreen DBus. 214 + 215 + Returns: 216 + List of dicts with format: 217 + [{"id": "connector-id", "box": [x1, y1, x2, y2], "position": "center|left|right|..."}, ...] 218 + """ 219 + try: 220 + from .monitor_positions import assign_monitor_positions 221 + 222 + intro = await bus.introspect(KSCREEN_BUS, KSCREEN_PATH) 223 + obj = bus.get_proxy_object(KSCREEN_BUS, KSCREEN_PATH, intro) 224 + iface = obj.get_interface(KSCREEN_IFACE) 225 + config = _unwrap_variants(await iface.call_get_config()) 226 + outputs = config.get("outputs", {}) 227 + output_values = outputs.values() if isinstance(outputs, dict) else outputs 228 + 229 + geometries = [] 230 + for output in output_values: 231 + if not isinstance(output, dict): 232 + continue 233 + if not output.get("enabled") or not output.get("connected"): 234 + continue 235 + 236 + name = output.get("name") 237 + pos = output.get("pos", {}) 238 + size = output.get("size", {}) 239 + if not isinstance(name, str) or not isinstance(pos, dict): 240 + continue 241 + if not isinstance(size, dict): 242 + continue 243 + 244 + x = int(pos.get("x", 0)) 245 + y = int(pos.get("y", 0)) 246 + scale = float(output.get("scale", 1.0) or 1.0) 247 + width = int(size.get("width", 0)) 248 + height = int(size.get("height", 0)) 249 + logical_width = round(width / scale) 250 + logical_height = round(height / scale) 251 + geometries.append( 252 + { 253 + "id": name, 254 + "box": [x, y, x + logical_width, y + logical_height], 255 + } 256 + ) 257 + 258 + monitors = assign_monitor_positions(geometries) 259 + logger.debug("KScreen monitor geometries found: %d", len(monitors)) 260 + return monitors 261 + except Exception: 262 + return []
+73 -11
src/solstone_linux/screencast.py
··· 122 122 123 123 def _match_streams_to_monitors(streams: list[dict], monitors: list[dict]) -> list[dict]: 124 124 """ 125 - Match portal stream geometries to GDK monitor info. 125 + Match portal stream geometries to monitor info. 126 126 127 127 Portal streams have position (x, y) and size (width, height). 128 - GDK monitors have connector IDs and box coordinates. 128 + Monitors (from GDK or KScreen) have connector IDs and box coordinates. 129 129 130 130 Returns streams augmented with connector and position labels. 131 131 """ 132 132 matched = [] 133 + used_position_connectors = set() 134 + 135 + # Detect if all streams lack meaningful position data (KDE portal reports (0,0) for all) 136 + all_zero_position = True 137 + for stream in streams: 138 + props = stream.get("props", {}) 139 + pos = _variant_or_value(props.get("position", (0, 0))) 140 + if isinstance(pos, (tuple, list)) and len(pos) >= 2: 141 + if int(pos[0]) != 0 or int(pos[1]) != 0: 142 + all_zero_position = False 143 + break 133 144 134 145 for stream in streams: 135 146 props = stream.get("props", {}) ··· 152 163 best_match = None 153 164 best_overlap = 0 154 165 155 - for monitor in monitors: 156 - mx1, my1, mx2, my2 = monitor["box"] 157 - mw, mh = mx2 - mx1, my2 - my1 166 + if not all_zero_position: 167 + for monitor in monitors: 168 + if monitor["id"] in used_position_connectors: 169 + continue 158 170 159 - # Check if geometries match (within tolerance for scaling) 160 - if abs(sx - mx1) < 10 and abs(sy - my1) < 10: 161 - overlap = min(sw, mw) * min(sh, mh) 162 - if overlap > best_overlap: 163 - best_overlap = overlap 164 - best_match = monitor 171 + mx1, my1, mx2, my2 = monitor["box"] 172 + mw, mh = mx2 - mx1, my2 - my1 173 + 174 + # Check if geometries match (within tolerance for scaling) 175 + if abs(sx - mx1) < 10 and abs(sy - my1) < 10: 176 + overlap = min(sw, mw) * min(sh, mh) 177 + if overlap > best_overlap: 178 + best_overlap = overlap 179 + best_match = monitor 165 180 166 181 if best_match: 182 + used_position_connectors.add(best_match["id"]) 167 183 stream["connector"] = best_match["id"] 168 184 stream["position_label"] = best_match.get("position", "unknown") 169 185 stream["x"] = best_match["box"][0] ··· 181 197 182 198 matched.append(stream) 183 199 200 + unmatched_streams = [ 201 + stream 202 + for stream in matched 203 + if str(stream.get("connector", "")).startswith("monitor-") 204 + ] 205 + matched_connectors = { 206 + stream["connector"] 207 + for stream in matched 208 + if not str(stream.get("connector", "")).startswith("monitor-") 209 + } 210 + unmatched_monitors = [ 211 + monitor for monitor in monitors if monitor["id"] not in matched_connectors 212 + ] 213 + 214 + for stream in unmatched_streams: 215 + if not unmatched_monitors: 216 + break 217 + 218 + best_match = None 219 + sw, sh = stream["width"], stream["height"] 220 + for monitor in unmatched_monitors: 221 + mx1, my1, mx2, my2 = monitor["box"] 222 + mw, mh = mx2 - mx1, my2 - my1 223 + if abs(sw - mw) <= 2 and abs(sh - mh) <= 2: 224 + best_match = monitor 225 + break 226 + 227 + if best_match: 228 + stream["connector"] = best_match["id"] 229 + stream["position_label"] = best_match.get("position", "unknown") 230 + stream["x"] = best_match["box"][0] 231 + stream["y"] = best_match["box"][1] 232 + stream["width"] = best_match["box"][2] - best_match["box"][0] 233 + stream["height"] = best_match["box"][3] - best_match["box"][1] 234 + unmatched_monitors.remove(best_match) 235 + 184 236 return matched 185 237 186 238 ··· 257 309 except Exception as e: 258 310 logger.warning(f"Failed to get monitor geometries: {e}") 259 311 monitors = [] 312 + 313 + # Fall back to KScreen on KDE when GDK is unavailable 314 + if not monitors and self.bus: 315 + from .activity import get_monitor_geometries_kscreen 316 + 317 + try: 318 + monitors = await get_monitor_geometries_kscreen(self.bus) 319 + except Exception as e: 320 + logger.warning(f"KScreen monitor fallback failed: {e}") 321 + monitors = [] 260 322 261 323 # Get portal interface 262 324 root_intro = await self.bus.introspect(PORTAL_BUS, PORTAL_PATH)
+118
tests/test_activity.py
··· 205 205 assert results["gnome_screensaver"] is True 206 206 assert results["gnome_display_config"] is True 207 207 assert results["kde_power"] is True 208 + assert results["kscreen"] is True 208 209 assert results["gtk4"] is activity._HAS_GTK 209 210 210 211 @pytest.mark.asyncio ··· 219 220 assert results["gnome_screensaver"] is False 220 221 assert results["gnome_display_config"] is False 221 222 assert results["kde_power"] is False 223 + assert results["kscreen"] is False 222 224 assert "No activity backends available" in caplog.text 223 225 224 226 @pytest.mark.asyncio ··· 230 232 Exception("missing"), 231 233 object(), 232 234 Exception("missing"), 235 + object(), 233 236 ] 234 237 ) 235 238 ··· 239 242 assert results["gnome_screensaver"] is False 240 243 assert results["gnome_display_config"] is True 241 244 assert results["kde_power"] is False 245 + assert results["kscreen"] is True 246 + 247 + 248 + class TestGetMonitorGeometriesKscreen: 249 + """Test KDE KScreen monitor geometry detection.""" 250 + 251 + @pytest.mark.asyncio 252 + async def test_returns_monitors_from_kscreen_dbus(self): 253 + bus = MagicMock() 254 + bus.introspect = AsyncMock(return_value=object()) 255 + iface = MagicMock() 256 + iface.call_get_config = AsyncMock( 257 + return_value={ 258 + "outputs": { 259 + 1: { 260 + "enabled": True, 261 + "connected": True, 262 + "name": "DP-1", 263 + "pos": {"x": 0, "y": 0}, 264 + "size": {"width": 1920, "height": 1080}, 265 + "scale": 1.0, 266 + }, 267 + 2: { 268 + "enabled": True, 269 + "connected": True, 270 + "name": "DP-2", 271 + "pos": {"x": 1920, "y": 0}, 272 + "size": {"width": 2560, "height": 1440}, 273 + "scale": 1.0, 274 + }, 275 + } 276 + } 277 + ) 278 + bus.get_proxy_object.return_value = _make_proxy_with_interface(iface) 279 + 280 + result = await activity.get_monitor_geometries_kscreen(bus) 281 + 282 + assert result == [ 283 + {"id": "DP-1", "box": [0, 0, 1920, 1080], "position": "left"}, 284 + {"id": "DP-2", "box": [1920, 0, 4480, 1440], "position": "right"}, 285 + ] 286 + bus.introspect.assert_awaited_once_with( 287 + activity.KSCREEN_BUS, activity.KSCREEN_PATH 288 + ) 289 + 290 + @pytest.mark.asyncio 291 + async def test_skips_disabled_outputs(self): 292 + bus = MagicMock() 293 + bus.introspect = AsyncMock(return_value=object()) 294 + iface = MagicMock() 295 + iface.call_get_config = AsyncMock( 296 + return_value={ 297 + "outputs": { 298 + 1: { 299 + "enabled": True, 300 + "connected": True, 301 + "name": "DP-1", 302 + "pos": {"x": 0, "y": 0}, 303 + "size": {"width": 1920, "height": 1080}, 304 + "scale": 1.0, 305 + }, 306 + 2: { 307 + "enabled": False, 308 + "connected": True, 309 + "name": "DP-2", 310 + "pos": {"x": 1920, "y": 0}, 311 + "size": {"width": 2560, "height": 1440}, 312 + "scale": 1.0, 313 + }, 314 + } 315 + } 316 + ) 317 + bus.get_proxy_object.return_value = _make_proxy_with_interface(iface) 318 + 319 + result = await activity.get_monitor_geometries_kscreen(bus) 320 + 321 + assert result == [ 322 + {"id": "DP-1", "box": [0, 0, 1920, 1080], "position": "center"} 323 + ] 324 + 325 + @pytest.mark.asyncio 326 + async def test_returns_empty_on_dbus_failure(self): 327 + bus = MagicMock() 328 + bus.introspect = AsyncMock(side_effect=Exception("missing")) 329 + 330 + result = await activity.get_monitor_geometries_kscreen(bus) 331 + 332 + assert result == [] 333 + 334 + @pytest.mark.asyncio 335 + async def test_applies_scale_factor(self): 336 + bus = MagicMock() 337 + bus.introspect = AsyncMock(return_value=object()) 338 + iface = MagicMock() 339 + iface.call_get_config = AsyncMock( 340 + return_value={ 341 + "outputs": { 342 + 1: { 343 + "enabled": True, 344 + "connected": True, 345 + "name": "DP-1", 346 + "pos": {"x": 0, "y": 0}, 347 + "size": {"width": 3840, "height": 2160}, 348 + "scale": 2.0, 349 + } 350 + } 351 + } 352 + ) 353 + bus.get_proxy_object.return_value = _make_proxy_with_interface(iface) 354 + 355 + result = await activity.get_monitor_geometries_kscreen(bus) 356 + 357 + assert result == [ 358 + {"id": "DP-1", "box": [0, 0, 1920, 1080], "position": "center"} 359 + ]
+170
tests/test_screencast.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for portal screencast stream matching.""" 5 + 6 + from solstone_linux.screencast import _match_streams_to_monitors 7 + 8 + 9 + class TestMatchStreamsToMonitors: 10 + """Test matching portal streams to monitor metadata.""" 11 + 12 + def test_position_based_matching(self): 13 + streams = [ 14 + { 15 + "idx": 0, 16 + "node_id": 10, 17 + "props": {"position": (0, 0), "size": (1920, 1080)}, 18 + }, 19 + { 20 + "idx": 1, 21 + "node_id": 11, 22 + "props": {"position": (1920, 0), "size": (2560, 1440)}, 23 + }, 24 + ] 25 + monitors = [ 26 + {"id": "DP-1", "box": [0, 0, 1920, 1080], "position": "left"}, 27 + {"id": "DP-2", "box": [1920, 0, 4480, 1440], "position": "right"}, 28 + ] 29 + 30 + result = _match_streams_to_monitors(streams, monitors) 31 + 32 + assert result[0]["connector"] == "DP-1" 33 + assert result[0]["position_label"] == "left" 34 + assert result[0]["x"] == 0 35 + assert result[0]["y"] == 0 36 + assert result[0]["width"] == 1920 37 + assert result[0]["height"] == 1080 38 + assert result[1]["connector"] == "DP-2" 39 + assert result[1]["position_label"] == "right" 40 + assert result[1]["x"] == 1920 41 + assert result[1]["y"] == 0 42 + assert result[1]["width"] == 2560 43 + assert result[1]["height"] == 1440 44 + 45 + def test_size_based_fallback_when_no_position(self): 46 + streams = [ 47 + { 48 + "idx": 0, 49 + "node_id": 10, 50 + "props": {"position": (0, 0), "size": (1920, 1080)}, 51 + }, 52 + { 53 + "idx": 1, 54 + "node_id": 11, 55 + "props": {"position": (0, 0), "size": (2560, 1440)}, 56 + }, 57 + ] 58 + monitors = [ 59 + {"id": "DP-1", "box": [20, 0, 1940, 1080], "position": "left"}, 60 + {"id": "DP-2", "box": [1940, 0, 4500, 1440], "position": "right"}, 61 + ] 62 + 63 + result = _match_streams_to_monitors(streams, monitors) 64 + 65 + assert result[0]["connector"] == "DP-1" 66 + assert result[0]["position_label"] == "left" 67 + assert result[0]["x"] == 20 68 + assert result[0]["width"] == 1920 69 + assert result[1]["connector"] == "DP-2" 70 + assert result[1]["position_label"] == "right" 71 + assert result[1]["x"] == 1940 72 + assert result[1]["width"] == 2560 73 + 74 + def test_position_match_skipped_when_all_zero(self): 75 + streams = [ 76 + { 77 + "idx": 0, 78 + "node_id": 10, 79 + "props": {"position": (0, 0), "size": (2560, 1440)}, 80 + }, 81 + { 82 + "idx": 1, 83 + "node_id": 11, 84 + "props": {"position": (0, 0), "size": (1920, 1080)}, 85 + }, 86 + ] 87 + monitors = [ 88 + {"id": "DP-1", "box": [0, 0, 1920, 1080], "position": "left"}, 89 + {"id": "DP-2", "box": [1920, 0, 4480, 1440], "position": "right"}, 90 + ] 91 + 92 + result = _match_streams_to_monitors(streams, monitors) 93 + 94 + assert result[0]["connector"] == "DP-2" 95 + assert result[0]["position_label"] == "right" 96 + assert result[0]["x"] == 1920 97 + assert result[0]["width"] == 2560 98 + assert result[1]["connector"] == "DP-1" 99 + assert result[1]["position_label"] == "left" 100 + assert result[1]["x"] == 0 101 + assert result[1]["width"] == 1920 102 + 103 + def test_ambiguous_size_assigns_in_order(self): 104 + streams = [ 105 + { 106 + "idx": 0, 107 + "node_id": 10, 108 + "props": {"position": (0, 0), "size": (1920, 1080)}, 109 + }, 110 + { 111 + "idx": 1, 112 + "node_id": 11, 113 + "props": {"position": (0, 0), "size": (1920, 1080)}, 114 + }, 115 + ] 116 + monitors = [ 117 + {"id": "DP-1", "box": [20, 0, 1940, 1080], "position": "left"}, 118 + {"id": "DP-2", "box": [1940, 0, 3860, 1080], "position": "right"}, 119 + ] 120 + 121 + result = _match_streams_to_monitors(streams, monitors) 122 + 123 + assert result[0]["connector"] == "DP-1" 124 + assert result[1]["connector"] == "DP-2" 125 + 126 + def test_no_monitors_falls_back_to_monitor_idx(self): 127 + streams = [ 128 + { 129 + "idx": 0, 130 + "node_id": 10, 131 + "props": {"position": (0, 0), "size": (1920, 1080)}, 132 + }, 133 + { 134 + "idx": 1, 135 + "node_id": 11, 136 + "props": {"position": (1920, 0), "size": (2560, 1440)}, 137 + }, 138 + ] 139 + 140 + result = _match_streams_to_monitors(streams, []) 141 + 142 + assert result[0]["connector"] == "monitor-0" 143 + assert result[0]["position_label"] == "unknown" 144 + assert result[1]["connector"] == "monitor-1" 145 + assert result[1]["position_label"] == "unknown" 146 + 147 + def test_mixed_position_and_size_matching(self): 148 + streams = [ 149 + { 150 + "idx": 0, 151 + "node_id": 10, 152 + "props": {"position": (0, 0), "size": (1920, 1080)}, 153 + }, 154 + { 155 + "idx": 1, 156 + "node_id": 11, 157 + "props": {"position": (0, 0), "size": (2560, 1440)}, 158 + }, 159 + ] 160 + monitors = [ 161 + {"id": "DP-1", "box": [0, 0, 1920, 1080], "position": "left"}, 162 + {"id": "DP-2", "box": [1920, 0, 4480, 1440], "position": "right"}, 163 + ] 164 + 165 + result = _match_streams_to_monitors(streams, monitors) 166 + 167 + assert result[0]["connector"] == "DP-1" 168 + assert result[0]["position_label"] == "left" 169 + assert result[1]["connector"] == "DP-2" 170 + assert result[1]["position_label"] == "right"