solstone-linux 0.2.0__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.2.0 → solstone_linux-0.3.0}/CHANGELOG.md +10 -0
  2. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/PKG-INFO +2 -2
  3. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/README.md +1 -1
  4. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/pyproject.toml +1 -1
  5. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/__init__.py +1 -1
  6. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/activity.py +95 -3
  7. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/cli.py +30 -21
  8. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/config.py +10 -0
  9. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/doctor.py +52 -8
  10. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/observer.py +61 -30
  11. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/screencast.py +192 -0
  12. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/solstone-linux.service.in +2 -3
  13. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/tray.py +3 -2
  14. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/tests/test_activity.py +245 -9
  15. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/tests/test_cli.py +127 -6
  16. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/tests/test_config.py +71 -0
  17. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/tests/test_doctor.py +107 -4
  18. solstone_linux-0.3.0/tests/test_observer.py +149 -0
  19. solstone_linux-0.3.0/tests/test_screencast.py +411 -0
  20. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/tests/test_tray.py +16 -0
  21. solstone_linux-0.2.0/tests/test_observer.py +0 -80
  22. solstone_linux-0.2.0/tests/test_screencast.py +0 -203
  23. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/.gitignore +0 -0
  24. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/AGENTS.md +0 -0
  25. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/CLAUDE.md +0 -0
  26. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/INSTALL.md +0 -0
  27. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/LICENSE +0 -0
  28. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/Makefile +0 -0
  29. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/contrib/icons/hicolor/scalable/status/solstone-error.svg +0 -0
  30. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/contrib/icons/hicolor/scalable/status/solstone-paused.svg +0 -0
  31. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/contrib/icons/hicolor/scalable/status/solstone-recording.svg +0 -0
  32. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/contrib/icons/hicolor/scalable/status/solstone-syncing.svg +0 -0
  33. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/scripts/extract_changelog.sh +0 -0
  34. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/scripts/release.sh +0 -0
  35. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/audio_detect.py +0 -0
  36. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/audio_mute.py +0 -0
  37. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/audio_recorder.py +0 -0
  38. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/chat_bridge.py +0 -0
  39. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/dbus_service.py +0 -0
  40. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/dbusmenu.py +0 -0
  41. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-error.svg +0 -0
  42. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-paused.svg +0 -0
  43. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-recording.svg +0 -0
  44. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-syncing.svg +0 -0
  45. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/install_guard.py +0 -0
  46. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/monitor_positions.py +0 -0
  47. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/recovery.py +0 -0
  48. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/session_env.py +0 -0
  49. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/sni.py +0 -0
  50. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/streams.py +0 -0
  51. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/sync.py +0 -0
  52. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/src/solstone_linux/upload.py +0 -0
  53. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/tests/__init__.py +0 -0
  54. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/tests/test_chat_bridge.py +0 -0
  55. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/tests/test_dbus_service.py +0 -0
  56. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/tests/test_dbusmenu.py +0 -0
  57. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/tests/test_extract_changelog.py +0 -0
  58. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/tests/test_install_guard.py +0 -0
  59. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/tests/test_monitor_positions.py +0 -0
  60. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/tests/test_observer_emits_stream_silent_event.py +0 -0
  61. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/tests/test_screencast_stop_filters_silent_streams.py +0 -0
  62. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/tests/test_session_env.py +0 -0
  63. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/tests/test_streams.py +0 -0
  64. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/tests/test_sync.py +0 -0
  65. {solstone_linux-0.2.0 → solstone_linux-0.3.0}/tests/test_upload.py +0 -0
@@ -4,6 +4,16 @@ 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
+
7
17
  ## [0.2.0] - 2026-06-13
8
18
 
9
19
  setup is now hands-off: the first time the observer runs, it connects itself
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solstone-linux
3
- Version: 0.2.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:**
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "solstone-linux"
3
- version = "0.2.0"
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.2.0"
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
@@ -113,6 +116,83 @@ async def _name_has_owner(bus: MessageBus, bus_name: str) -> bool:
113
116
  return False
114
117
 
115
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
+
116
196
  async def probe_activity_services(bus: MessageBus) -> dict[str, bool]:
117
197
  """Check which activity DBus services are reachable."""
118
198
  services = {
@@ -126,17 +206,24 @@ async def probe_activity_services(bus: MessageBus) -> dict[str, bool]:
126
206
  for name, bus_name in services.items():
127
207
  results[name] = await _name_has_owner(bus, bus_name)
128
208
 
209
+ # DPMS is X11-only, checked via xset availability
210
+ results["dpms"] = bool(shutil.which("xset"))
211
+ results["gtk4"] = _HAS_GTK
212
+
129
213
  # Log grouped by function
130
214
  lock_backends = ["fdo_screensaver", "gnome_screensaver"]
131
215
  power_backends = ["gnome_display_config", "kde_power"]
132
216
  monitor_backends = ["kscreen"]
133
- results["gtk4"] = _HAS_GTK
134
217
 
135
218
  def _status(keys):
136
219
  return ", ".join(f"{k} [{'ok' if results[k] else 'missing'}]" for k in keys)
137
220
 
138
221
  logger.info("Screen lock backends: %s", _status(lock_backends))
139
- 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
+ )
140
227
  logger.info(
141
228
  "Monitor backends: %s, gtk4 [%s]",
142
229
  _status(monitor_backends),
@@ -265,7 +352,12 @@ async def is_power_save_active(bus: MessageBus) -> bool:
265
352
  ) as exc:
266
353
  if not _is_service_missing(exc):
267
354
  log_backend_failure_once("KDE", KDE_POWER_BUS, KDE_POWER_PATH, exc)
268
- return False
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
269
361
 
270
362
 
271
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)
@@ -161,19 +152,15 @@ def cmd_setup(args: argparse.Namespace) -> int:
161
152
 
162
153
 
163
154
  def _cmd_setup_interactive() -> int:
164
- # 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.
165
156
  from .upload import UploadClient
166
157
 
167
158
  config = load_config()
168
159
 
169
160
  # Prompt for server URL
170
- default_url = config.server_url or ""
161
+ default_url = config.server_url or DEFAULT_SERVER_URL
171
162
  url = input(f"Solstone journal URL [{default_url}]: ").strip()
172
- if url:
173
- config.server_url = url
174
- elif not config.server_url:
175
- print("Error: journal URL is required", file=sys.stderr)
176
- return 1
163
+ config.server_url = url or default_url
177
164
 
178
165
  # Derive stream name
179
166
  if not config.stream:
@@ -241,6 +228,28 @@ def cmd_install_service(args: argparse.Namespace) -> int:
241
228
  unit_path.write_text(unit)
242
229
  print(f"Wrote {unit_path}")
243
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
+
244
253
  # Reload, enable, restart, and show status
245
254
  try:
246
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),
@@ -42,12 +42,36 @@ from .audio_recorder import AudioRecorder
42
42
  from .chat_bridge import run_chat_bridge
43
43
  from .config import Config
44
44
  from .recovery import write_segment_metadata
45
- from .screencast import Screencaster, SilentStream, StreamInfo
45
+ from .screencast import Screencaster, SilentStream, StreamInfo, X11Screencaster
46
46
  from .sync import SyncService
47
47
  from .upload import UploadClient
48
48
 
49
49
  logger = logging.getLogger(__name__)
50
50
 
51
+
52
+ def _create_screencaster(config) -> "Screencaster | X11Screencaster":
53
+ """Return the appropriate screencaster for the current desktop session.
54
+
55
+ Selection order:
56
+ 1. XDG_SESSION_TYPE=x11 → X11Screencaster
57
+ 2. WAYLAND_DISPLAY set → Screencaster (portal/PipeWire)
58
+ 3. Only DISPLAY set → X11Screencaster (no Wayland available)
59
+ 4. Fallback → Screencaster (portal/PipeWire)
60
+ """
61
+ session_type = os.environ.get("XDG_SESSION_TYPE", "").lower()
62
+ if session_type == "x11":
63
+ logger.info("X11 session detected — using X11 screencaster")
64
+ return X11Screencaster()
65
+ if os.environ.get("WAYLAND_DISPLAY") or session_type == "wayland":
66
+ logger.info("Wayland session detected — using portal/PipeWire screencaster")
67
+ return Screencaster(config.restore_token_path)
68
+ if os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"):
69
+ logger.info("No Wayland display found — falling back to X11 screencaster")
70
+ return X11Screencaster()
71
+ logger.info("Using portal/PipeWire screencaster (default)")
72
+ return Screencaster(config.restore_token_path)
73
+
74
+
51
75
  # Host identification
52
76
  HOST = socket.gethostname()
53
77
  PLATFORM = platform.system().lower()
@@ -81,7 +105,7 @@ class Observer:
81
105
  self.config = config
82
106
  self.interval = config.segment_interval
83
107
  self.audio_recorder = AudioRecorder()
84
- self.screencaster = Screencaster(config.restore_token_path)
108
+ self.screencaster = _create_screencaster(config)
85
109
  self.bus: MessageBus | None = None
86
110
  self.running = True
87
111
  self.stream = config.stream
@@ -152,11 +176,11 @@ class Observer:
152
176
  # Probe which activity signals are available (logging only)
153
177
  await probe_activity_services(self.bus)
154
178
 
155
- # Verify portal is available (exit if not)
179
+ # Verify capture backend is available (exit if not)
156
180
  if not await self.screencaster.connect():
157
- logger.error("Screencast portal not available")
181
+ logger.error("Screencast capture backend not available")
158
182
  return False
159
- logger.info("Screencast portal connected")
183
+ logger.info("Screencast capture backend connected")
160
184
 
161
185
  # Initialize upload client and sync service
162
186
  self._client = UploadClient(self.config)
@@ -374,7 +398,9 @@ class Observer:
374
398
 
375
399
  try:
376
400
  streams = await self.screencaster.start(
377
- str(segment_dir), framerate=1, draw_cursor=True
401
+ str(segment_dir),
402
+ framerate=self.config.capture_framerate,
403
+ draw_cursor=self.config.draw_cursor,
378
404
  )
379
405
  except RuntimeError as e:
380
406
  logger.error(f"Failed to start screencast: {e}")
@@ -519,30 +545,35 @@ class Observer:
519
545
  self.segment_is_muted = self.cached_is_muted
520
546
  self.current_mode = new_mode
521
547
 
522
- # Start initial capture based on mode
523
- if new_mode == MODE_SCREENCAST and not self.cached_screen_locked:
524
- try:
525
- await self.initialize_screencast()
526
- except RuntimeError:
527
- self.running = False
528
- if sync_task:
529
- if self._sync:
530
- self._sync.stop()
531
- sync_task.cancel()
532
- try:
533
- await sync_task
534
- except asyncio.CancelledError:
535
- pass
536
- bridge_stop_event.set()
537
- if bridge_task:
538
- bridge_task.cancel()
539
- try:
540
- await bridge_task
541
- except (asyncio.CancelledError, Exception):
542
- pass
543
- return
544
- else:
545
- self._start_segment()
548
+ if self.config.start_paused:
549
+ self.pause(0)
550
+ logger.info("Starting in paused mode (start_paused=true)")
551
+
552
+ # Start initial capture based on mode (skipped when starting paused)
553
+ if not self._paused:
554
+ if new_mode == MODE_SCREENCAST and not self.cached_screen_locked:
555
+ try:
556
+ await self.initialize_screencast()
557
+ except RuntimeError:
558
+ self.running = False
559
+ if sync_task:
560
+ if self._sync:
561
+ self._sync.stop()
562
+ sync_task.cancel()
563
+ try:
564
+ await sync_task
565
+ except asyncio.CancelledError:
566
+ pass
567
+ bridge_stop_event.set()
568
+ if bridge_task:
569
+ bridge_task.cancel()
570
+ try:
571
+ await bridge_task
572
+ except (asyncio.CancelledError, Exception):
573
+ pass
574
+ return
575
+ else:
576
+ self._start_segment()
546
577
 
547
578
  logger.info(f"Initial mode: {self.current_mode}")
548
579