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.
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/CHANGELOG.md +12 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/INSTALL.md +1 -1
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/PKG-INFO +1 -1
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/pyproject.toml +1 -1
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/__init__.py +1 -1
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/activity.py +20 -10
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/cli.py +22 -65
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/observer.py +1 -2
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/sync.py +1 -4
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/upload.py +44 -53
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_activity.py +35 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_cli.py +58 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_observer_emits_stream_silent_event.py +0 -1
- solstone_linux-0.2.0/tests/test_upload.py +119 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/.gitignore +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/AGENTS.md +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/CLAUDE.md +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/LICENSE +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/Makefile +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/README.md +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/contrib/icons/hicolor/scalable/status/solstone-error.svg +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/contrib/icons/hicolor/scalable/status/solstone-paused.svg +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/contrib/icons/hicolor/scalable/status/solstone-recording.svg +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/contrib/icons/hicolor/scalable/status/solstone-syncing.svg +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/scripts/extract_changelog.sh +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/scripts/release.sh +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/audio_detect.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/audio_mute.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/audio_recorder.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/chat_bridge.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/config.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/dbus_service.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/dbusmenu.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/doctor.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-error.svg +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-paused.svg +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-recording.svg +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-syncing.svg +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/install_guard.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/monitor_positions.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/recovery.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/screencast.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/session_env.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/sni.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/solstone-linux.service.in +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/streams.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/src/solstone_linux/tray.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/__init__.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_chat_bridge.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_config.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_dbus_service.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_dbusmenu.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_doctor.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_extract_changelog.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_install_guard.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_monitor_positions.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_observer.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_screencast.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_screencast_stop_filters_silent_streams.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_session_env.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_streams.py +0 -0
- {solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_sync.py +0 -0
- {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
|
|
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
|
```
|
|
@@ -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
|
-
|
|
231
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
)
|
|
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
|
|
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
|
|
19
|
-
import
|
|
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
|
|
67
|
-
"""
|
|
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
|
-
"""
|
|
79
|
+
"""Register this observer over HTTP, persisting the handle + locked stream.
|
|
75
80
|
|
|
76
|
-
|
|
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
|
-
|
|
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.
|
|
107
|
+
self._stream = data["name"]
|
|
108
|
+
self._persist_registration(config, data["key"], data["name"])
|
|
123
109
|
logger.info(
|
|
124
|
-
f"
|
|
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
|
|
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
|
|
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,
|
|
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/
|
|
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(
|
|
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/
|
|
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(
|
|
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()
|
{solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_observer_emits_stream_silent_event.py
RENAMED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{solstone_linux-0.1.1 → solstone_linux-0.2.0}/tests/test_screencast_stop_filters_silent_streams.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|