solstone-linux 0.1.1__tar.gz → 0.2.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 (63) hide show
  1. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/CHANGELOG.md +12 -0
  2. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/INSTALL.md +1 -1
  3. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/PKG-INFO +1 -1
  4. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/pyproject.toml +1 -1
  5. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/__init__.py +1 -1
  6. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/activity.py +20 -10
  7. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/cli.py +22 -65
  8. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/observer.py +1 -2
  9. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/sync.py +1 -4
  10. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/upload.py +44 -53
  11. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_activity.py +35 -0
  12. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_cli.py +58 -0
  13. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_observer_emits_stream_silent_event.py +0 -1
  14. solstone_linux-0.2.0/tests/test_upload.py +119 -0
  15. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/.gitignore +0 -0
  16. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/AGENTS.md +0 -0
  17. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/CLAUDE.md +0 -0
  18. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/LICENSE +0 -0
  19. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/Makefile +0 -0
  20. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/README.md +0 -0
  21. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/contrib/icons/hicolor/scalable/status/solstone-error.svg +0 -0
  22. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/contrib/icons/hicolor/scalable/status/solstone-paused.svg +0 -0
  23. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/contrib/icons/hicolor/scalable/status/solstone-recording.svg +0 -0
  24. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/contrib/icons/hicolor/scalable/status/solstone-syncing.svg +0 -0
  25. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/scripts/extract_changelog.sh +0 -0
  26. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/scripts/release.sh +0 -0
  27. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/audio_detect.py +0 -0
  28. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/audio_mute.py +0 -0
  29. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/audio_recorder.py +0 -0
  30. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/chat_bridge.py +0 -0
  31. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/config.py +0 -0
  32. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/dbus_service.py +0 -0
  33. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/dbusmenu.py +0 -0
  34. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/doctor.py +0 -0
  35. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-error.svg +0 -0
  36. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-paused.svg +0 -0
  37. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-recording.svg +0 -0
  38. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-syncing.svg +0 -0
  39. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/install_guard.py +0 -0
  40. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/monitor_positions.py +0 -0
  41. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/recovery.py +0 -0
  42. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/screencast.py +0 -0
  43. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/session_env.py +0 -0
  44. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/sni.py +0 -0
  45. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/solstone-linux.service.in +0 -0
  46. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/streams.py +0 -0
  47. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/tray.py +0 -0
  48. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/__init__.py +0 -0
  49. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_chat_bridge.py +0 -0
  50. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_config.py +0 -0
  51. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_dbus_service.py +0 -0
  52. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_dbusmenu.py +0 -0
  53. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_doctor.py +0 -0
  54. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_extract_changelog.py +0 -0
  55. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_install_guard.py +0 -0
  56. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_monitor_positions.py +0 -0
  57. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_observer.py +0 -0
  58. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_screencast.py +0 -0
  59. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_screencast_stop_filters_silent_streams.py +0 -0
  60. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_session_env.py +0 -0
  61. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_streams.py +0 -0
  62. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_sync.py +0 -0
  63. {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_tray.py +0 -0
@@ -4,6 +4,18 @@ 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.2.0] - 2026-06-13
8
+
9
+ setup is now hands-off: the first time the observer runs, it connects itself
10
+ to your journal automatically, with no separate key step.
11
+
12
+ ### Changed
13
+
14
+ - **first run sets itself up.** earlier versions asked you to create and paste
15
+ a key to connect the observer to your journal. now the observer introduces
16
+ itself to your journal on first run and remembers the connection on its own.
17
+ you go straight from install to observing, with no manual key step.
18
+
7
19
  ## [0.1.1] - 2026-06-02
8
20
 
9
21
  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.2.0
4
4
  Summary: Standalone Linux desktop observer for solstone
5
5
  License-Expression: AGPL-3.0-only
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "solstone-linux"
3
- version = "0.1.1"
3
+ version = "0.2.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.2.0"
@@ -24,6 +24,7 @@ from dbus_next.errors import (
24
24
  logger = logging.getLogger(__name__)
25
25
 
26
26
  _DBUS_PROBE_TIMEOUT_SEC = 2.0
27
+ _POWER_SAVE_WARNED_BACKENDS: set[str] = set()
27
28
 
28
29
  _SERVICE_MISSING_ERRORS = (
29
30
  "org.freedesktop.DBus.Error.ServiceUnknown",
@@ -212,6 +213,22 @@ async def is_power_save_active(bus: MessageBus) -> bool:
212
213
 
213
214
  Returns True if power save is active, False otherwise.
214
215
  """
216
+
217
+ def log_backend_failure_once(backend: str, bus_name: str, path: str, exc) -> None:
218
+ level = logger.warning
219
+ if backend in _POWER_SAVE_WARNED_BACKENDS:
220
+ level = logger.debug
221
+ else:
222
+ _POWER_SAVE_WARNED_BACKENDS.add(backend)
223
+ level(
224
+ "is_power_save_active %s backend failed: service=%s path=%s: %s: %s",
225
+ backend,
226
+ bus_name,
227
+ path,
228
+ type(exc).__name__,
229
+ exc,
230
+ )
231
+
215
232
  # Try GNOME Mutter DisplayConfig first
216
233
  try:
217
234
  intro = await bus.introspect(DISPLAY_CONFIG_BUS, DISPLAY_CONFIG_PATH)
@@ -227,11 +244,10 @@ async def is_power_save_active(bus: MessageBus) -> bool:
227
244
  OSError,
228
245
  ) as exc:
229
246
  if not _is_service_missing(exc):
230
- logger.warning(
231
- "is_power_save_active Mutter backend failed: service=%s path=%s: %s: %s",
247
+ log_backend_failure_once(
248
+ "Mutter",
232
249
  DISPLAY_CONFIG_BUS,
233
250
  DISPLAY_CONFIG_PATH,
234
- type(exc).__name__,
235
251
  exc,
236
252
  )
237
253
 
@@ -248,13 +264,7 @@ async def is_power_save_active(bus: MessageBus) -> bool:
248
264
  OSError,
249
265
  ) as exc:
250
266
  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
- )
267
+ log_backend_failure_once("KDE", KDE_POWER_BUS, KDE_POWER_PATH, exc)
258
268
  return False
259
269
 
260
270
 
@@ -134,44 +134,23 @@ def cmd_setup(args: argparse.Namespace) -> int:
134
134
  )
135
135
  return 0
136
136
 
137
- print(f"Stream: {config.stream}")
138
137
  save_config(config)
139
138
 
140
139
  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
140
+ print("Registering with your journal...")
141
+ client = UploadClient(config)
142
+ if client.ensure_registered(config):
143
+ print(f"Registered (key: {config.key[:8]}...)")
144
+ print(f"Stream: {config.stream}")
145
+ else:
146
+ print(
147
+ "Warning: registration failed. Run setup again when your journal is available."
148
+ )
149
+ if non_interactive:
150
+ return 1
173
151
  else:
174
152
  print(f"Already registered (key: {config.key[:8]}...)")
153
+ print(f"Stream: {config.stream}")
175
154
 
176
155
  print(f"\nConfig saved to {config.config_path}")
177
156
  print(f"Captures will go to {config.captures_dir}")
@@ -203,46 +182,24 @@ def _cmd_setup_interactive() -> int:
203
182
  except ValueError as e:
204
183
  print(f"Error deriving stream name: {e}", file=sys.stderr)
205
184
  return 1
206
- print(f"Stream: {config.stream}")
207
185
 
208
186
  # Save config before registration (so URL is persisted)
209
187
  config.ensure_dirs()
210
188
  save_config(config)
211
189
 
212
- # Auto-register — try sol CLI first (no server needed), fall back to HTTP
213
190
  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
- )
191
+ print("Registering with your journal...")
192
+ client = UploadClient(config)
193
+ if client.ensure_registered(config):
194
+ print(f"Registered (key: {config.key[:8]}...)")
195
+ print(f"Stream: {config.stream}")
196
+ else:
197
+ print(
198
+ "Warning: registration failed. Run setup again when your journal is available."
199
+ )
244
200
  else:
245
201
  print(f"Already registered (key: {config.key[:8]}...)")
202
+ print(f"Stream: {config.stream}")
246
203
 
247
204
  print(f"\nConfig saved to {config.config_path}")
248
205
  print(f"Captures will go to {config.captures_dir}")
@@ -162,6 +162,7 @@ class Observer:
162
162
  self._client = UploadClient(self.config)
163
163
  if self.config.server_url:
164
164
  self._client.ensure_registered(self.config)
165
+ self.stream = self.config.stream
165
166
  self._sync = SyncService(self.config, self._client)
166
167
 
167
168
  from .dbus_service import BUS_NAME, OBJECT_PATH, ObserverService
@@ -407,7 +408,6 @@ class Observer:
407
408
  duration_seconds=duration_seconds,
408
409
  host=HOST,
409
410
  platform=PLATFORM,
410
- stream=self.stream,
411
411
  )
412
412
 
413
413
  def emit_status(self):
@@ -458,7 +458,6 @@ class Observer:
458
458
  activity=activity_info,
459
459
  host=HOST,
460
460
  platform=PLATFORM,
461
- stream=self.stream,
462
461
  )
463
462
 
464
463
  def _refresh_tray(self):
@@ -25,7 +25,6 @@ import shutil
25
25
  import time
26
26
  from datetime import datetime, timedelta
27
27
  from pathlib import Path
28
- from typing import Any
29
28
 
30
29
  from .config import Config
31
30
  from .upload import ErrorType, UploadClient
@@ -473,10 +472,8 @@ class SyncService:
473
472
  if not files:
474
473
  return True # Nothing to upload
475
474
 
476
- meta: dict[str, Any] = {"stream": self._config.stream}
477
-
478
475
  result = await asyncio.to_thread(
479
- self._client.upload_segment, day, segment_key, files, meta
476
+ self._client.upload_segment, day, segment_key, files
480
477
  )
481
478
 
482
479
  if result.success:
@@ -13,10 +13,9 @@ Refinements over tmux baseline:
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
- import json
17
16
  import logging
18
- import shutil
19
- import subprocess
17
+ import platform
18
+ import socket
20
19
  import time
21
20
  from enum import Enum
22
21
  from pathlib import Path
@@ -24,6 +23,7 @@ from typing import Any, NamedTuple
24
23
 
25
24
  import requests
26
25
 
26
+ from . import __version__
27
27
  from .config import Config
28
28
 
29
29
  logger = logging.getLogger(__name__)
@@ -32,6 +32,10 @@ UPLOAD_TIMEOUT = 300
32
32
  EVENT_TIMEOUT = 30
33
33
 
34
34
 
35
+ def _auth_headers(key: str) -> dict[str, str]:
36
+ return {"Authorization": f"Bearer {key}"}
37
+
38
+
35
39
  class ErrorType(Enum):
36
40
  """Classification of upload errors for circuit breaker tuning."""
37
41
 
@@ -63,65 +67,47 @@ class UploadClient:
63
67
  def is_revoked(self) -> bool:
64
68
  return self._revoked
65
69
 
66
- def _persist_key(self, config: Config, key: str) -> None:
67
- """Save auto-registered key back to config."""
70
+ def _persist_registration(self, config: Config, key: str, stream: str) -> None:
71
+ """Persist the server-issued handle and locked stream back to config."""
68
72
  from .config import save_config
69
73
 
70
74
  config.key = key
75
+ config.stream = stream
71
76
  save_config(config)
72
77
 
73
78
  def ensure_registered(self, config: Config) -> bool:
74
- """Ensure the client has a valid key, auto-registering if needed.
79
+ """Register this observer over HTTP, persisting the handle + locked stream.
75
80
 
76
- Tries sol CLI first (no server needed), falls back to HTTP.
77
- Returns True if a key is available.
81
+ Short-circuits if a key is already present. Returns True if a key is available.
78
82
  """
79
83
  if self._key:
80
84
  return True
81
-
82
- # Try sol CLI registration first
83
- name = self._stream or "solstone-linux"
84
- sol = shutil.which("sol")
85
- if sol:
86
- try:
87
- result = subprocess.run(
88
- [sol, "observer", "--json", "create", name],
89
- capture_output=True,
90
- text=True,
91
- timeout=10,
92
- )
93
- if result.returncode == 0:
94
- data = json.loads(result.stdout)
95
- self._key = data["key"]
96
- self._persist_key(config, self._key)
97
- logger.info(f"CLI-registered as '{name}' (key: {self._key[:8]}...)")
98
- return True
99
- except (
100
- subprocess.TimeoutExpired,
101
- json.JSONDecodeError,
102
- KeyError,
103
- OSError,
104
- ) as e:
105
- logger.debug(f"CLI registration failed: {e}")
106
-
107
85
  if not self._url:
108
86
  return False
109
87
 
110
- url = f"{self._url}/app/observer/api/create"
88
+ descriptor: dict[str, Any] = {
89
+ "platform": platform.system().lower(),
90
+ "hostname": socket.gethostname(),
91
+ "stream_type": "desktop",
92
+ "version": __version__,
93
+ }
94
+ if self._stream:
95
+ descriptor["label"] = self._stream
96
+
97
+ url = f"{self._url}/app/observer/register"
111
98
 
112
99
  retries = min(3, len(self._retry_backoff))
113
100
  for attempt in range(retries):
114
101
  delay = self._retry_backoff[min(attempt, len(self._retry_backoff) - 1)]
115
102
  try:
116
- resp = self._session.post(
117
- url, json={"name": name}, timeout=EVENT_TIMEOUT
118
- )
103
+ resp = self._session.post(url, json=descriptor, timeout=EVENT_TIMEOUT)
119
104
  if resp.status_code == 200:
120
105
  data = resp.json()
121
106
  self._key = data["key"]
122
- self._persist_key(config, self._key)
107
+ self._stream = data["name"]
108
+ self._persist_registration(config, data["key"], data["name"])
123
109
  logger.info(
124
- f"Auto-registered as '{name}' (key: {self._key[:8]}...)"
110
+ f"Registered as '{data['name']}' (key: {self._key[:8]}...)"
125
111
  )
126
112
  return True
127
113
  elif resp.status_code == 403:
@@ -161,7 +147,6 @@ class UploadClient:
161
147
  day: str,
162
148
  segment: str,
163
149
  files: list[Path],
164
- meta: dict[str, Any] | None = None,
165
150
  ) -> UploadResult:
166
151
  """Upload a segment's files to the ingest server."""
167
152
  if self._revoked or not self._key or not self._url:
@@ -169,7 +154,7 @@ class UploadClient:
169
154
  False, error_type=ErrorType.AUTH if self._revoked else None
170
155
  )
171
156
 
172
- url = f"{self._url}/app/observer/ingest/{self._key}"
157
+ url = f"{self._url}/app/observer/ingest"
173
158
 
174
159
  for attempt in range(self._max_retries):
175
160
  file_handles = []
@@ -189,12 +174,14 @@ class UploadClient:
189
174
  if not files_data:
190
175
  return UploadResult(False)
191
176
 
192
- data: dict[str, Any] = {"day": day, "segment": segment}
193
- if meta:
194
- data["meta"] = json.dumps(meta)
177
+ data = {"day": day, "segment": segment}
195
178
 
196
179
  response = self._session.post(
197
- url, data=data, files=files_data, timeout=UPLOAD_TIMEOUT
180
+ url,
181
+ data=data,
182
+ files=files_data,
183
+ headers=_auth_headers(self._key),
184
+ timeout=UPLOAD_TIMEOUT,
198
185
  )
199
186
 
200
187
  if response.status_code == 200:
@@ -249,13 +236,12 @@ class UploadClient:
249
236
  if self._revoked or not self._key or not self._url:
250
237
  return None
251
238
 
252
- url = f"{self._url}/app/observer/ingest/{self._key}/segments/{day}"
253
- params = {}
254
- if self._stream:
255
- params["stream"] = self._stream
239
+ url = f"{self._url}/app/observer/ingest/segments/{day}"
256
240
 
257
241
  try:
258
- resp = self._session.get(url, params=params, timeout=EVENT_TIMEOUT)
242
+ resp = self._session.get(
243
+ url, headers=_auth_headers(self._key), timeout=EVENT_TIMEOUT
244
+ )
259
245
  if resp.status_code == 200:
260
246
  return resp.json()
261
247
  if resp.status_code in (401, 403):
@@ -274,10 +260,15 @@ class UploadClient:
274
260
  if self._revoked or not self._key or not self._url:
275
261
  return False
276
262
 
277
- url = f"{self._url}/app/observer/ingest/{self._key}/event"
263
+ url = f"{self._url}/app/observer/ingest/event"
278
264
  payload = {"tract": tract, "event": event, **fields}
279
265
  try:
280
- resp = self._session.post(url, json=payload, timeout=EVENT_TIMEOUT)
266
+ resp = self._session.post(
267
+ url,
268
+ json=payload,
269
+ headers=_auth_headers(self._key),
270
+ timeout=EVENT_TIMEOUT,
271
+ )
281
272
  if resp.status_code == 200:
282
273
  return True
283
274
  if resp.status_code == 403:
@@ -269,6 +269,10 @@ class TestIsScreenLocked:
269
269
  class TestIsPowerSaveActive:
270
270
  """Test power save fallback order."""
271
271
 
272
+ @pytest.fixture(autouse=True)
273
+ def _clear_warning_cache(self):
274
+ activity._POWER_SAVE_WARNED_BACKENDS.clear()
275
+
272
276
  @pytest.mark.asyncio
273
277
  async def test_gnome_backend_nonzero_mode_returns_true(self):
274
278
  bus = MagicMock()
@@ -421,6 +425,37 @@ class TestIsPowerSaveActive:
421
425
  "path=/org/kde/Solid/PowerManagement: DBusError: broke",
422
426
  ]
423
427
 
428
+ @pytest.mark.asyncio
429
+ async def test_is_power_save_active_repeated_backend_failures_log_debug_after_first(
430
+ self, caplog
431
+ ):
432
+ bus = MagicMock()
433
+ bus.introspect = AsyncMock(side_effect=[_no_reply("broke")] * 4)
434
+
435
+ with caplog.at_level(logging.DEBUG):
436
+ assert await activity.is_power_save_active(bus) is False
437
+ assert await activity.is_power_save_active(bus) is False
438
+
439
+ warnings = [
440
+ record.message
441
+ for record in caplog.records
442
+ if record.levelno == logging.WARNING
443
+ ]
444
+ debug = [
445
+ record.message
446
+ for record in caplog.records
447
+ if record.levelno == logging.DEBUG
448
+ ]
449
+ assert warnings == [
450
+ "is_power_save_active Mutter backend failed: "
451
+ "service=org.gnome.Mutter.DisplayConfig "
452
+ "path=/org/gnome/Mutter/DisplayConfig: DBusError: broke",
453
+ "is_power_save_active KDE backend failed: "
454
+ "service=org.kde.Solid.PowerManagement "
455
+ "path=/org/kde/Solid/PowerManagement: DBusError: broke",
456
+ ]
457
+ assert debug == warnings
458
+
424
459
 
425
460
  class TestProbeActivityServices:
426
461
  """Test activity backend probing and logging."""
@@ -325,3 +325,61 @@ def test_cmd_setup_cli_token_beats_env(tmp_path: Path, capsys):
325
325
  captured = capsys.readouterr()
326
326
  assert saved_config.key == "clitok"
327
327
  assert "shared machines" in captured.err
328
+
329
+
330
+ def test_cmd_setup_registers_via_http_when_no_token(tmp_path: Path):
331
+ args = argparse.Namespace(
332
+ server_url="http://localhost:9",
333
+ token=None,
334
+ stream_name=None,
335
+ non_interactive=True,
336
+ )
337
+ config = Config(base_dir=tmp_path)
338
+
339
+ def _register(cfg):
340
+ cfg.key = "newkey00"
341
+ cfg.stream = "locked-stream"
342
+ return True
343
+
344
+ with patch.dict(os.environ, {}, clear=True):
345
+ with patch("solstone_linux.cli.load_config", return_value=config):
346
+ with patch("solstone_linux.cli.save_config"):
347
+ with patch(
348
+ "solstone_linux.cli.streams.stream_name", return_value="host-a"
349
+ ):
350
+ with patch(
351
+ "solstone_linux.upload.UploadClient.ensure_registered",
352
+ side_effect=_register,
353
+ ) as reg_mock:
354
+ assert cmd_setup(args) == 0
355
+
356
+ reg_mock.assert_called_once()
357
+ assert config.key == "newkey00"
358
+ assert config.stream == "locked-stream"
359
+
360
+
361
+ def test_cmd_setup_http_register_failure_non_interactive_returns_1(
362
+ tmp_path: Path, capsys
363
+ ):
364
+ args = argparse.Namespace(
365
+ server_url="http://localhost:9",
366
+ token=None,
367
+ stream_name=None,
368
+ non_interactive=True,
369
+ )
370
+ config = Config(base_dir=tmp_path)
371
+
372
+ with patch.dict(os.environ, {}, clear=True):
373
+ with patch("solstone_linux.cli.load_config", return_value=config):
374
+ with patch("solstone_linux.cli.save_config"):
375
+ with patch(
376
+ "solstone_linux.cli.streams.stream_name", return_value="host-a"
377
+ ):
378
+ with patch(
379
+ "solstone_linux.upload.UploadClient.ensure_registered",
380
+ return_value=False,
381
+ ):
382
+ assert cmd_setup(args) == 1
383
+
384
+ captured = capsys.readouterr()
385
+ assert "registration failed" in captured.out.lower()
@@ -39,7 +39,6 @@ def test_emits_with_full_fields(tmp_path: Path):
39
39
  assert 118 <= kwargs["duration_seconds"] <= 122
40
40
  assert kwargs["host"] == HOST
41
41
  assert kwargs["platform"] == PLATFORM
42
- assert kwargs["stream"] == observer.stream
43
42
 
44
43
 
45
44
  def test_skips_when_client_none(tmp_path: Path):
@@ -0,0 +1,119 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (c) 2026 sol pbc
3
+
4
+ from pathlib import Path
5
+ from unittest.mock import MagicMock
6
+
7
+ from solstone_linux.config import Config, load_config
8
+ from solstone_linux.upload import UploadClient
9
+
10
+
11
+ def test_ensure_registered_posts_descriptor_and_persists(tmp_path: Path):
12
+ config = Config(
13
+ base_dir=tmp_path,
14
+ server_url="http://localhost:9999",
15
+ stream="host-a",
16
+ )
17
+ client = UploadClient(config)
18
+ client._session = MagicMock()
19
+ client._session.post.return_value = MagicMock(
20
+ status_code=200,
21
+ json=lambda: {
22
+ "key": "K123456789",
23
+ "prefix": "K1234567",
24
+ "name": "fedora",
25
+ "ingest_url": "/app/observer/ingest",
26
+ "protocol_version": 2,
27
+ },
28
+ )
29
+
30
+ assert client.ensure_registered(config) is True
31
+
32
+ client._session.post.assert_called_once()
33
+ call = client._session.post.call_args
34
+ assert call.args[0].endswith("/app/observer/register")
35
+ descriptor = call.kwargs["json"]
36
+ assert descriptor["stream_type"] == "desktop"
37
+ assert descriptor["platform"]
38
+ assert descriptor["hostname"]
39
+ assert descriptor["version"]
40
+ assert descriptor["label"] == "host-a"
41
+ assert config.key == "K123456789"
42
+ assert config.stream == "fedora"
43
+ assert client._key == "K123456789"
44
+
45
+ reloaded = load_config(base_dir=tmp_path)
46
+ assert reloaded.key == "K123456789"
47
+ assert reloaded.stream == "fedora"
48
+
49
+
50
+ def test_ensure_registered_skips_when_key_present(tmp_path: Path):
51
+ config = Config(
52
+ base_dir=tmp_path,
53
+ server_url="http://localhost:9999",
54
+ key="existing",
55
+ )
56
+ client = UploadClient(config)
57
+ client._session = MagicMock()
58
+
59
+ assert client.ensure_registered(config) is True
60
+ client._session.post.assert_not_called()
61
+
62
+
63
+ def test_upload_segment_uses_bearer_and_keyless_route(tmp_path: Path):
64
+ config = Config(base_dir=tmp_path, server_url="http://localhost:9999", key="K")
65
+ client = UploadClient(config)
66
+ client._session = MagicMock()
67
+ client._session.post.return_value = MagicMock(
68
+ status_code=200,
69
+ json=lambda: {"status": "ok"},
70
+ )
71
+ media = tmp_path / "audio.flac"
72
+ media.write_bytes(b"audio")
73
+
74
+ result = client.upload_segment("20260101", "120000_005", [media])
75
+
76
+ assert result.success
77
+ call = client._session.post.call_args
78
+ url = call.args[0]
79
+ assert url.endswith("/app/observer/ingest")
80
+ assert "/ingest/K" not in url
81
+ assert call.kwargs["headers"] == {"Authorization": "Bearer K"}
82
+ assert call.kwargs["data"] == {"day": "20260101", "segment": "120000_005"}
83
+ assert "stream" not in call.kwargs["data"]
84
+ assert "meta" not in call.kwargs["data"]
85
+ assert "files" in call.kwargs
86
+
87
+
88
+ def test_relay_event_uses_bearer_and_keyless_route(tmp_path: Path):
89
+ config = Config(base_dir=tmp_path, server_url="http://localhost:9999", key="K")
90
+ client = UploadClient(config)
91
+ client._session = MagicMock()
92
+ client._session.post.return_value = MagicMock(status_code=200)
93
+
94
+ assert client.relay_event("observe", "status", mode="idle") is True
95
+
96
+ call = client._session.post.call_args
97
+ assert call.args[0].endswith("/app/observer/ingest/event")
98
+ assert call.kwargs["headers"] == {"Authorization": "Bearer K"}
99
+ assert call.kwargs["json"] == {
100
+ "tract": "observe",
101
+ "event": "status",
102
+ "mode": "idle",
103
+ }
104
+ assert "stream" not in call.kwargs["json"]
105
+
106
+
107
+ def test_get_server_segments_uses_bearer_and_keyless_route(tmp_path: Path):
108
+ config = Config(base_dir=tmp_path, server_url="http://localhost:9999", key="K")
109
+ client = UploadClient(config)
110
+ client._session = MagicMock()
111
+ client._session.get.return_value = MagicMock(status_code=200, json=lambda: [])
112
+
113
+ assert client.get_server_segments("20260101") == []
114
+
115
+ call = client._session.get.call_args
116
+ assert call.args[0].endswith("/app/observer/ingest/segments/20260101")
117
+ assert call.kwargs["headers"] == {"Authorization": "Bearer K"}
118
+ params = call.kwargs.get("params")
119
+ assert params is None or "stream" not in params
File without changes
File without changes
File without changes
File without changes
File without changes