solstone-linux 0.3.2__tar.gz → 0.4.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.3.2 → solstone_linux-0.4.0}/AGENTS.md +4 -4
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/CHANGELOG.md +13 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/PKG-INFO +1 -1
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/pyproject.toml +1 -1
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/__init__.py +1 -1
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/cli.py +79 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/config.py +48 -7
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/dbusmenu.py +10 -2
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/tray.py +27 -6
- solstone_linux-0.4.0/tests/conftest.py +10 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_cli.py +73 -0
- solstone_linux-0.4.0/tests/test_config.py +254 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_dbusmenu.py +22 -1
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_observer.py +1 -1
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_tray.py +150 -2
- solstone_linux-0.3.2/tests/test_config.py +0 -157
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/.gitignore +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/CLAUDE.md +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/INSTALL.md +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/LICENSE +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/Makefile +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/README.md +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/contrib/icons/hicolor/scalable/status/solstone-error.svg +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/contrib/icons/hicolor/scalable/status/solstone-paused.svg +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/contrib/icons/hicolor/scalable/status/solstone-recording.svg +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/contrib/icons/hicolor/scalable/status/solstone-syncing.svg +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/scripts/extract_changelog.sh +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/scripts/release.sh +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/activity.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/audio_detect.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/audio_mute.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/audio_recorder.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/chat_bridge.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/dbus_service.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/doctor.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-error.svg +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-paused.svg +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-recording.svg +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-syncing.svg +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/install_guard.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/monitor_positions.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/observer.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/recovery.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/screencast.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/session_env.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/sni.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/solstone-linux.service.in +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/streams.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/sync.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/sync_health.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/upload.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/__init__.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_activity.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_chat_bridge.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_dbus_service.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_doctor.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_extract_changelog.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_install_guard.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_monitor_positions.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_observer_emits_stream_silent_event.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_screencast.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_screencast_stop_filters_silent_streams.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_session_env.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_streams.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_sync.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_sync_health.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_sync_health_surfaces.py +0 -0
- {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_upload.py +0 -0
|
@@ -13,9 +13,9 @@ This is **not** part of the solstone monorepo. It is a standalone package with i
|
|
|
13
13
|
```
|
|
14
14
|
src/solstone_linux/
|
|
15
15
|
__init__.py Package version
|
|
16
|
-
cli.py CLI entry point (run, setup, install-service, status)
|
|
16
|
+
cli.py CLI entry point (run, setup, settings, install-service, status)
|
|
17
17
|
solstone-linux.service.in Systemd unit template (rendered by install-service)
|
|
18
|
-
config.py Config loading/persistence (~/.
|
|
18
|
+
config.py Config loading/persistence (config under ~/.config/solstone-linux/)
|
|
19
19
|
observer.py Main capture loop — state machine (idle/screencast), audio + video
|
|
20
20
|
screencast.py Portal-based multi-monitor recording (xdg-desktop-portal + GStreamer)
|
|
21
21
|
audio_recorder.py Stereo audio recording (mic + system via soundcard)
|
|
@@ -126,10 +126,10 @@ Python packages (in pyproject.toml):
|
|
|
126
126
|
|
|
127
127
|
## Data Paths
|
|
128
128
|
|
|
129
|
-
- Config: `~/.
|
|
129
|
+
- Config: `~/.config/solstone-linux/config.json`
|
|
130
130
|
- Captures: `~/.local/share/solstone-linux/captures/`
|
|
131
131
|
- State: `~/.local/share/solstone-linux/state/`
|
|
132
|
-
- Restore token: `~/.
|
|
132
|
+
- Restore token: `~/.config/solstone-linux/restore_token`
|
|
133
133
|
- Install source marker: `~/.config/solstone-linux/.install-source` (tracks which repo clone owns the pipx install)
|
|
134
134
|
|
|
135
135
|
## Key Patterns
|
|
@@ -4,6 +4,19 @@ 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.4.0] - 2026-06-17
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- a new `solstone-linux settings` command lets you adjust how this observer behaves after setup, from one place instead of hand-editing a config file. you can change how often it makes a segment, the framerate, whether it starts paused, the chat bridge, and how long it keeps local cache. setup itself stays prompt-free; your identity and pairing are left untouched.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- this observer's settings now live under `~/.config/solstone-linux/`, where linux tools expect config to be. if you're upgrading, the move happens on its own the first time you run, with nothing to redo: no re-setup, no re-pairing. your segments stay exactly where they are.
|
|
14
|
+
|
|
15
|
+
## [0.3.3] - 2026-06-16
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- the tray status submenu now refreshes its values every time you open it. the segment countdown, cache size, captures today, uptime, and sync line had been showing stale values on reopen on some desktops; they now reflect the current state each time you open the menu.
|
|
19
|
+
|
|
7
20
|
## [0.3.2] - 2026-06-16
|
|
8
21
|
|
|
9
22
|
### Changed
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
Subcommands:
|
|
7
7
|
run Start capture loop + sync service (default)
|
|
8
8
|
setup Interactive configuration
|
|
9
|
+
settings Edit capture/behavior settings
|
|
9
10
|
install-service Write systemd user unit, enable, start
|
|
10
11
|
status Show capture and sync state
|
|
11
12
|
"""
|
|
@@ -39,6 +40,63 @@ def _setup_logging(verbose: bool = False) -> None:
|
|
|
39
40
|
)
|
|
40
41
|
|
|
41
42
|
|
|
43
|
+
def _prompt_bool(label: str, current: bool) -> bool:
|
|
44
|
+
while True:
|
|
45
|
+
value = input(f"{label} [{'y' if current else 'n'}]: ").strip().lower()
|
|
46
|
+
if value == "":
|
|
47
|
+
return current
|
|
48
|
+
if value in ("y", "yes"):
|
|
49
|
+
return True
|
|
50
|
+
if value in ("n", "no"):
|
|
51
|
+
return False
|
|
52
|
+
print("Enter y or n.")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _prompt_positive_int(label: str, current: int) -> int:
|
|
56
|
+
while True:
|
|
57
|
+
value = input(f"{label} [{current}]: ").strip()
|
|
58
|
+
if value == "":
|
|
59
|
+
return current
|
|
60
|
+
try:
|
|
61
|
+
parsed = int(value)
|
|
62
|
+
except ValueError:
|
|
63
|
+
print("Enter a positive integer.")
|
|
64
|
+
continue
|
|
65
|
+
if parsed > 0:
|
|
66
|
+
return parsed
|
|
67
|
+
print("Enter a positive integer.")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _prompt_framerate(current: int) -> int:
|
|
71
|
+
while True:
|
|
72
|
+
value = input(f"Capture framerate [{current}]: ").strip()
|
|
73
|
+
if value == "":
|
|
74
|
+
return current
|
|
75
|
+
try:
|
|
76
|
+
parsed = int(value)
|
|
77
|
+
except ValueError:
|
|
78
|
+
print("Enter an integer.")
|
|
79
|
+
continue
|
|
80
|
+
clamped = max(1, min(parsed, 10))
|
|
81
|
+
if clamped != parsed:
|
|
82
|
+
print(f"(clamped to {clamped})")
|
|
83
|
+
return clamped
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _prompt_retention(current: int) -> int:
|
|
87
|
+
while True:
|
|
88
|
+
value = input(
|
|
89
|
+
"Cache retention days (-1 = keep forever, 0 = delete after sync, "
|
|
90
|
+
f"N = keep N days) [{current}]: "
|
|
91
|
+
).strip()
|
|
92
|
+
if value == "":
|
|
93
|
+
return current
|
|
94
|
+
try:
|
|
95
|
+
return int(value)
|
|
96
|
+
except ValueError:
|
|
97
|
+
print("Enter an integer.")
|
|
98
|
+
|
|
99
|
+
|
|
42
100
|
def cmd_run(args: argparse.Namespace) -> int:
|
|
43
101
|
"""Start the capture loop + sync service."""
|
|
44
102
|
from .observer import async_run
|
|
@@ -199,6 +257,23 @@ def _cmd_setup_interactive() -> int:
|
|
|
199
257
|
return 0
|
|
200
258
|
|
|
201
259
|
|
|
260
|
+
def cmd_settings(args: argparse.Namespace) -> int:
|
|
261
|
+
config = load_config()
|
|
262
|
+
config.capture_framerate = _prompt_framerate(config.capture_framerate)
|
|
263
|
+
config.draw_cursor = _prompt_bool("Draw cursor", config.draw_cursor)
|
|
264
|
+
config.start_paused = _prompt_bool("Start paused", config.start_paused)
|
|
265
|
+
config.segment_interval = _prompt_positive_int(
|
|
266
|
+
"Segment interval seconds", config.segment_interval
|
|
267
|
+
)
|
|
268
|
+
config.chat_bridge_enabled = _prompt_bool(
|
|
269
|
+
"Chat bridge enabled", config.chat_bridge_enabled
|
|
270
|
+
)
|
|
271
|
+
config.cache_retention_days = _prompt_retention(config.cache_retention_days)
|
|
272
|
+
save_config(config)
|
|
273
|
+
print(f"\nSettings saved to {config.config_path}")
|
|
274
|
+
return 0
|
|
275
|
+
|
|
276
|
+
|
|
202
277
|
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
203
278
|
return doctor.run_doctor()
|
|
204
279
|
|
|
@@ -440,6 +515,9 @@ def main() -> None:
|
|
|
440
515
|
help="Verify install prerequisites",
|
|
441
516
|
)
|
|
442
517
|
|
|
518
|
+
# settings
|
|
519
|
+
subparsers.add_parser("settings", help="Edit capture/behavior settings")
|
|
520
|
+
|
|
443
521
|
# install-service
|
|
444
522
|
subparsers.add_parser("install-service", help="Install systemd user service")
|
|
445
523
|
|
|
@@ -456,6 +534,7 @@ def main() -> None:
|
|
|
456
534
|
"run": cmd_run,
|
|
457
535
|
"setup": cmd_setup,
|
|
458
536
|
"doctor": cmd_doctor,
|
|
537
|
+
"settings": cmd_settings,
|
|
459
538
|
"install-service": cmd_install_service,
|
|
460
539
|
"status": cmd_status,
|
|
461
540
|
}
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
"""Configuration loading and persistence for solstone-linux.
|
|
5
5
|
|
|
6
|
-
Config lives at ~/.
|
|
6
|
+
Config lives at ~/.config/solstone-linux/config.json.
|
|
7
7
|
Captures go to ~/.local/share/solstone-linux/captures/.
|
|
8
|
-
Screencast restore token at ~/.
|
|
8
|
+
Screencast restore token at ~/.config/solstone-linux/restore_token.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
import json
|
|
14
14
|
import logging
|
|
15
15
|
import os
|
|
16
|
+
import shutil
|
|
16
17
|
import stat
|
|
17
18
|
from dataclasses import dataclass, field
|
|
18
19
|
from pathlib import Path
|
|
@@ -27,6 +28,15 @@ DEFAULT_SYNC_MAX_RETRIES = 10
|
|
|
27
28
|
DEFAULT_SYNC_STALE_THRESHOLD = 600
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
def _default_config_dir() -> Path:
|
|
32
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
33
|
+
if xdg:
|
|
34
|
+
base = Path(xdg)
|
|
35
|
+
if base.is_absolute():
|
|
36
|
+
return base / "solstone-linux"
|
|
37
|
+
return Path.home() / ".config" / "solstone-linux"
|
|
38
|
+
|
|
39
|
+
|
|
30
40
|
@dataclass
|
|
31
41
|
class Config:
|
|
32
42
|
"""Configuration for the Linux desktop observer."""
|
|
@@ -46,15 +56,12 @@ class Config:
|
|
|
46
56
|
draw_cursor: bool = True
|
|
47
57
|
start_paused: bool = False
|
|
48
58
|
base_dir: Path = DEFAULT_BASE_DIR
|
|
59
|
+
config_dir: Path = field(default_factory=_default_config_dir)
|
|
49
60
|
|
|
50
61
|
@property
|
|
51
62
|
def captures_dir(self) -> Path:
|
|
52
63
|
return self.base_dir / "captures"
|
|
53
64
|
|
|
54
|
-
@property
|
|
55
|
-
def config_dir(self) -> Path:
|
|
56
|
-
return self.base_dir / "config"
|
|
57
|
-
|
|
58
65
|
@property
|
|
59
66
|
def state_dir(self) -> Path:
|
|
60
67
|
return self.base_dir / "state"
|
|
@@ -74,11 +81,45 @@ class Config:
|
|
|
74
81
|
self.state_dir.mkdir(parents=True, exist_ok=True)
|
|
75
82
|
|
|
76
83
|
|
|
77
|
-
def
|
|
84
|
+
def _migrate_legacy_config(config: Config) -> None:
|
|
85
|
+
old_dir = config.base_dir / "config"
|
|
86
|
+
if config.config_dir == old_dir:
|
|
87
|
+
return
|
|
88
|
+
if config.config_path.exists():
|
|
89
|
+
return
|
|
90
|
+
old_config = old_dir / "config.json"
|
|
91
|
+
if not old_config.exists():
|
|
92
|
+
return
|
|
93
|
+
try:
|
|
94
|
+
config.config_dir.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
shutil.copy2(old_config, config.config_path)
|
|
96
|
+
os.chmod(config.config_path, stat.S_IRUSR | stat.S_IWUSR)
|
|
97
|
+
old_token = old_dir / "restore_token"
|
|
98
|
+
if old_token.exists():
|
|
99
|
+
shutil.copy2(old_token, config.restore_token_path)
|
|
100
|
+
logger.info(f"Migrated config to {config.config_dir}")
|
|
101
|
+
except OSError as e:
|
|
102
|
+
logger.warning(f"Config migration failed: {e}")
|
|
103
|
+
return
|
|
104
|
+
for p in (old_config, old_dir / "restore_token"):
|
|
105
|
+
try:
|
|
106
|
+
p.unlink()
|
|
107
|
+
except OSError:
|
|
108
|
+
pass
|
|
109
|
+
try:
|
|
110
|
+
old_dir.rmdir()
|
|
111
|
+
except OSError:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def load_config(base_dir: Path | None = None, config_dir: Path | None = None) -> Config:
|
|
78
116
|
"""Load config from disk, returning defaults if not found."""
|
|
79
117
|
config = Config()
|
|
80
118
|
if base_dir:
|
|
81
119
|
config.base_dir = base_dir
|
|
120
|
+
if config_dir:
|
|
121
|
+
config.config_dir = config_dir
|
|
122
|
+
_migrate_legacy_config(config)
|
|
82
123
|
|
|
83
124
|
config_path = config.config_path
|
|
84
125
|
if not config_path.exists():
|
|
@@ -86,6 +86,8 @@ class DBusMenu(ServiceInterface):
|
|
|
86
86
|
|
|
87
87
|
def __init__(self):
|
|
88
88
|
super().__init__("com.canonical.dbusmenu")
|
|
89
|
+
self.on_about_to_show = None
|
|
90
|
+
self._props_emitted = 0
|
|
89
91
|
self._revision = 1
|
|
90
92
|
self._root = MenuItem() # id 0 is root
|
|
91
93
|
self._root.id = 0
|
|
@@ -106,6 +108,7 @@ class DBusMenu(ServiceInterface):
|
|
|
106
108
|
return
|
|
107
109
|
|
|
108
110
|
updated = {name: self._property_variant(item, name) for name in names}
|
|
111
|
+
self._props_emitted += 1
|
|
109
112
|
self.ItemsPropertiesUpdated([[item.id, updated]], [])
|
|
110
113
|
|
|
111
114
|
def _register_items(self, items: list[MenuItem]):
|
|
@@ -198,11 +201,16 @@ class DBusMenu(ServiceInterface):
|
|
|
198
201
|
|
|
199
202
|
@method()
|
|
200
203
|
def AboutToShow(self, item_id: "i") -> "b":
|
|
201
|
-
|
|
204
|
+
if self.on_about_to_show is None:
|
|
205
|
+
return False
|
|
206
|
+
return bool(self.on_about_to_show())
|
|
202
207
|
|
|
203
208
|
@method()
|
|
204
209
|
def AboutToShowGroup(self, ids: "ai") -> "aiai":
|
|
205
|
-
|
|
210
|
+
if self.on_about_to_show is None:
|
|
211
|
+
return [[], []]
|
|
212
|
+
changed = bool(self.on_about_to_show())
|
|
213
|
+
return [list(ids), []] if changed else [[], []]
|
|
206
214
|
|
|
207
215
|
# ── D-Bus Properties ──
|
|
208
216
|
|
|
@@ -160,9 +160,10 @@ class TrayApp:
|
|
|
160
160
|
|
|
161
161
|
return True
|
|
162
162
|
|
|
163
|
-
def update(self):
|
|
163
|
+
def update(self, force_stats=False):
|
|
164
164
|
"""Read observer state and update tray display."""
|
|
165
165
|
obs = self._observer
|
|
166
|
+
now = time.monotonic()
|
|
166
167
|
|
|
167
168
|
# Determine status
|
|
168
169
|
if obs._paused:
|
|
@@ -178,18 +179,17 @@ class TrayApp:
|
|
|
178
179
|
if obs._paused or obs.segment_dir is None:
|
|
179
180
|
segment_timer = 0
|
|
180
181
|
else:
|
|
181
|
-
remaining = obs.interval - (
|
|
182
|
+
remaining = obs.interval - (now - obs.start_at_mono)
|
|
182
183
|
segment_timer = max(0, int(remaining))
|
|
183
184
|
|
|
184
185
|
# Pause remaining
|
|
185
186
|
if not obs._paused or obs._pause_until <= 0:
|
|
186
187
|
pause_remaining = 0
|
|
187
188
|
else:
|
|
188
|
-
pause_remaining = max(0, int(obs._pause_until -
|
|
189
|
+
pause_remaining = max(0, int(obs._pause_until - now))
|
|
189
190
|
|
|
190
191
|
# Compute stats (throttled — filesystem walk every 60s)
|
|
191
|
-
now
|
|
192
|
-
if now - self._last_stats_time >= 60:
|
|
192
|
+
if force_stats or now - self._last_stats_time >= 60:
|
|
193
193
|
self._last_stats_time = now
|
|
194
194
|
captures_today = 0
|
|
195
195
|
total_size = 0
|
|
@@ -220,7 +220,7 @@ class TrayApp:
|
|
|
220
220
|
pass
|
|
221
221
|
|
|
222
222
|
total_size_mb = int(total_size / (1024 * 1024))
|
|
223
|
-
uptime_seconds = int(
|
|
223
|
+
uptime_seconds = int(now - obs._start_mono)
|
|
224
224
|
|
|
225
225
|
self.stats = {
|
|
226
226
|
"captures_today": captures_today,
|
|
@@ -234,6 +234,19 @@ class TrayApp:
|
|
|
234
234
|
self._update_live_stats(segment_timer, pause_remaining)
|
|
235
235
|
self.paused_remaining = pause_remaining
|
|
236
236
|
|
|
237
|
+
def _on_about_to_show(self) -> bool:
|
|
238
|
+
"""Full recompute on menu open; returns True if any item changed.
|
|
239
|
+
|
|
240
|
+
Runs outside _refresh_tray so a failure here never tears down the tray.
|
|
241
|
+
"""
|
|
242
|
+
before = self.menu._props_emitted
|
|
243
|
+
try:
|
|
244
|
+
self.update(force_stats=True)
|
|
245
|
+
except Exception:
|
|
246
|
+
log.warning("Tray on-open recompute failed", exc_info=True)
|
|
247
|
+
return False
|
|
248
|
+
return self.menu._props_emitted > before
|
|
249
|
+
|
|
237
250
|
def _build_menu(self):
|
|
238
251
|
"""Build the full tray menu structure."""
|
|
239
252
|
|
|
@@ -361,6 +374,7 @@ class TrayApp:
|
|
|
361
374
|
service_hint,
|
|
362
375
|
]
|
|
363
376
|
)
|
|
377
|
+
self.menu.on_about_to_show = self._on_about_to_show
|
|
364
378
|
|
|
365
379
|
def _icon_for_health(self, status: str, health: SyncHealth) -> str:
|
|
366
380
|
if self.error:
|
|
@@ -420,6 +434,7 @@ class TrayApp:
|
|
|
420
434
|
self._status_header.label = label
|
|
421
435
|
self._status_item.label = label
|
|
422
436
|
self.menu.update_properties(self._status_header, "label")
|
|
437
|
+
self.menu.update_properties(self._status_item, "label")
|
|
423
438
|
|
|
424
439
|
def _update_sync(self, health: SyncHealth):
|
|
425
440
|
"""Update sync status display."""
|
|
@@ -427,6 +442,7 @@ class TrayApp:
|
|
|
427
442
|
return
|
|
428
443
|
self.health = health
|
|
429
444
|
self._sync_item.label = health.sync_line
|
|
445
|
+
self.menu.update_properties(self._sync_item, "label")
|
|
430
446
|
|
|
431
447
|
if not self.error:
|
|
432
448
|
self.sni.set_icon(self._icon_for_health(self.status, health))
|
|
@@ -446,6 +462,7 @@ class TrayApp:
|
|
|
446
462
|
new_label = f"segment: {mins}:{secs:02d} remaining"
|
|
447
463
|
if self._segment_item.label != new_label:
|
|
448
464
|
self._segment_item.label = new_label
|
|
465
|
+
self.menu.update_properties(self._segment_item, "label")
|
|
449
466
|
|
|
450
467
|
# Stats (computed in update())
|
|
451
468
|
if self.stats:
|
|
@@ -462,10 +479,13 @@ class TrayApp:
|
|
|
462
479
|
|
|
463
480
|
if self._cache_item.label != new_cache:
|
|
464
481
|
self._cache_item.label = new_cache
|
|
482
|
+
self.menu.update_properties(self._cache_item, "label")
|
|
465
483
|
if self._captures_item.label != new_captures:
|
|
466
484
|
self._captures_item.label = new_captures
|
|
485
|
+
self.menu.update_properties(self._captures_item, "label")
|
|
467
486
|
if self._uptime_item.label != new_uptime:
|
|
468
487
|
self._uptime_item.label = new_uptime
|
|
488
|
+
self.menu.update_properties(self._uptime_item, "label")
|
|
469
489
|
|
|
470
490
|
# Update pause remaining in resume button
|
|
471
491
|
if self.status == "paused" and pause_remaining > 0:
|
|
@@ -473,6 +493,7 @@ class TrayApp:
|
|
|
473
493
|
new_resume = f"resume ({pr_mins}m remaining)"
|
|
474
494
|
if self._resume_item.label != new_resume:
|
|
475
495
|
self._resume_item.label = new_resume
|
|
496
|
+
self.menu.update_properties(self._resume_item, "label")
|
|
476
497
|
|
|
477
498
|
def _build_tooltip(self, health: SyncHealth | None = None) -> str:
|
|
478
499
|
"""Build plain-text tooltip body (cross-DE compatible)."""
|
|
@@ -12,6 +12,7 @@ from solstone_linux.cli import (
|
|
|
12
12
|
_cmd_setup_interactive,
|
|
13
13
|
cmd_install_service,
|
|
14
14
|
cmd_setup,
|
|
15
|
+
cmd_settings,
|
|
15
16
|
cmd_status,
|
|
16
17
|
)
|
|
17
18
|
from solstone_linux.config import Config, DEFAULT_SERVER_URL
|
|
@@ -22,6 +23,28 @@ def _args() -> argparse.Namespace:
|
|
|
22
23
|
return argparse.Namespace()
|
|
23
24
|
|
|
24
25
|
|
|
26
|
+
def _settings_config(tmp_path: Path) -> Config:
|
|
27
|
+
return Config(
|
|
28
|
+
base_dir=tmp_path,
|
|
29
|
+
config_dir=tmp_path / "config",
|
|
30
|
+
server_url="https://id",
|
|
31
|
+
key="KKKK",
|
|
32
|
+
stream="strm",
|
|
33
|
+
capture_framerate=2,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _run_settings(tmp_path: Path, inputs: list[str]) -> Config:
|
|
38
|
+
config = _settings_config(tmp_path)
|
|
39
|
+
|
|
40
|
+
with patch("solstone_linux.cli.load_config", return_value=config):
|
|
41
|
+
with patch("solstone_linux.cli.save_config") as save_mock:
|
|
42
|
+
with patch("builtins.input", side_effect=inputs):
|
|
43
|
+
assert cmd_settings(_args()) == 0
|
|
44
|
+
|
|
45
|
+
return save_mock.call_args.args[0]
|
|
46
|
+
|
|
47
|
+
|
|
25
48
|
_BINARY = "/home/user/.local/pipx/venvs/solstone-linux/bin/solstone-linux"
|
|
26
49
|
_EXPECTED_SVGS = {
|
|
27
50
|
"solstone-error.svg",
|
|
@@ -41,6 +64,56 @@ def _is_dir_without_icons(self: Path) -> bool:
|
|
|
41
64
|
return _REAL_IS_DIR(self)
|
|
42
65
|
|
|
43
66
|
|
|
67
|
+
def test_cmd_settings_enter_keeps_all(tmp_path: Path):
|
|
68
|
+
saved_config = _run_settings(tmp_path, ["", "", "", "", "", ""])
|
|
69
|
+
|
|
70
|
+
assert saved_config.capture_framerate == 2
|
|
71
|
+
assert saved_config.draw_cursor is True
|
|
72
|
+
assert saved_config.start_paused is False
|
|
73
|
+
assert saved_config.segment_interval == 300
|
|
74
|
+
assert saved_config.chat_bridge_enabled is True
|
|
75
|
+
assert saved_config.cache_retention_days == 7
|
|
76
|
+
assert saved_config.server_url == "https://id"
|
|
77
|
+
assert saved_config.key == "KKKK"
|
|
78
|
+
assert saved_config.stream == "strm"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_cmd_settings_changes_framerate(tmp_path: Path):
|
|
82
|
+
saved_config = _run_settings(tmp_path, ["5", "", "", "", "", ""])
|
|
83
|
+
|
|
84
|
+
assert saved_config.capture_framerate == 5
|
|
85
|
+
assert saved_config.server_url == "https://id"
|
|
86
|
+
assert saved_config.key == "KKKK"
|
|
87
|
+
assert saved_config.stream == "strm"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_cmd_settings_framerate_clamped(tmp_path: Path):
|
|
91
|
+
saved_config = _run_settings(tmp_path, ["99", "", "", "", "", ""])
|
|
92
|
+
|
|
93
|
+
assert saved_config.capture_framerate == 10
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_cmd_settings_framerate_reprompts_on_invalid(tmp_path: Path):
|
|
97
|
+
saved_config = _run_settings(tmp_path, ["abc", "3", "", "", "", "", ""])
|
|
98
|
+
|
|
99
|
+
assert saved_config.capture_framerate == 3
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_cmd_settings_toggles_bool(tmp_path: Path):
|
|
103
|
+
saved_config = _run_settings(tmp_path, ["", "n", "", "", "", ""])
|
|
104
|
+
|
|
105
|
+
assert saved_config.draw_cursor is False
|
|
106
|
+
assert saved_config.server_url == "https://id"
|
|
107
|
+
assert saved_config.key == "KKKK"
|
|
108
|
+
assert saved_config.stream == "strm"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_cmd_settings_retention_semantics(tmp_path: Path):
|
|
112
|
+
saved_config = _run_settings(tmp_path, ["", "", "", "", "", "-1"])
|
|
113
|
+
|
|
114
|
+
assert saved_config.cache_retention_days == -1
|
|
115
|
+
|
|
116
|
+
|
|
44
117
|
def test_cmd_status_prints_sync_health(tmp_path: Path, monkeypatch, capsys):
|
|
45
118
|
config = Config(
|
|
46
119
|
base_dir=tmp_path,
|