solstone-linux 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- solstone_linux/__init__.py +6 -0
- solstone_linux/activity.py +384 -0
- solstone_linux/audio_detect.py +79 -0
- solstone_linux/audio_mute.py +47 -0
- solstone_linux/audio_recorder.py +186 -0
- solstone_linux/chat_bridge.py +493 -0
- solstone_linux/cli.py +489 -0
- solstone_linux/config.py +130 -0
- solstone_linux/dbus_service.py +149 -0
- solstone_linux/dbusmenu.py +242 -0
- solstone_linux/doctor.py +277 -0
- solstone_linux/icons/hicolor/index.theme +12 -0
- solstone_linux/icons/hicolor/scalable/status/solstone-error.svg +17 -0
- solstone_linux/icons/hicolor/scalable/status/solstone-paused.svg +17 -0
- solstone_linux/icons/hicolor/scalable/status/solstone-recording.svg +7 -0
- solstone_linux/icons/hicolor/scalable/status/solstone-syncing.svg +16 -0
- solstone_linux/install_guard.py +210 -0
- solstone_linux/monitor_positions.py +110 -0
- solstone_linux/observer.py +757 -0
- solstone_linux/recovery.py +175 -0
- solstone_linux/screencast.py +572 -0
- solstone_linux/session_env.py +92 -0
- solstone_linux/sni.py +250 -0
- solstone_linux/solstone-linux.service.in +17 -0
- solstone_linux/streams.py +87 -0
- solstone_linux/sync.py +497 -0
- solstone_linux/tray.py +577 -0
- solstone_linux/upload.py +290 -0
- solstone_linux-0.1.0.dist-info/METADATA +73 -0
- solstone_linux-0.1.0.dist-info/RECORD +33 -0
- solstone_linux-0.1.0.dist-info/WHEEL +4 -0
- solstone_linux-0.1.0.dist-info/entry_points.txt +2 -0
- solstone_linux-0.1.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (c) 2026 sol pbc
|
|
3
|
+
# ruff: noqa: F722, F821
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from dbus_next import PropertyAccess, Variant
|
|
10
|
+
from dbus_next.service import (
|
|
11
|
+
ServiceInterface,
|
|
12
|
+
dbus_property,
|
|
13
|
+
method,
|
|
14
|
+
signal as dbus_signal,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
BUS_NAME = "org.solpbc.solstone.Observer1"
|
|
20
|
+
OBJECT_PATH = "/org/solpbc/solstone/Observer1"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ObserverService(ServiceInterface):
|
|
24
|
+
"""D-Bus service interface for the observer."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, observer):
|
|
27
|
+
super().__init__("org.solpbc.solstone.Observer1")
|
|
28
|
+
self._observer = observer
|
|
29
|
+
|
|
30
|
+
@dbus_property(access=PropertyAccess.READ)
|
|
31
|
+
def Status(self) -> "s":
|
|
32
|
+
if self._observer._paused:
|
|
33
|
+
return "paused"
|
|
34
|
+
if self._observer.current_mode == "screencast":
|
|
35
|
+
return "recording"
|
|
36
|
+
return "idle"
|
|
37
|
+
|
|
38
|
+
@dbus_property(access=PropertyAccess.READ)
|
|
39
|
+
def SyncStatus(self) -> "s":
|
|
40
|
+
if self._observer._sync:
|
|
41
|
+
return self._observer._sync.sync_status
|
|
42
|
+
return "synced"
|
|
43
|
+
|
|
44
|
+
@dbus_property(access=PropertyAccess.READ)
|
|
45
|
+
def SyncProgress(self) -> "s":
|
|
46
|
+
if self._observer._sync:
|
|
47
|
+
return self._observer._sync.sync_progress
|
|
48
|
+
return ""
|
|
49
|
+
|
|
50
|
+
@dbus_property(access=PropertyAccess.READ)
|
|
51
|
+
def CaptureDir(self) -> "s":
|
|
52
|
+
return str(self._observer.config.captures_dir)
|
|
53
|
+
|
|
54
|
+
@dbus_property(access=PropertyAccess.READ)
|
|
55
|
+
def SegmentTimer(self) -> "i":
|
|
56
|
+
if self._observer._paused or self._observer.segment_dir is None:
|
|
57
|
+
return 0
|
|
58
|
+
remaining = self._observer.interval - (
|
|
59
|
+
time.monotonic() - self._observer.start_at_mono
|
|
60
|
+
)
|
|
61
|
+
return max(0, int(remaining))
|
|
62
|
+
|
|
63
|
+
@dbus_property(access=PropertyAccess.READ)
|
|
64
|
+
def PauseRemaining(self) -> "i":
|
|
65
|
+
if not self._observer._paused or self._observer._pause_until <= 0:
|
|
66
|
+
return 0
|
|
67
|
+
return max(0, int(self._observer._pause_until - time.monotonic()))
|
|
68
|
+
|
|
69
|
+
@dbus_property(access=PropertyAccess.READ)
|
|
70
|
+
def Error(self) -> "s":
|
|
71
|
+
return ""
|
|
72
|
+
|
|
73
|
+
@dbus_property(access=PropertyAccess.READ)
|
|
74
|
+
def ServerUrl(self) -> "s":
|
|
75
|
+
return self._observer.config.server_url or ""
|
|
76
|
+
|
|
77
|
+
@dbus_property(access=PropertyAccess.READ)
|
|
78
|
+
def Stream(self) -> "s":
|
|
79
|
+
return self._observer.stream
|
|
80
|
+
|
|
81
|
+
@dbus_property(access=PropertyAccess.READ)
|
|
82
|
+
def SegmentInterval(self) -> "i":
|
|
83
|
+
return self._observer.interval
|
|
84
|
+
|
|
85
|
+
@method()
|
|
86
|
+
def Pause(self, duration_seconds: "i") -> "s":
|
|
87
|
+
self._observer.pause(duration_seconds)
|
|
88
|
+
return "ok"
|
|
89
|
+
|
|
90
|
+
@method()
|
|
91
|
+
def Resume(self) -> "s":
|
|
92
|
+
self._observer.resume()
|
|
93
|
+
return "ok"
|
|
94
|
+
|
|
95
|
+
@method()
|
|
96
|
+
def GetStats(self) -> "a{sv}":
|
|
97
|
+
captures_today = 0
|
|
98
|
+
total_size = 0
|
|
99
|
+
today = datetime.now().strftime("%Y%m%d")
|
|
100
|
+
captures_dir = self._observer.config.captures_dir
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
if captures_dir.exists():
|
|
104
|
+
for day_dir in captures_dir.iterdir():
|
|
105
|
+
if not day_dir.is_dir():
|
|
106
|
+
continue
|
|
107
|
+
for stream_dir in day_dir.iterdir():
|
|
108
|
+
if not stream_dir.is_dir():
|
|
109
|
+
continue
|
|
110
|
+
for seg_dir in stream_dir.iterdir():
|
|
111
|
+
if not seg_dir.is_dir():
|
|
112
|
+
continue
|
|
113
|
+
if seg_dir.name.endswith(".incomplete"):
|
|
114
|
+
continue
|
|
115
|
+
if seg_dir.name.endswith(".failed"):
|
|
116
|
+
continue
|
|
117
|
+
if day_dir.name == today:
|
|
118
|
+
captures_today += 1
|
|
119
|
+
for file_path in seg_dir.iterdir():
|
|
120
|
+
if file_path.is_file():
|
|
121
|
+
total_size += file_path.stat().st_size
|
|
122
|
+
except OSError:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
synced_days = 0
|
|
126
|
+
if self._observer._sync:
|
|
127
|
+
synced_days = len(self._observer._sync._synced_days)
|
|
128
|
+
|
|
129
|
+
total_size_mb = int(total_size / (1024 * 1024))
|
|
130
|
+
uptime_seconds = int(time.monotonic() - self._observer._start_mono)
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
"captures_today": Variant("i", captures_today),
|
|
134
|
+
"total_size_mb": Variant("i", total_size_mb),
|
|
135
|
+
"synced_days": Variant("i", synced_days),
|
|
136
|
+
"uptime_seconds": Variant("i", uptime_seconds),
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
@dbus_signal()
|
|
140
|
+
def StatusChanged(self, status) -> "s":
|
|
141
|
+
return status
|
|
142
|
+
|
|
143
|
+
@dbus_signal()
|
|
144
|
+
def SyncProgressChanged(self, progress) -> "s":
|
|
145
|
+
return progress
|
|
146
|
+
|
|
147
|
+
@dbus_signal()
|
|
148
|
+
def ErrorOccurred(self, message) -> "s":
|
|
149
|
+
return message
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (c) 2026 sol pbc
|
|
3
|
+
# ruff: noqa: F722, F821
|
|
4
|
+
"""com.canonical.dbusmenu implementation over dbus-next.
|
|
5
|
+
|
|
6
|
+
This implements the D-Bus menu protocol used by StatusNotifierItem
|
|
7
|
+
to export application menus to the desktop environment's tray host.
|
|
8
|
+
Both KDE Plasma and GNOME's AppIndicator extension consume this.
|
|
9
|
+
|
|
10
|
+
Reference: https://github.com/AyatanaIndicators/libdbusmenu/blob/master/libdbusmenu-glib/dbus-menu.xml
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
from dbus_next import PropertyAccess, Variant
|
|
16
|
+
from dbus_next.service import (
|
|
17
|
+
ServiceInterface,
|
|
18
|
+
dbus_property,
|
|
19
|
+
method,
|
|
20
|
+
signal as dbus_signal,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MenuItem:
|
|
27
|
+
"""A menu item in the dbusmenu tree."""
|
|
28
|
+
|
|
29
|
+
_next_id = 1
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
label="",
|
|
34
|
+
icon_name="",
|
|
35
|
+
enabled=True,
|
|
36
|
+
visible=True,
|
|
37
|
+
toggle_type="",
|
|
38
|
+
toggle_state=-1,
|
|
39
|
+
item_type="",
|
|
40
|
+
children_display="",
|
|
41
|
+
shortcut=None,
|
|
42
|
+
callback=None,
|
|
43
|
+
):
|
|
44
|
+
self.id = MenuItem._next_id
|
|
45
|
+
MenuItem._next_id += 1
|
|
46
|
+
self.label = label
|
|
47
|
+
self.icon_name = icon_name
|
|
48
|
+
self.enabled = enabled
|
|
49
|
+
self.visible = visible
|
|
50
|
+
self.toggle_type = toggle_type # "", "checkmark", "radio"
|
|
51
|
+
self.toggle_state = toggle_state # -1 = none, 0 = off, 1 = on
|
|
52
|
+
self.item_type = item_type # "" = standard, "separator"
|
|
53
|
+
self.children_display = children_display # "" or "submenu"
|
|
54
|
+
self.shortcut = shortcut
|
|
55
|
+
self.callback = callback
|
|
56
|
+
self.children: list["MenuItem"] = []
|
|
57
|
+
|
|
58
|
+
def get_properties(self) -> dict:
|
|
59
|
+
"""Return non-default properties as a dict of Variants."""
|
|
60
|
+
props = {}
|
|
61
|
+
if self.label:
|
|
62
|
+
props["label"] = Variant("s", self.label)
|
|
63
|
+
if self.icon_name:
|
|
64
|
+
props["icon-name"] = Variant("s", self.icon_name)
|
|
65
|
+
# Some hosts cache booleans and won't default missing keys back to True.
|
|
66
|
+
props["enabled"] = Variant("b", self.enabled)
|
|
67
|
+
props["visible"] = Variant("b", self.visible)
|
|
68
|
+
if self.toggle_type:
|
|
69
|
+
props["toggle-type"] = Variant("s", self.toggle_type)
|
|
70
|
+
props["toggle-state"] = Variant("i", self.toggle_state)
|
|
71
|
+
if self.item_type:
|
|
72
|
+
props["type"] = Variant("s", self.item_type)
|
|
73
|
+
if self.children_display:
|
|
74
|
+
props["children-display"] = Variant("s", self.children_display)
|
|
75
|
+
return props
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _separator():
|
|
79
|
+
"""Create a separator menu item."""
|
|
80
|
+
item = MenuItem(item_type="separator")
|
|
81
|
+
return item
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class DBusMenu(ServiceInterface):
|
|
85
|
+
"""com.canonical.dbusmenu service interface."""
|
|
86
|
+
|
|
87
|
+
def __init__(self):
|
|
88
|
+
super().__init__("com.canonical.dbusmenu")
|
|
89
|
+
self._revision = 1
|
|
90
|
+
self._root = MenuItem() # id 0 is root
|
|
91
|
+
self._root.id = 0
|
|
92
|
+
self._root.children_display = "submenu"
|
|
93
|
+
self._items: dict[int, MenuItem] = {0: self._root}
|
|
94
|
+
MenuItem._next_id = 1
|
|
95
|
+
|
|
96
|
+
def set_menu(self, items: list[MenuItem]):
|
|
97
|
+
"""Replace the entire menu tree."""
|
|
98
|
+
self._root.children = items
|
|
99
|
+
self._items = {0: self._root}
|
|
100
|
+
self._register_items(items)
|
|
101
|
+
self._revision += 1
|
|
102
|
+
self.LayoutUpdated(self._revision, 0)
|
|
103
|
+
|
|
104
|
+
def update_properties(self, item: MenuItem, *names: str):
|
|
105
|
+
if not names:
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
updated = {name: self._property_variant(item, name) for name in names}
|
|
109
|
+
self.ItemsPropertiesUpdated([[item.id, updated]], [])
|
|
110
|
+
|
|
111
|
+
def _register_items(self, items: list[MenuItem]):
|
|
112
|
+
for item in items:
|
|
113
|
+
self._items[item.id] = item
|
|
114
|
+
if item.children:
|
|
115
|
+
self._register_items(item.children)
|
|
116
|
+
|
|
117
|
+
def _property_variant(self, item: MenuItem, name: str) -> Variant:
|
|
118
|
+
if name == "label":
|
|
119
|
+
return Variant("s", item.label)
|
|
120
|
+
if name == "visible":
|
|
121
|
+
return Variant("b", item.visible)
|
|
122
|
+
if name == "enabled":
|
|
123
|
+
return Variant("b", item.enabled)
|
|
124
|
+
if name == "icon-name":
|
|
125
|
+
return Variant("s", item.icon_name)
|
|
126
|
+
if name == "toggle-state":
|
|
127
|
+
return Variant("i", item.toggle_state)
|
|
128
|
+
|
|
129
|
+
raise ValueError(f"unsupported menu property: {name}")
|
|
130
|
+
|
|
131
|
+
def _build_layout(self, item: MenuItem, depth: int, props: list[str]):
|
|
132
|
+
"""Build the (ia{sv}av) layout tuple for GetLayout."""
|
|
133
|
+
item_props = item.get_properties()
|
|
134
|
+
if props:
|
|
135
|
+
item_props = {k: v for k, v in item_props.items() if k in props}
|
|
136
|
+
|
|
137
|
+
children_variants = []
|
|
138
|
+
if depth != 0 and item.children:
|
|
139
|
+
for child in item.children:
|
|
140
|
+
child_layout = self._build_layout(
|
|
141
|
+
child,
|
|
142
|
+
depth - 1 if depth > 0 else -1,
|
|
143
|
+
props,
|
|
144
|
+
)
|
|
145
|
+
children_variants.append(Variant("(ia{sv}av)", child_layout))
|
|
146
|
+
|
|
147
|
+
return [item.id, item_props, children_variants]
|
|
148
|
+
|
|
149
|
+
# ── D-Bus Methods ──
|
|
150
|
+
|
|
151
|
+
@method()
|
|
152
|
+
def GetLayout(
|
|
153
|
+
self, parent_id: "i", recursion_depth: "i", property_names: "as"
|
|
154
|
+
) -> "u(ia{sv}av)":
|
|
155
|
+
parent = self._items.get(parent_id, self._root)
|
|
156
|
+
layout = self._build_layout(parent, recursion_depth, property_names)
|
|
157
|
+
return [self._revision, layout]
|
|
158
|
+
|
|
159
|
+
@method()
|
|
160
|
+
def GetGroupProperties(self, ids: "ai", property_names: "as") -> "a(ia{sv})":
|
|
161
|
+
result = []
|
|
162
|
+
for item_id in ids:
|
|
163
|
+
item = self._items.get(item_id)
|
|
164
|
+
if item:
|
|
165
|
+
props = item.get_properties()
|
|
166
|
+
if property_names:
|
|
167
|
+
props = {k: v for k, v in props.items() if k in property_names}
|
|
168
|
+
result.append([item_id, props])
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
@method()
|
|
172
|
+
def GetProperty(self, item_id: "i", name: "s") -> "v":
|
|
173
|
+
item = self._items.get(item_id)
|
|
174
|
+
if item:
|
|
175
|
+
props = item.get_properties()
|
|
176
|
+
if name in props:
|
|
177
|
+
return props[name]
|
|
178
|
+
return Variant("s", "")
|
|
179
|
+
|
|
180
|
+
@method()
|
|
181
|
+
def Event(self, item_id: "i", event_id: "s", data: "v", timestamp: "u"):
|
|
182
|
+
item = self._items.get(item_id)
|
|
183
|
+
if item and event_id == "clicked" and item.callback:
|
|
184
|
+
log.info(f"Menu item clicked: {item.label!r} (id={item_id})")
|
|
185
|
+
item.callback()
|
|
186
|
+
elif item:
|
|
187
|
+
log.debug(f"Menu event: {event_id} on {item.label!r} (id={item_id})")
|
|
188
|
+
|
|
189
|
+
@method()
|
|
190
|
+
def EventGroup(self, events: "a(isvu)") -> "ai":
|
|
191
|
+
errors = []
|
|
192
|
+
for item_id, event_id, data, timestamp in events:
|
|
193
|
+
item = self._items.get(item_id)
|
|
194
|
+
if item and event_id == "clicked" and item.callback:
|
|
195
|
+
log.info(f"Menu item clicked: {item.label!r} (id={item_id})")
|
|
196
|
+
item.callback()
|
|
197
|
+
return errors
|
|
198
|
+
|
|
199
|
+
@method()
|
|
200
|
+
def AboutToShow(self, item_id: "i") -> "b":
|
|
201
|
+
return False # GetLayout always returns fresh state; no pending unsignaled changes.
|
|
202
|
+
|
|
203
|
+
@method()
|
|
204
|
+
def AboutToShowGroup(self, ids: "ai") -> "aiai":
|
|
205
|
+
return [[], []] # no updates, no errors
|
|
206
|
+
|
|
207
|
+
# ── D-Bus Properties ──
|
|
208
|
+
|
|
209
|
+
@dbus_property(access=PropertyAccess.READ)
|
|
210
|
+
def Version(self) -> "u":
|
|
211
|
+
return 3
|
|
212
|
+
|
|
213
|
+
@dbus_property(access=PropertyAccess.READ)
|
|
214
|
+
def TextDirection(self) -> "s":
|
|
215
|
+
return "ltr"
|
|
216
|
+
|
|
217
|
+
@dbus_property(access=PropertyAccess.READ)
|
|
218
|
+
def Status(self) -> "s":
|
|
219
|
+
return "normal"
|
|
220
|
+
|
|
221
|
+
@dbus_property(access=PropertyAccess.READ)
|
|
222
|
+
def IconThemePath(self) -> "as":
|
|
223
|
+
return []
|
|
224
|
+
|
|
225
|
+
# ── D-Bus Signals ──
|
|
226
|
+
|
|
227
|
+
@dbus_signal()
|
|
228
|
+
def ItemsPropertiesUpdated(self, updated_props, removed_props) -> "a(ia{sv})a(ias)":
|
|
229
|
+
return [updated_props, removed_props]
|
|
230
|
+
|
|
231
|
+
@dbus_signal()
|
|
232
|
+
def LayoutUpdated(self, revision, parent) -> "ui":
|
|
233
|
+
return [revision, parent]
|
|
234
|
+
|
|
235
|
+
@dbus_signal()
|
|
236
|
+
def ItemActivationRequested(self, item_id, timestamp) -> "iu":
|
|
237
|
+
return [item_id, timestamp]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def separator():
|
|
241
|
+
"""Create a separator menu item."""
|
|
242
|
+
return _separator()
|
solstone_linux/doctor.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (c) 2026 sol pbc
|
|
3
|
+
|
|
4
|
+
"""Install prerequisite checks for solstone-linux.
|
|
5
|
+
|
|
6
|
+
Exit code rule: fail anywhere -> 1; otherwise 0. Warn does not flip exit code.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Callable, NamedTuple
|
|
17
|
+
|
|
18
|
+
CheckResult = NamedTuple(
|
|
19
|
+
"CheckResult",
|
|
20
|
+
[("name", str), ("severity", str), ("detail", str)],
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_PORTAL_CHECK_TIMEOUT_SEC: float = 2.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check_python_version() -> CheckResult:
|
|
27
|
+
version = tuple(sys.version_info[:2])
|
|
28
|
+
if version >= (3, 10):
|
|
29
|
+
return CheckResult("python version", "ok", f"{version[0]}.{version[1]}")
|
|
30
|
+
return CheckResult(
|
|
31
|
+
"python version",
|
|
32
|
+
"fail",
|
|
33
|
+
f"need >=3.10, got {version[0]}.{version[1]}",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def check_gtk4_typelib() -> CheckResult:
|
|
38
|
+
try:
|
|
39
|
+
import gi
|
|
40
|
+
|
|
41
|
+
gi.require_version("Gtk", "4.0")
|
|
42
|
+
from gi.repository import Gtk # noqa: F401
|
|
43
|
+
except (ImportError, ValueError):
|
|
44
|
+
return CheckResult(
|
|
45
|
+
"gtk4 typelib",
|
|
46
|
+
"fail",
|
|
47
|
+
"install gir1.2-gtk-4.0 (or distro equivalent)",
|
|
48
|
+
)
|
|
49
|
+
return CheckResult("gtk4 typelib", "ok", "Gtk 4.0 available")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check_gstreamer() -> CheckResult:
|
|
53
|
+
if shutil.which("gst-launch-1.0") is None:
|
|
54
|
+
return CheckResult(
|
|
55
|
+
"gstreamer",
|
|
56
|
+
"fail",
|
|
57
|
+
"gst-launch-1.0 not on PATH; install gstreamer1.0-tools or equivalent",
|
|
58
|
+
)
|
|
59
|
+
try:
|
|
60
|
+
import gi
|
|
61
|
+
|
|
62
|
+
gi.require_version("Gst", "1.0")
|
|
63
|
+
from gi.repository import Gst # noqa: F401
|
|
64
|
+
except (ImportError, ValueError):
|
|
65
|
+
return CheckResult("gstreamer", "fail", "gir1.2-gstreamer-1.0 missing")
|
|
66
|
+
return CheckResult("gstreamer", "ok", "gst-launch-1.0 and Gst typelib available")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def check_cairo() -> CheckResult:
|
|
70
|
+
try:
|
|
71
|
+
import cairo # noqa: F401
|
|
72
|
+
except ImportError:
|
|
73
|
+
return CheckResult(
|
|
74
|
+
"cairo binding",
|
|
75
|
+
"fail",
|
|
76
|
+
"install python3-cairo (or distro equivalent)",
|
|
77
|
+
)
|
|
78
|
+
return CheckResult("cairo binding", "ok", "cairo import ok")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def check_session_type() -> CheckResult:
|
|
82
|
+
session_type = os.environ.get("XDG_SESSION_TYPE", "").lower()
|
|
83
|
+
if session_type == "wayland":
|
|
84
|
+
return CheckResult("session type", "ok", "wayland")
|
|
85
|
+
if session_type == "x11":
|
|
86
|
+
return CheckResult(
|
|
87
|
+
"session type",
|
|
88
|
+
"fail",
|
|
89
|
+
"x11 session; ScreenCast portal needs Wayland "
|
|
90
|
+
"(KDE's screencast protocol is Wayland-only)",
|
|
91
|
+
)
|
|
92
|
+
if not session_type:
|
|
93
|
+
return CheckResult(
|
|
94
|
+
"session type",
|
|
95
|
+
"warn",
|
|
96
|
+
"XDG_SESSION_TYPE not set; ScreenCast requires a Wayland session",
|
|
97
|
+
)
|
|
98
|
+
return CheckResult(
|
|
99
|
+
"session type",
|
|
100
|
+
"warn",
|
|
101
|
+
f"unrecognized session type '{session_type}'; Wayland required",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def check_pipewire() -> CheckResult:
|
|
106
|
+
try:
|
|
107
|
+
result = subprocess.run(
|
|
108
|
+
["pactl", "info"],
|
|
109
|
+
capture_output=True,
|
|
110
|
+
timeout=5,
|
|
111
|
+
text=True,
|
|
112
|
+
)
|
|
113
|
+
except FileNotFoundError:
|
|
114
|
+
return CheckResult(
|
|
115
|
+
"pipewire (pactl)",
|
|
116
|
+
"fail",
|
|
117
|
+
"pactl missing; install pipewire-pulse or pulseaudio-utils",
|
|
118
|
+
)
|
|
119
|
+
if result.returncode != 0:
|
|
120
|
+
detail = result.stderr.strip().splitlines()[0] if result.stderr.strip() else ""
|
|
121
|
+
return CheckResult("pipewire (pactl)", "fail", detail)
|
|
122
|
+
detail = result.stdout.strip().splitlines()[0] if result.stdout.strip() else ""
|
|
123
|
+
return CheckResult("pipewire (pactl)", "ok", detail)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def check_portal() -> CheckResult:
|
|
127
|
+
from dbus_next.aio import MessageBus
|
|
128
|
+
from dbus_next.constants import BusType
|
|
129
|
+
from dbus_next.errors import AuthError, DBusError, InvalidAddressError
|
|
130
|
+
|
|
131
|
+
async def _body() -> CheckResult:
|
|
132
|
+
bus = None
|
|
133
|
+
try:
|
|
134
|
+
try:
|
|
135
|
+
bus = await MessageBus(bus_type=BusType.SESSION).connect()
|
|
136
|
+
except (OSError, AuthError, InvalidAddressError, DBusError) as e:
|
|
137
|
+
return CheckResult(
|
|
138
|
+
"xdg-desktop-portal",
|
|
139
|
+
"fail",
|
|
140
|
+
f"session bus unreachable: {e}",
|
|
141
|
+
)
|
|
142
|
+
try:
|
|
143
|
+
intro = await bus.introspect(
|
|
144
|
+
"org.freedesktop.DBus", "/org/freedesktop/DBus"
|
|
145
|
+
)
|
|
146
|
+
obj = bus.get_proxy_object(
|
|
147
|
+
"org.freedesktop.DBus", "/org/freedesktop/DBus", intro
|
|
148
|
+
)
|
|
149
|
+
iface = obj.get_interface("org.freedesktop.DBus")
|
|
150
|
+
owned = await iface.call_name_has_owner(
|
|
151
|
+
"org.freedesktop.portal.Desktop"
|
|
152
|
+
)
|
|
153
|
+
except (DBusError, OSError) as e:
|
|
154
|
+
return CheckResult(
|
|
155
|
+
"xdg-desktop-portal",
|
|
156
|
+
"fail",
|
|
157
|
+
f"session bus unreachable: {e}",
|
|
158
|
+
)
|
|
159
|
+
if owned:
|
|
160
|
+
return CheckResult(
|
|
161
|
+
"xdg-desktop-portal",
|
|
162
|
+
"ok",
|
|
163
|
+
"org.freedesktop.portal.Desktop registered on session bus",
|
|
164
|
+
)
|
|
165
|
+
return CheckResult(
|
|
166
|
+
"xdg-desktop-portal",
|
|
167
|
+
"fail",
|
|
168
|
+
"org.freedesktop.portal.Desktop not registered on session bus",
|
|
169
|
+
)
|
|
170
|
+
finally:
|
|
171
|
+
if bus is not None:
|
|
172
|
+
bus.disconnect()
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
return await asyncio.wait_for(_body(), timeout=_PORTAL_CHECK_TIMEOUT_SEC)
|
|
176
|
+
except asyncio.TimeoutError:
|
|
177
|
+
return CheckResult(
|
|
178
|
+
"xdg-desktop-portal",
|
|
179
|
+
"fail",
|
|
180
|
+
f"timed out after {_PORTAL_CHECK_TIMEOUT_SEC:g}s",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def check_user_systemd() -> CheckResult:
|
|
185
|
+
try:
|
|
186
|
+
result = subprocess.run(
|
|
187
|
+
["systemctl", "--user", "is-system-running"],
|
|
188
|
+
capture_output=True,
|
|
189
|
+
timeout=5,
|
|
190
|
+
text=True,
|
|
191
|
+
)
|
|
192
|
+
except FileNotFoundError:
|
|
193
|
+
return CheckResult(
|
|
194
|
+
"systemd --user",
|
|
195
|
+
"fail",
|
|
196
|
+
"systemctl --user not reachable",
|
|
197
|
+
)
|
|
198
|
+
detail = result.stdout.strip().splitlines()[0] if result.stdout.strip() else ""
|
|
199
|
+
if detail:
|
|
200
|
+
return CheckResult("systemd --user", "ok", detail)
|
|
201
|
+
return CheckResult("systemd --user", "fail", "systemctl --user not reachable")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def check_pipx() -> CheckResult:
|
|
205
|
+
if shutil.which("pipx") is None:
|
|
206
|
+
return CheckResult(
|
|
207
|
+
"pipx",
|
|
208
|
+
"fail",
|
|
209
|
+
"pipx missing; install via 'python3 -m pip install --user pipx' or distro package",
|
|
210
|
+
)
|
|
211
|
+
return CheckResult("pipx", "ok", "pipx on PATH")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def check_appindicator_ext() -> CheckResult:
|
|
215
|
+
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "")
|
|
216
|
+
if "GNOME" not in desktop:
|
|
217
|
+
return CheckResult(
|
|
218
|
+
"appindicator ext (soft)",
|
|
219
|
+
"ok",
|
|
220
|
+
"not applicable (non-GNOME desktop)",
|
|
221
|
+
)
|
|
222
|
+
try:
|
|
223
|
+
result = subprocess.run(
|
|
224
|
+
["gnome-extensions", "list"],
|
|
225
|
+
capture_output=True,
|
|
226
|
+
timeout=5,
|
|
227
|
+
text=True,
|
|
228
|
+
)
|
|
229
|
+
except FileNotFoundError:
|
|
230
|
+
return CheckResult(
|
|
231
|
+
"appindicator ext (soft)",
|
|
232
|
+
"warn",
|
|
233
|
+
"install gnome-shell-extension-appindicator",
|
|
234
|
+
)
|
|
235
|
+
if "appindicator" in result.stdout.lower():
|
|
236
|
+
return CheckResult(
|
|
237
|
+
"appindicator ext (soft)", "ok", "appindicator extension present"
|
|
238
|
+
)
|
|
239
|
+
return CheckResult(
|
|
240
|
+
"appindicator ext (soft)",
|
|
241
|
+
"warn",
|
|
242
|
+
"install gnome-shell-extension-appindicator",
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def run_doctor() -> int:
|
|
247
|
+
checks: list[tuple[str, Callable[[], CheckResult]]] = [
|
|
248
|
+
("python version", check_python_version),
|
|
249
|
+
("session type", check_session_type),
|
|
250
|
+
("gtk4 typelib", check_gtk4_typelib),
|
|
251
|
+
("gstreamer", check_gstreamer),
|
|
252
|
+
("cairo binding", check_cairo),
|
|
253
|
+
("pipewire (pactl)", check_pipewire),
|
|
254
|
+
("xdg-desktop-portal", lambda: asyncio.run(check_portal())),
|
|
255
|
+
("systemd --user", check_user_systemd),
|
|
256
|
+
("pipx", check_pipx),
|
|
257
|
+
("appindicator ext (soft)", check_appindicator_ext),
|
|
258
|
+
]
|
|
259
|
+
fail_count = 0
|
|
260
|
+
warn_count = 0
|
|
261
|
+
|
|
262
|
+
for name, fn in checks:
|
|
263
|
+
try:
|
|
264
|
+
result = fn()
|
|
265
|
+
except Exception as e:
|
|
266
|
+
result = CheckResult(name, "fail", repr(e))
|
|
267
|
+
if not result.name or result.name != name:
|
|
268
|
+
result = CheckResult(name, result.severity, result.detail)
|
|
269
|
+
print(f"{result.severity:<4} {result.name:<28} {result.detail}")
|
|
270
|
+
if result.severity == "fail":
|
|
271
|
+
fail_count += 1
|
|
272
|
+
elif result.severity == "warn":
|
|
273
|
+
warn_count += 1
|
|
274
|
+
|
|
275
|
+
print()
|
|
276
|
+
print(f"doctor: {len(checks)} checks, {fail_count} failed, {warn_count} warnings")
|
|
277
|
+
return 1 if fail_count else 0
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="2.5 2.5 27 27"
|
|
2
|
+
role="img" aria-label="solstone icon error state">
|
|
3
|
+
<title>solstone — error</title>
|
|
4
|
+
<defs>
|
|
5
|
+
<mask id="ixko" maskUnits="userSpaceOnUse">
|
|
6
|
+
<rect x="2.5" y="2.5" width="27.0" height="27.0" fill="white"/>
|
|
7
|
+
<path d="M7 7 L25 25 M25 7 L7 25" fill="none" stroke="black"
|
|
8
|
+
stroke-width="5.5" stroke-linecap="round"/>
|
|
9
|
+
</mask>
|
|
10
|
+
</defs>
|
|
11
|
+
<g mask="url(#ixko)">
|
|
12
|
+
<path fill="#F5C740" d="M16.0 2.5 L18.6 7.3 A9.1 9.1 0 0 0 13.4 7.3 Z M23.9 5.1 L23.2 10.5 A9.1 9.1 0 0 0 19.0 7.4 Z M28.8 11.8 L25.1 15.8 A9.1 9.1 0 0 0 23.5 10.9 Z M28.8 20.2 L23.5 21.1 A9.1 9.1 0 0 0 25.1 16.2 Z M23.9 26.9 L19.0 24.6 A9.1 9.1 0 0 0 23.2 21.5 Z M16.0 29.5 L13.4 24.7 A9.1 9.1 0 0 0 18.6 24.7 Z M8.1 26.9 L8.8 21.5 A9.1 9.1 0 0 0 13.0 24.6 Z M3.2 20.2 L6.9 16.2 A9.1 9.1 0 0 0 8.5 21.1 Z M3.2 11.8 L8.5 10.9 A9.1 9.1 0 0 0 6.9 15.8 Z M8.1 5.1 L13.0 7.4 A9.1 9.1 0 0 0 8.8 10.5 Z"/>
|
|
13
|
+
<circle cx="16.0" cy="16.0" r="6.5" fill="none" stroke="#E8923A" stroke-width="1.7"/>
|
|
14
|
+
</g>
|
|
15
|
+
<path d="M7 7 L25 25 M25 7 L7 25" fill="none" stroke="#E8923A"
|
|
16
|
+
stroke-width="2.5" stroke-linecap="round"/>
|
|
17
|
+
</svg>
|