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.
@@ -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()
@@ -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,12 @@
1
+ [Icon Theme]
2
+ Name=solstone
3
+ Comment=solstone observer tray icons
4
+ Inherits=hicolor
5
+ Directories=scalable/status
6
+
7
+ [scalable/status]
8
+ Size=32
9
+ Type=Scalable
10
+ MinSize=16
11
+ MaxSize=256
12
+ Context=Status
@@ -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>