solstone-linux 0.1.1__tar.gz → 0.3.0__tar.gz
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-0.1.1 → solstone_linux-0.3.0}/CHANGELOG.md +22 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/INSTALL.md +1 -1
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/PKG-INFO +2 -2
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/README.md +1 -1
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/pyproject.toml +1 -1
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/__init__.py +1 -1
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/activity.py +115 -13
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/cli.py +52 -86
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/config.py +10 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/doctor.py +52 -8
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/observer.py +62 -32
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/screencast.py +192 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/solstone-linux.service.in +2 -3
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/sync.py +1 -4
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/tray.py +3 -2
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/upload.py +44 -53
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_activity.py +279 -8
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_cli.py +185 -6
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_config.py +71 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_doctor.py +107 -4
- solstone_linux-0.3.0/tests/test_observer.py +149 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_observer_emits_stream_silent_event.py +0 -1
- solstone_linux-0.3.0/tests/test_screencast.py +411 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_tray.py +16 -0
- solstone_linux-0.3.0/tests/test_upload.py +119 -0
- solstone_linux-0.1.1/tests/test_observer.py +0 -80
- solstone_linux-0.1.1/tests/test_screencast.py +0 -203
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/.gitignore +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/AGENTS.md +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/CLAUDE.md +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/LICENSE +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/Makefile +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/contrib/icons/hicolor/scalable/status/solstone-error.svg +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/contrib/icons/hicolor/scalable/status/solstone-paused.svg +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/contrib/icons/hicolor/scalable/status/solstone-recording.svg +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/contrib/icons/hicolor/scalable/status/solstone-syncing.svg +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/scripts/extract_changelog.sh +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/scripts/release.sh +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/audio_detect.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/audio_mute.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/audio_recorder.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/chat_bridge.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/dbus_service.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/dbusmenu.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-error.svg +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-paused.svg +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-recording.svg +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-syncing.svg +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/install_guard.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/monitor_positions.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/recovery.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/session_env.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/sni.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/streams.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/__init__.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_chat_bridge.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_dbus_service.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_dbusmenu.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_extract_changelog.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_install_guard.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_monitor_positions.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_screencast_stop_filters_silent_streams.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_session_env.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_streams.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_sync.py +0 -0
|
@@ -4,6 +4,28 @@ All notable changes to solstone-linux are documented here.
|
|
|
4
4
|
The format is based on Keep a Changelog (https://keepachangelog.com/),
|
|
5
5
|
and this project adheres to Semantic Versioning.
|
|
6
6
|
|
|
7
|
+
## [0.3.0] - 2026-06-14
|
|
8
|
+
|
|
9
|
+
setup is now zero-config: the observer connects to your journal automatically,
|
|
10
|
+
with no url to type.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- setup no longer asks for a journal url. if your journal runs on another
|
|
14
|
+
machine you reach directly, set its address with
|
|
15
|
+
`solstone-linux setup --server-url <url>`.
|
|
16
|
+
|
|
17
|
+
## [0.2.0] - 2026-06-13
|
|
18
|
+
|
|
19
|
+
setup is now hands-off: the first time the observer runs, it connects itself
|
|
20
|
+
to your journal automatically, with no separate key step.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **first run sets itself up.** earlier versions asked you to create and paste
|
|
25
|
+
a key to connect the observer to your journal. now the observer introduces
|
|
26
|
+
itself to your journal on first run and remembers the connection on its own.
|
|
27
|
+
you go straight from install to observing, with no manual key step.
|
|
28
|
+
|
|
7
29
|
## [0.1.1] - 2026-06-02
|
|
8
30
|
|
|
9
31
|
A focused maintenance release: two reliability fixes and a round of
|
|
@@ -79,7 +79,7 @@ this is the developer/from-source path; most installs should use the `pipx insta
|
|
|
79
79
|
```
|
|
80
80
|
solstone-linux setup
|
|
81
81
|
```
|
|
82
|
-
this prompts for the journal URL and
|
|
82
|
+
this prompts for the journal URL and registers the observer with your journal.
|
|
83
83
|
|
|
84
84
|
4. verify the service is running:
|
|
85
85
|
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: solstone-linux
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Standalone Linux desktop observer for solstone
|
|
5
5
|
License-Expression: AGPL-3.0-only
|
|
6
6
|
License-File: LICENSE
|
|
@@ -33,7 +33,7 @@ Standalone Linux desktop observer for [solstone](https://solpbc.org). Experience
|
|
|
33
33
|
|
|
34
34
|
**Arch:**
|
|
35
35
|
```
|
|
36
|
-
sudo pacman -S python-gobject gtk4 gstreamer gst-plugin-pipewire libpulse alsa-lib xdg-desktop-portal pipx
|
|
36
|
+
sudo pacman -S python-gobject gtk4 gstreamer gst-plugin-pipewire gst-plugins-good libpulse alsa-lib xdg-desktop-portal python-pipx uv python-cairo
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
**openSUSE:**
|
|
@@ -18,7 +18,7 @@ Standalone Linux desktop observer for [solstone](https://solpbc.org). Experience
|
|
|
18
18
|
|
|
19
19
|
**Arch:**
|
|
20
20
|
```
|
|
21
|
-
sudo pacman -S python-gobject gtk4 gstreamer gst-plugin-pipewire libpulse alsa-lib xdg-desktop-portal pipx
|
|
21
|
+
sudo pacman -S python-gobject gtk4 gstreamer gst-plugin-pipewire gst-plugins-good libpulse alsa-lib xdg-desktop-portal python-pipx uv python-cairo
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
**openSUSE:**
|
|
@@ -12,6 +12,9 @@ running regardless of desktop environment.
|
|
|
12
12
|
import asyncio
|
|
13
13
|
import logging
|
|
14
14
|
import os
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
15
18
|
|
|
16
19
|
from dbus_next import Variant
|
|
17
20
|
from dbus_next.aio import MessageBus
|
|
@@ -24,6 +27,7 @@ from dbus_next.errors import (
|
|
|
24
27
|
logger = logging.getLogger(__name__)
|
|
25
28
|
|
|
26
29
|
_DBUS_PROBE_TIMEOUT_SEC = 2.0
|
|
30
|
+
_POWER_SAVE_WARNED_BACKENDS: set[str] = set()
|
|
27
31
|
|
|
28
32
|
_SERVICE_MISSING_ERRORS = (
|
|
29
33
|
"org.freedesktop.DBus.Error.ServiceUnknown",
|
|
@@ -112,6 +116,83 @@ async def _name_has_owner(bus: MessageBus, bus_name: str) -> bool:
|
|
|
112
116
|
return False
|
|
113
117
|
|
|
114
118
|
|
|
119
|
+
def get_monitor_geometries_x11() -> list[dict]:
|
|
120
|
+
"""Get monitor geometry from xrandr (X11 only).
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
List of dicts with format:
|
|
124
|
+
[{"id": "connector-id", "box": [x1, y1, x2, y2], "position": "..."}, ...]
|
|
125
|
+
Empty list if xrandr is unavailable or returns no connected monitors.
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
result = subprocess.run(
|
|
129
|
+
["xrandr"],
|
|
130
|
+
capture_output=True,
|
|
131
|
+
text=True,
|
|
132
|
+
timeout=5,
|
|
133
|
+
)
|
|
134
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
135
|
+
return []
|
|
136
|
+
|
|
137
|
+
if result.returncode != 0:
|
|
138
|
+
return []
|
|
139
|
+
|
|
140
|
+
from .monitor_positions import assign_monitor_positions
|
|
141
|
+
|
|
142
|
+
monitors = []
|
|
143
|
+
for line in result.stdout.splitlines():
|
|
144
|
+
if " connected" not in line or "disconnected" in line:
|
|
145
|
+
continue
|
|
146
|
+
parts = line.split()
|
|
147
|
+
name = parts[0]
|
|
148
|
+
for part in parts:
|
|
149
|
+
m = re.match(r"(\d+)x(\d+)\+(-?\d+)\+(-?\d+)", part)
|
|
150
|
+
if m:
|
|
151
|
+
w, h = int(m.group(1)), int(m.group(2))
|
|
152
|
+
x, y = int(m.group(3)), int(m.group(4))
|
|
153
|
+
if x < 0 or y < 0:
|
|
154
|
+
logger.warning(
|
|
155
|
+
"Skipping monitor %s with negative offset (%d, %d); "
|
|
156
|
+
"ximagesrc requires non-negative coordinates",
|
|
157
|
+
name,
|
|
158
|
+
x,
|
|
159
|
+
y,
|
|
160
|
+
)
|
|
161
|
+
break
|
|
162
|
+
monitors.append({"id": name, "box": [x, y, x + w, y + h]})
|
|
163
|
+
break
|
|
164
|
+
|
|
165
|
+
return assign_monitor_positions(monitors)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def is_dpms_active() -> bool:
|
|
169
|
+
"""Check if DPMS has powered off the display (X11 only).
|
|
170
|
+
|
|
171
|
+
Runs xset q and parses the monitor state line.
|
|
172
|
+
Returns True if the display is in standby/suspend/off state, False otherwise.
|
|
173
|
+
Degrades gracefully to False when xset is unavailable or returns an error.
|
|
174
|
+
"""
|
|
175
|
+
try:
|
|
176
|
+
result = await asyncio.to_thread(
|
|
177
|
+
subprocess.run,
|
|
178
|
+
["xset", "q"],
|
|
179
|
+
capture_output=True,
|
|
180
|
+
text=True,
|
|
181
|
+
timeout=2,
|
|
182
|
+
)
|
|
183
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
if result.returncode != 0:
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
for line in result.stdout.splitlines():
|
|
190
|
+
stripped = line.strip()
|
|
191
|
+
if stripped.startswith("Monitor is"):
|
|
192
|
+
return stripped != "Monitor is On"
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
|
|
115
196
|
async def probe_activity_services(bus: MessageBus) -> dict[str, bool]:
|
|
116
197
|
"""Check which activity DBus services are reachable."""
|
|
117
198
|
services = {
|
|
@@ -125,17 +206,24 @@ async def probe_activity_services(bus: MessageBus) -> dict[str, bool]:
|
|
|
125
206
|
for name, bus_name in services.items():
|
|
126
207
|
results[name] = await _name_has_owner(bus, bus_name)
|
|
127
208
|
|
|
209
|
+
# DPMS is X11-only, checked via xset availability
|
|
210
|
+
results["dpms"] = bool(shutil.which("xset"))
|
|
211
|
+
results["gtk4"] = _HAS_GTK
|
|
212
|
+
|
|
128
213
|
# Log grouped by function
|
|
129
214
|
lock_backends = ["fdo_screensaver", "gnome_screensaver"]
|
|
130
215
|
power_backends = ["gnome_display_config", "kde_power"]
|
|
131
216
|
monitor_backends = ["kscreen"]
|
|
132
|
-
results["gtk4"] = _HAS_GTK
|
|
133
217
|
|
|
134
218
|
def _status(keys):
|
|
135
219
|
return ", ".join(f"{k} [{'ok' if results[k] else 'missing'}]" for k in keys)
|
|
136
220
|
|
|
137
221
|
logger.info("Screen lock backends: %s", _status(lock_backends))
|
|
138
|
-
logger.info(
|
|
222
|
+
logger.info(
|
|
223
|
+
"Power save backends: %s, dpms [%s]",
|
|
224
|
+
_status(power_backends),
|
|
225
|
+
"ok" if results["dpms"] else "missing",
|
|
226
|
+
)
|
|
139
227
|
logger.info(
|
|
140
228
|
"Monitor backends: %s, gtk4 [%s]",
|
|
141
229
|
_status(monitor_backends),
|
|
@@ -212,6 +300,22 @@ async def is_power_save_active(bus: MessageBus) -> bool:
|
|
|
212
300
|
|
|
213
301
|
Returns True if power save is active, False otherwise.
|
|
214
302
|
"""
|
|
303
|
+
|
|
304
|
+
def log_backend_failure_once(backend: str, bus_name: str, path: str, exc) -> None:
|
|
305
|
+
level = logger.warning
|
|
306
|
+
if backend in _POWER_SAVE_WARNED_BACKENDS:
|
|
307
|
+
level = logger.debug
|
|
308
|
+
else:
|
|
309
|
+
_POWER_SAVE_WARNED_BACKENDS.add(backend)
|
|
310
|
+
level(
|
|
311
|
+
"is_power_save_active %s backend failed: service=%s path=%s: %s: %s",
|
|
312
|
+
backend,
|
|
313
|
+
bus_name,
|
|
314
|
+
path,
|
|
315
|
+
type(exc).__name__,
|
|
316
|
+
exc,
|
|
317
|
+
)
|
|
318
|
+
|
|
215
319
|
# Try GNOME Mutter DisplayConfig first
|
|
216
320
|
try:
|
|
217
321
|
intro = await bus.introspect(DISPLAY_CONFIG_BUS, DISPLAY_CONFIG_PATH)
|
|
@@ -227,11 +331,10 @@ async def is_power_save_active(bus: MessageBus) -> bool:
|
|
|
227
331
|
OSError,
|
|
228
332
|
) as exc:
|
|
229
333
|
if not _is_service_missing(exc):
|
|
230
|
-
|
|
231
|
-
"
|
|
334
|
+
log_backend_failure_once(
|
|
335
|
+
"Mutter",
|
|
232
336
|
DISPLAY_CONFIG_BUS,
|
|
233
337
|
DISPLAY_CONFIG_PATH,
|
|
234
|
-
type(exc).__name__,
|
|
235
338
|
exc,
|
|
236
339
|
)
|
|
237
340
|
|
|
@@ -248,14 +351,13 @@ async def is_power_save_active(bus: MessageBus) -> bool:
|
|
|
248
351
|
OSError,
|
|
249
352
|
) as exc:
|
|
250
353
|
if not _is_service_missing(exc):
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
return False
|
|
354
|
+
log_backend_failure_once("KDE", KDE_POWER_BUS, KDE_POWER_PATH, exc)
|
|
355
|
+
|
|
356
|
+
# X11-only fallback: DPMS via xset
|
|
357
|
+
if os.environ.get("XDG_SESSION_TYPE", "").lower() == "x11":
|
|
358
|
+
return await is_dpms_active()
|
|
359
|
+
|
|
360
|
+
return False
|
|
259
361
|
|
|
260
362
|
|
|
261
363
|
def get_monitor_geometries() -> list[dict]:
|
|
@@ -25,7 +25,7 @@ import sys
|
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
|
|
27
27
|
from . import doctor, streams
|
|
28
|
-
from .config import load_config, save_config
|
|
28
|
+
from .config import DEFAULT_SERVER_URL, load_config, save_config
|
|
29
29
|
from .streams import stream_name
|
|
30
30
|
|
|
31
31
|
|
|
@@ -94,19 +94,10 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|
|
94
94
|
config = load_config()
|
|
95
95
|
|
|
96
96
|
server_url = getattr(args, "server_url", None) or config.server_url
|
|
97
|
-
if not server_url:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
)
|
|
102
|
-
return 2
|
|
103
|
-
default_url = config.server_url or ""
|
|
104
|
-
url = input(f"Solstone journal URL [{default_url}]: ").strip()
|
|
105
|
-
if url:
|
|
106
|
-
server_url = url
|
|
107
|
-
elif not config.server_url:
|
|
108
|
-
print("Error: journal URL is required", file=sys.stderr)
|
|
109
|
-
return 1
|
|
97
|
+
if not server_url and not non_interactive:
|
|
98
|
+
url = input(f"Solstone journal URL [{DEFAULT_SERVER_URL}]: ").strip()
|
|
99
|
+
server_url = url or DEFAULT_SERVER_URL
|
|
100
|
+
server_url = server_url or DEFAULT_SERVER_URL
|
|
110
101
|
config.server_url = server_url
|
|
111
102
|
|
|
112
103
|
stream_override = getattr(args, "stream_name", None)
|
|
@@ -134,44 +125,23 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|
|
134
125
|
)
|
|
135
126
|
return 0
|
|
136
127
|
|
|
137
|
-
print(f"Stream: {config.stream}")
|
|
138
128
|
save_config(config)
|
|
139
129
|
|
|
140
130
|
if not config.key:
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
data = json.loads(result.stdout)
|
|
153
|
-
config.key = data["key"]
|
|
154
|
-
save_config(config)
|
|
155
|
-
print(f"Registered (key: {config.key[:8]}...)")
|
|
156
|
-
else:
|
|
157
|
-
print("CLI registration failed, trying HTTP...")
|
|
158
|
-
except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, OSError):
|
|
159
|
-
print("CLI registration failed, trying HTTP...")
|
|
160
|
-
|
|
161
|
-
if not config.key:
|
|
162
|
-
print("Registering with your journal...")
|
|
163
|
-
client = UploadClient(config)
|
|
164
|
-
if client.ensure_registered(config):
|
|
165
|
-
config = load_config()
|
|
166
|
-
print(f"Registered (key: {config.key[:8]}...)")
|
|
167
|
-
else:
|
|
168
|
-
print(
|
|
169
|
-
"Warning: registration failed. Run setup again when your journal is available."
|
|
170
|
-
)
|
|
171
|
-
if non_interactive:
|
|
172
|
-
return 1
|
|
131
|
+
print("Registering with your journal...")
|
|
132
|
+
client = UploadClient(config)
|
|
133
|
+
if client.ensure_registered(config):
|
|
134
|
+
print(f"Registered (key: {config.key[:8]}...)")
|
|
135
|
+
print(f"Stream: {config.stream}")
|
|
136
|
+
else:
|
|
137
|
+
print(
|
|
138
|
+
"Warning: registration failed. Run setup again when your journal is available."
|
|
139
|
+
)
|
|
140
|
+
if non_interactive:
|
|
141
|
+
return 1
|
|
173
142
|
else:
|
|
174
143
|
print(f"Already registered (key: {config.key[:8]}...)")
|
|
144
|
+
print(f"Stream: {config.stream}")
|
|
175
145
|
|
|
176
146
|
print(f"\nConfig saved to {config.config_path}")
|
|
177
147
|
print(f"Captures will go to {config.captures_dir}")
|
|
@@ -182,19 +152,15 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|
|
182
152
|
|
|
183
153
|
|
|
184
154
|
def _cmd_setup_interactive() -> int:
|
|
185
|
-
# Keep the legacy no-flags setup path separate so its
|
|
155
|
+
# Keep the legacy no-flags setup path separate so its output stays stable.
|
|
186
156
|
from .upload import UploadClient
|
|
187
157
|
|
|
188
158
|
config = load_config()
|
|
189
159
|
|
|
190
160
|
# Prompt for server URL
|
|
191
|
-
default_url = config.server_url or
|
|
161
|
+
default_url = config.server_url or DEFAULT_SERVER_URL
|
|
192
162
|
url = input(f"Solstone journal URL [{default_url}]: ").strip()
|
|
193
|
-
|
|
194
|
-
config.server_url = url
|
|
195
|
-
elif not config.server_url:
|
|
196
|
-
print("Error: journal URL is required", file=sys.stderr)
|
|
197
|
-
return 1
|
|
163
|
+
config.server_url = url or default_url
|
|
198
164
|
|
|
199
165
|
# Derive stream name
|
|
200
166
|
if not config.stream:
|
|
@@ -203,46 +169,24 @@ def _cmd_setup_interactive() -> int:
|
|
|
203
169
|
except ValueError as e:
|
|
204
170
|
print(f"Error deriving stream name: {e}", file=sys.stderr)
|
|
205
171
|
return 1
|
|
206
|
-
print(f"Stream: {config.stream}")
|
|
207
172
|
|
|
208
173
|
# Save config before registration (so URL is persisted)
|
|
209
174
|
config.ensure_dirs()
|
|
210
175
|
save_config(config)
|
|
211
176
|
|
|
212
|
-
# Auto-register — try sol CLI first (no server needed), fall back to HTTP
|
|
213
177
|
if not config.key:
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
)
|
|
224
|
-
if result.returncode == 0:
|
|
225
|
-
data = json.loads(result.stdout)
|
|
226
|
-
config.key = data["key"]
|
|
227
|
-
save_config(config)
|
|
228
|
-
print(f"Registered (key: {config.key[:8]}...)")
|
|
229
|
-
else:
|
|
230
|
-
print("CLI registration failed, trying HTTP...")
|
|
231
|
-
except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, OSError):
|
|
232
|
-
print("CLI registration failed, trying HTTP...")
|
|
233
|
-
|
|
234
|
-
if not config.key:
|
|
235
|
-
print("Registering with your journal...")
|
|
236
|
-
client = UploadClient(config)
|
|
237
|
-
if client.ensure_registered(config):
|
|
238
|
-
config = load_config()
|
|
239
|
-
print(f"Registered (key: {config.key[:8]}...)")
|
|
240
|
-
else:
|
|
241
|
-
print(
|
|
242
|
-
"Warning: registration failed. Run setup again when your journal is available."
|
|
243
|
-
)
|
|
178
|
+
print("Registering with your journal...")
|
|
179
|
+
client = UploadClient(config)
|
|
180
|
+
if client.ensure_registered(config):
|
|
181
|
+
print(f"Registered (key: {config.key[:8]}...)")
|
|
182
|
+
print(f"Stream: {config.stream}")
|
|
183
|
+
else:
|
|
184
|
+
print(
|
|
185
|
+
"Warning: registration failed. Run setup again when your journal is available."
|
|
186
|
+
)
|
|
244
187
|
else:
|
|
245
188
|
print(f"Already registered (key: {config.key[:8]}...)")
|
|
189
|
+
print(f"Stream: {config.stream}")
|
|
246
190
|
|
|
247
191
|
print(f"\nConfig saved to {config.config_path}")
|
|
248
192
|
print(f"Captures will go to {config.captures_dir}")
|
|
@@ -284,6 +228,28 @@ def cmd_install_service(args: argparse.Namespace) -> int:
|
|
|
284
228
|
unit_path.write_text(unit)
|
|
285
229
|
print(f"Wrote {unit_path}")
|
|
286
230
|
|
|
231
|
+
# XDG autostart entry — X11 session managers that don't activate
|
|
232
|
+
# graphical-session.target (the systemd unit's WantedBy target) need this
|
|
233
|
+
# to autostart the service. On Wayland, `start` is a no-op when the
|
|
234
|
+
# service is already running.
|
|
235
|
+
autostart_dir = Path.home() / ".config" / "autostart"
|
|
236
|
+
autostart_dir.mkdir(parents=True, exist_ok=True)
|
|
237
|
+
autostart_path = autostart_dir / "solstone-linux.desktop"
|
|
238
|
+
autostart_path.write_text(
|
|
239
|
+
"[Desktop Entry]\n"
|
|
240
|
+
"Version=1.2\n"
|
|
241
|
+
"Type=Application\n"
|
|
242
|
+
"Name=Solstone Observer\n"
|
|
243
|
+
"Comment=Experience screen and audio with your solstone journal\n"
|
|
244
|
+
"Exec=/bin/sh -c 'systemctl --user import-environment"
|
|
245
|
+
" DISPLAY XAUTHORITY XDG_SESSION_TYPE 2>/dev/null;"
|
|
246
|
+
" systemctl --user start solstone-linux.service'\n"
|
|
247
|
+
"StartupNotify=false\n"
|
|
248
|
+
"X-GNOME-Autostart-enabled=true\n"
|
|
249
|
+
"Hidden=false\n"
|
|
250
|
+
)
|
|
251
|
+
print(f"Wrote {autostart_path}")
|
|
252
|
+
|
|
287
253
|
# Reload, enable, restart, and show status
|
|
288
254
|
try:
|
|
289
255
|
subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
|
|
@@ -20,6 +20,7 @@ from pathlib import Path
|
|
|
20
20
|
logger = logging.getLogger(__name__)
|
|
21
21
|
|
|
22
22
|
DEFAULT_BASE_DIR = Path.home() / ".local" / "share" / "solstone-linux"
|
|
23
|
+
DEFAULT_SERVER_URL = "http://localhost:5015"
|
|
23
24
|
DEFAULT_SEGMENT_INTERVAL = 300
|
|
24
25
|
DEFAULT_SYNC_RETRY_DELAYS = [5, 30, 120, 300]
|
|
25
26
|
DEFAULT_SYNC_MAX_RETRIES = 10
|
|
@@ -39,6 +40,9 @@ class Config:
|
|
|
39
40
|
sync_max_retries: int = DEFAULT_SYNC_MAX_RETRIES
|
|
40
41
|
cache_retention_days: int = 7
|
|
41
42
|
chat_bridge_enabled: bool = True
|
|
43
|
+
capture_framerate: int = 1
|
|
44
|
+
draw_cursor: bool = True
|
|
45
|
+
start_paused: bool = False
|
|
42
46
|
base_dir: Path = DEFAULT_BASE_DIR
|
|
43
47
|
|
|
44
48
|
@property
|
|
@@ -98,6 +102,9 @@ def load_config(base_dir: Path | None = None) -> Config:
|
|
|
98
102
|
except (TypeError, ValueError):
|
|
99
103
|
config.cache_retention_days = 7
|
|
100
104
|
config.chat_bridge_enabled = data.get("chat_bridge_enabled", True)
|
|
105
|
+
config.capture_framerate = max(1, min(int(data.get("capture_framerate", 1)), 10))
|
|
106
|
+
config.draw_cursor = bool(data.get("draw_cursor", True))
|
|
107
|
+
config.start_paused = bool(data.get("start_paused", False))
|
|
101
108
|
|
|
102
109
|
return config
|
|
103
110
|
|
|
@@ -115,6 +122,9 @@ def save_config(config: Config) -> None:
|
|
|
115
122
|
"sync_max_retries": config.sync_max_retries,
|
|
116
123
|
"cache_retention_days": config.cache_retention_days,
|
|
117
124
|
"chat_bridge_enabled": config.chat_bridge_enabled,
|
|
125
|
+
"capture_framerate": config.capture_framerate,
|
|
126
|
+
"draw_cursor": config.draw_cursor,
|
|
127
|
+
"start_paused": config.start_paused,
|
|
118
128
|
}
|
|
119
129
|
|
|
120
130
|
config_path = config.config_path
|
|
@@ -83,22 +83,17 @@ def check_session_type() -> CheckResult:
|
|
|
83
83
|
if session_type == "wayland":
|
|
84
84
|
return CheckResult("session type", "ok", "wayland")
|
|
85
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
|
-
)
|
|
86
|
+
return CheckResult("session type", "ok", "x11 (using ximagesrc capture)")
|
|
92
87
|
if not session_type:
|
|
93
88
|
return CheckResult(
|
|
94
89
|
"session type",
|
|
95
90
|
"warn",
|
|
96
|
-
"XDG_SESSION_TYPE not set;
|
|
91
|
+
"XDG_SESSION_TYPE not set; Wayland or X11 required",
|
|
97
92
|
)
|
|
98
93
|
return CheckResult(
|
|
99
94
|
"session type",
|
|
100
95
|
"warn",
|
|
101
|
-
f"unrecognized session type '{session_type}'; Wayland required",
|
|
96
|
+
f"unrecognized session type '{session_type}'; Wayland or X11 required",
|
|
102
97
|
)
|
|
103
98
|
|
|
104
99
|
|
|
@@ -162,6 +157,13 @@ async def check_portal() -> CheckResult:
|
|
|
162
157
|
"ok",
|
|
163
158
|
"org.freedesktop.portal.Desktop registered on session bus",
|
|
164
159
|
)
|
|
160
|
+
session_type = os.environ.get("XDG_SESSION_TYPE", "").lower()
|
|
161
|
+
if session_type == "x11":
|
|
162
|
+
return CheckResult(
|
|
163
|
+
"xdg-desktop-portal",
|
|
164
|
+
"warn",
|
|
165
|
+
"not registered — not needed on X11 (using ximagesrc)",
|
|
166
|
+
)
|
|
165
167
|
return CheckResult(
|
|
166
168
|
"xdg-desktop-portal",
|
|
167
169
|
"fail",
|
|
@@ -181,6 +183,47 @@ async def check_portal() -> CheckResult:
|
|
|
181
183
|
)
|
|
182
184
|
|
|
183
185
|
|
|
186
|
+
def check_x11_capture() -> CheckResult:
|
|
187
|
+
session_type = os.environ.get("XDG_SESSION_TYPE", "").lower()
|
|
188
|
+
if session_type == "wayland":
|
|
189
|
+
return CheckResult("x11 capture", "ok", "not applicable (wayland session)")
|
|
190
|
+
if not os.environ.get("DISPLAY"):
|
|
191
|
+
if session_type != "x11":
|
|
192
|
+
return CheckResult("x11 capture", "ok", "not applicable (no X11 display)")
|
|
193
|
+
return CheckResult("x11 capture", "fail", "DISPLAY not set")
|
|
194
|
+
if shutil.which("xrandr") is None:
|
|
195
|
+
return CheckResult(
|
|
196
|
+
"x11 capture",
|
|
197
|
+
"fail",
|
|
198
|
+
"xrandr not on PATH; install x11-xserver-utils or equivalent",
|
|
199
|
+
)
|
|
200
|
+
try:
|
|
201
|
+
result = subprocess.run(
|
|
202
|
+
["gst-inspect-1.0", "ximagesrc"],
|
|
203
|
+
capture_output=True,
|
|
204
|
+
timeout=5,
|
|
205
|
+
)
|
|
206
|
+
if result.returncode != 0:
|
|
207
|
+
return CheckResult(
|
|
208
|
+
"x11 capture",
|
|
209
|
+
"fail",
|
|
210
|
+
"ximagesrc plugin missing; install gstreamer1.0-plugins-good",
|
|
211
|
+
)
|
|
212
|
+
except FileNotFoundError:
|
|
213
|
+
return CheckResult(
|
|
214
|
+
"x11 capture",
|
|
215
|
+
"warn",
|
|
216
|
+
"could not verify ximagesrc (gst-inspect-1.0 not found)",
|
|
217
|
+
)
|
|
218
|
+
except subprocess.TimeoutExpired:
|
|
219
|
+
return CheckResult(
|
|
220
|
+
"x11 capture",
|
|
221
|
+
"warn",
|
|
222
|
+
"could not verify ximagesrc (gst-inspect-1.0 timed out)",
|
|
223
|
+
)
|
|
224
|
+
return CheckResult("x11 capture", "ok", "xrandr and ximagesrc available")
|
|
225
|
+
|
|
226
|
+
|
|
184
227
|
def check_user_systemd() -> CheckResult:
|
|
185
228
|
try:
|
|
186
229
|
result = subprocess.run(
|
|
@@ -252,6 +295,7 @@ def run_doctor() -> int:
|
|
|
252
295
|
("cairo binding", check_cairo),
|
|
253
296
|
("pipewire (pactl)", check_pipewire),
|
|
254
297
|
("xdg-desktop-portal", lambda: asyncio.run(check_portal())),
|
|
298
|
+
("x11 capture", check_x11_capture),
|
|
255
299
|
("systemd --user", check_user_systemd),
|
|
256
300
|
("pipx", check_pipx),
|
|
257
301
|
("appindicator ext (soft)", check_appindicator_ext),
|