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.
Files changed (68) hide show
  1. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/AGENTS.md +4 -4
  2. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/CHANGELOG.md +13 -0
  3. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/PKG-INFO +1 -1
  4. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/pyproject.toml +1 -1
  5. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/__init__.py +1 -1
  6. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/cli.py +79 -0
  7. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/config.py +48 -7
  8. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/dbusmenu.py +10 -2
  9. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/tray.py +27 -6
  10. solstone_linux-0.4.0/tests/conftest.py +10 -0
  11. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_cli.py +73 -0
  12. solstone_linux-0.4.0/tests/test_config.py +254 -0
  13. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_dbusmenu.py +22 -1
  14. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_observer.py +1 -1
  15. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_tray.py +150 -2
  16. solstone_linux-0.3.2/tests/test_config.py +0 -157
  17. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/.gitignore +0 -0
  18. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/CLAUDE.md +0 -0
  19. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/INSTALL.md +0 -0
  20. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/LICENSE +0 -0
  21. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/Makefile +0 -0
  22. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/README.md +0 -0
  23. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/contrib/icons/hicolor/scalable/status/solstone-error.svg +0 -0
  24. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/contrib/icons/hicolor/scalable/status/solstone-paused.svg +0 -0
  25. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/contrib/icons/hicolor/scalable/status/solstone-recording.svg +0 -0
  26. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/contrib/icons/hicolor/scalable/status/solstone-syncing.svg +0 -0
  27. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/scripts/extract_changelog.sh +0 -0
  28. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/scripts/release.sh +0 -0
  29. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/activity.py +0 -0
  30. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/audio_detect.py +0 -0
  31. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/audio_mute.py +0 -0
  32. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/audio_recorder.py +0 -0
  33. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/chat_bridge.py +0 -0
  34. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/dbus_service.py +0 -0
  35. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/doctor.py +0 -0
  36. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-error.svg +0 -0
  37. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-paused.svg +0 -0
  38. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-recording.svg +0 -0
  39. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-syncing.svg +0 -0
  40. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/install_guard.py +0 -0
  41. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/monitor_positions.py +0 -0
  42. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/observer.py +0 -0
  43. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/recovery.py +0 -0
  44. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/screencast.py +0 -0
  45. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/session_env.py +0 -0
  46. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/sni.py +0 -0
  47. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/solstone-linux.service.in +0 -0
  48. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/streams.py +0 -0
  49. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/sync.py +0 -0
  50. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/sync_health.py +0 -0
  51. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/src/solstone_linux/upload.py +0 -0
  52. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/__init__.py +0 -0
  53. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_activity.py +0 -0
  54. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_chat_bridge.py +0 -0
  55. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_dbus_service.py +0 -0
  56. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_doctor.py +0 -0
  57. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_extract_changelog.py +0 -0
  58. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_install_guard.py +0 -0
  59. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_monitor_positions.py +0 -0
  60. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_observer_emits_stream_silent_event.py +0 -0
  61. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_screencast.py +0 -0
  62. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_screencast_stop_filters_silent_streams.py +0 -0
  63. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_session_env.py +0 -0
  64. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_streams.py +0 -0
  65. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_sync.py +0 -0
  66. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_sync_health.py +0 -0
  67. {solstone_linux-0.3.2 → solstone_linux-0.4.0}/tests/test_sync_health_surfaces.py +0 -0
  68. {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 (~/.local/share/solstone-linux/)
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: `~/.local/share/solstone-linux/config/config.json`
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: `~/.local/share/solstone-linux/config/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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solstone-linux
3
- Version: 0.3.2
3
+ Version: 0.4.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.3.2"
3
+ version = "0.4.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.3.2"
6
+ __version__ = "0.4.0"
@@ -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 ~/.local/share/solstone-linux/config/config.json.
6
+ Config lives at ~/.config/solstone-linux/config.json.
7
7
  Captures go to ~/.local/share/solstone-linux/captures/.
8
- Screencast restore token at ~/.local/share/solstone-linux/config/restore_token.
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 load_config(base_dir: Path | None = None) -> Config:
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
- return False # GetLayout always returns fresh state; no pending unsignaled changes.
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
- return [[], []] # no updates, no errors
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 - (time.monotonic() - obs.start_at_mono)
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 - time.monotonic()))
189
+ pause_remaining = max(0, int(obs._pause_until - now))
189
190
 
190
191
  # Compute stats (throttled — filesystem walk every 60s)
191
- now = time.monotonic()
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(time.monotonic() - obs._start_mono)
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)."""
@@ -0,0 +1,10 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (c) 2026 sol pbc
3
+
4
+ import pytest
5
+
6
+
7
+ @pytest.fixture(autouse=True)
8
+ def _isolate_xdg_config(tmp_path, monkeypatch):
9
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg-config-home"))
10
+ yield
@@ -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,