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.
Files changed (65) hide show
  1. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/CHANGELOG.md +22 -0
  2. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/INSTALL.md +1 -1
  3. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/PKG-INFO +2 -2
  4. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/README.md +1 -1
  5. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/pyproject.toml +1 -1
  6. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/__init__.py +1 -1
  7. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/activity.py +115 -13
  8. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/cli.py +52 -86
  9. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/config.py +10 -0
  10. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/doctor.py +52 -8
  11. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/observer.py +62 -32
  12. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/screencast.py +192 -0
  13. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/solstone-linux.service.in +2 -3
  14. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/sync.py +1 -4
  15. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/tray.py +3 -2
  16. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/upload.py +44 -53
  17. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_activity.py +279 -8
  18. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_cli.py +185 -6
  19. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_config.py +71 -0
  20. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_doctor.py +107 -4
  21. solstone_linux-0.3.0/tests/test_observer.py +149 -0
  22. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_observer_emits_stream_silent_event.py +0 -1
  23. solstone_linux-0.3.0/tests/test_screencast.py +411 -0
  24. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_tray.py +16 -0
  25. solstone_linux-0.3.0/tests/test_upload.py +119 -0
  26. solstone_linux-0.1.1/tests/test_observer.py +0 -80
  27. solstone_linux-0.1.1/tests/test_screencast.py +0 -203
  28. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/.gitignore +0 -0
  29. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/AGENTS.md +0 -0
  30. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/CLAUDE.md +0 -0
  31. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/LICENSE +0 -0
  32. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/Makefile +0 -0
  33. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/contrib/icons/hicolor/scalable/status/solstone-error.svg +0 -0
  34. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/contrib/icons/hicolor/scalable/status/solstone-paused.svg +0 -0
  35. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/contrib/icons/hicolor/scalable/status/solstone-recording.svg +0 -0
  36. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/contrib/icons/hicolor/scalable/status/solstone-syncing.svg +0 -0
  37. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/scripts/extract_changelog.sh +0 -0
  38. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/scripts/release.sh +0 -0
  39. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/audio_detect.py +0 -0
  40. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/audio_mute.py +0 -0
  41. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/audio_recorder.py +0 -0
  42. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/chat_bridge.py +0 -0
  43. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/dbus_service.py +0 -0
  44. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/dbusmenu.py +0 -0
  45. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-error.svg +0 -0
  46. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-paused.svg +0 -0
  47. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-recording.svg +0 -0
  48. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-syncing.svg +0 -0
  49. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/install_guard.py +0 -0
  50. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/monitor_positions.py +0 -0
  51. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/recovery.py +0 -0
  52. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/session_env.py +0 -0
  53. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/sni.py +0 -0
  54. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/src/solstone_linux/streams.py +0 -0
  55. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/__init__.py +0 -0
  56. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_chat_bridge.py +0 -0
  57. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_dbus_service.py +0 -0
  58. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_dbusmenu.py +0 -0
  59. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_extract_changelog.py +0 -0
  60. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_install_guard.py +0 -0
  61. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_monitor_positions.py +0 -0
  62. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_screencast_stop_filters_silent_streams.py +0 -0
  63. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_session_env.py +0 -0
  64. {solstone_linux-0.1.1 → solstone_linux-0.3.0}/tests/test_streams.py +0 -0
  65. {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 auto-registers via `sol` when available.
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.1.1
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:**
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "solstone-linux"
3
- version = "0.1.1"
3
+ version = "0.3.0"
4
4
  description = "Standalone Linux desktop observer for solstone"
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-only"
@@ -3,4 +3,4 @@
3
3
 
4
4
  """Standalone Linux desktop observer for solstone."""
5
5
 
6
- __version__ = "0.1.1"
6
+ __version__ = "0.3.0"
@@ -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("Power save backends: %s", _status(power_backends))
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
- logger.warning(
231
- "is_power_save_active Mutter backend failed: service=%s path=%s: %s: %s",
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
- logger.warning(
252
- "is_power_save_active KDE backend failed: service=%s path=%s: %s: %s",
253
- KDE_POWER_BUS,
254
- KDE_POWER_PATH,
255
- type(exc).__name__,
256
- exc,
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
- if non_interactive:
99
- print(
100
- "error: --server-url required with --non-interactive", file=sys.stderr
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
- sol = shutil.which("sol")
142
- if sol:
143
- print("Registering via sol CLI...")
144
- try:
145
- result = subprocess.run(
146
- [sol, "observer", "--json", "create", config.stream],
147
- capture_output=True,
148
- text=True,
149
- timeout=10,
150
- )
151
- if result.returncode == 0:
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 prompt/output stays byte-identical.
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
- if url:
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
- sol = shutil.which("sol")
215
- if sol:
216
- print("Registering via sol CLI...")
217
- try:
218
- result = subprocess.run(
219
- [sol, "observer", "--json", "create", config.stream],
220
- capture_output=True,
221
- text=True,
222
- timeout=10,
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; ScreenCast requires a Wayland session",
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),