synodic-client 0.0.1.dev31__tar.gz → 0.0.1.dev33__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 (57) hide show
  1. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/PKG-INFO +2 -2
  2. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/pyproject.toml +2 -2
  3. synodic_client-0.0.1.dev33/synodic_client/_version.py +1 -0
  4. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/application/screen/settings.py +30 -2
  5. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/application/screen/tray.py +68 -171
  6. synodic_client-0.0.1.dev33/synodic_client/application/workers.py +112 -0
  7. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/updater.py +1 -1
  8. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/tests/unit/qt/test_settings.py +48 -0
  9. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/tests/unit/test_updater.py +23 -20
  10. synodic_client-0.0.1.dev31/synodic_client/_version.py +0 -1
  11. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/LICENSE.md +0 -0
  12. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/README.md +0 -0
  13. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/__init__.py +0 -0
  14. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/__main__.py +0 -0
  15. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/application/__init__.py +0 -0
  16. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/application/bootstrap.py +0 -0
  17. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/application/icon.py +0 -0
  18. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/application/instance.py +0 -0
  19. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/application/qt.py +0 -0
  20. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/application/screen/__init__.py +0 -0
  21. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/application/screen/action_card.py +0 -0
  22. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/application/screen/card.py +0 -0
  23. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/application/screen/install.py +0 -0
  24. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/application/screen/log_panel.py +0 -0
  25. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/application/screen/screen.py +0 -0
  26. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/application/screen/spinner.py +0 -0
  27. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/application/theme.py +0 -0
  28. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/application/uri.py +0 -0
  29. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/cli.py +0 -0
  30. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/client.py +0 -0
  31. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/config.py +0 -0
  32. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/logging.py +0 -0
  33. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/protocol.py +0 -0
  34. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/py.typed +0 -0
  35. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/resolution.py +0 -0
  36. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/synodic_client/startup.py +0 -0
  37. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/tests/__init__.py +0 -0
  38. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/tests/conftest.py +0 -0
  39. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/tests/unit/__init__.py +0 -0
  40. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/tests/unit/qt/__init__.py +0 -0
  41. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/tests/unit/qt/conftest.py +0 -0
  42. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/tests/unit/qt/test_action_card.py +0 -0
  43. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/tests/unit/qt/test_install_preview.py +0 -0
  44. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/tests/unit/qt/test_log_panel.py +0 -0
  45. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/tests/unit/qt/test_logging.py +0 -0
  46. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/tests/unit/test_cli.py +0 -0
  47. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/tests/unit/test_client_updater.py +0 -0
  48. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/tests/unit/test_client_version.py +0 -0
  49. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/tests/unit/test_config.py +0 -0
  50. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/tests/unit/test_examples.py +0 -0
  51. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/tests/unit/test_install.py +0 -0
  52. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/tests/unit/test_resolution.py +0 -0
  53. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/tests/unit/test_uri.py +0 -0
  54. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/tests/unit/windows/__init__.py +0 -0
  55. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/tests/unit/windows/conftest.py +0 -0
  56. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/tests/unit/windows/test_protocol.py +0 -0
  57. {synodic_client-0.0.1.dev31 → synodic_client-0.0.1.dev33}/tests/unit/windows/test_startup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: synodic_client
3
- Version: 0.0.1.dev31
3
+ Version: 0.0.1.dev33
4
4
  Author-Email: Synodic Software <contact@synodic.software>
5
5
  License: LGPL-3.0-or-later
6
6
  Project-URL: homepage, https://github.com/synodic/synodic-client
@@ -8,7 +8,7 @@ Project-URL: repository, https://github.com/synodic/synodic-client
8
8
  Requires-Python: <3.15,>=3.14
9
9
  Requires-Dist: pyside6>=6.10.2
10
10
  Requires-Dist: packaging>=26.0
11
- Requires-Dist: porringer>=0.2.1.dev50
11
+ Requires-Dist: porringer>=0.2.1.dev51
12
12
  Requires-Dist: qasync>=0.28.0
13
13
  Requires-Dist: velopack>=0.0.1442.dev64255
14
14
  Requires-Dist: typer>=0.24.1
@@ -10,12 +10,12 @@ requires-python = ">=3.14, <3.15"
10
10
  dependencies = [
11
11
  "pyside6>=6.10.2",
12
12
  "packaging>=26.0",
13
- "porringer>=0.2.1.dev50",
13
+ "porringer>=0.2.1.dev51",
14
14
  "qasync>=0.28.0",
15
15
  "velopack>=0.0.1442.dev64255",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev31"
18
+ version = "0.0.1.dev33"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -0,0 +1 @@
1
+ __version__ = '0.0.1.dev33'
@@ -1,8 +1,8 @@
1
1
  """Settings window for the Synodic Client application.
2
2
 
3
3
  Provides a single-page window with grouped sections for all application
4
- settings. Quick-access items (Channel, Check for Updates) remain in the
5
- tray menu; the full set is available here.
4
+ settings including update-channel selection and a manual *Check for
5
+ Updates* button with inline status feedback.
6
6
  """
7
7
 
8
8
  import logging
@@ -52,6 +52,9 @@ class SettingsWindow(QMainWindow):
52
52
  settings_changed = Signal()
53
53
  """Emitted whenever a setting is changed and persisted."""
54
54
 
55
+ check_updates_requested = Signal()
56
+ """Emitted when the user clicks the *Check for Updates* button."""
57
+
55
58
  def __init__(
56
59
  self,
57
60
  config: GlobalConfiguration,
@@ -155,6 +158,16 @@ class SettingsWindow(QMainWindow):
155
158
  self._detect_updates_check.toggled.connect(self._on_detect_updates_changed)
156
159
  content.addWidget(self._detect_updates_check)
157
160
 
161
+ # Check for Updates
162
+ row = QHBoxLayout()
163
+ self._check_updates_btn = QPushButton('Check for Updates\u2026')
164
+ self._check_updates_btn.clicked.connect(self._on_check_updates_clicked)
165
+ row.addWidget(self._check_updates_btn)
166
+ self._update_status_label = QLabel('')
167
+ row.addWidget(self._update_status_label)
168
+ row.addStretch()
169
+ content.addLayout(row)
170
+
158
171
  return card
159
172
 
160
173
  def _build_startup_section(self) -> CardFrame:
@@ -209,6 +222,14 @@ class SettingsWindow(QMainWindow):
209
222
  self._detect_updates_check.setChecked(config.detect_updates)
210
223
  self._auto_start_check.setChecked(is_startup_registered())
211
224
 
225
+ def set_update_status(self, text: str) -> None:
226
+ """Set the inline status text next to the *Check for Updates* button."""
227
+ self._update_status_label.setText(text)
228
+
229
+ def reset_check_updates_button(self) -> None:
230
+ """Re-enable the *Check for Updates* button after a check completes."""
231
+ self._check_updates_btn.setEnabled(True)
232
+
212
233
  def show(self) -> None:
213
234
  """Sync controls from config, then show the window."""
214
235
  self.sync_from_config()
@@ -235,6 +256,7 @@ class SettingsWindow(QMainWindow):
235
256
  self._tool_update_spin,
236
257
  self._detect_updates_check,
237
258
  self._auto_start_check,
259
+ self._check_updates_btn,
238
260
  )
239
261
  for w in widgets:
240
262
  w.blockSignals(True)
@@ -244,6 +266,12 @@ class SettingsWindow(QMainWindow):
244
266
  for w in widgets:
245
267
  w.blockSignals(False)
246
268
 
269
+ def _on_check_updates_clicked(self) -> None:
270
+ """Handle the *Check for Updates* button click."""
271
+ self._check_updates_btn.setEnabled(False)
272
+ self._update_status_label.setText('Checking\u2026')
273
+ self.check_updates_requested.emit()
274
+
247
275
  def _on_channel_changed(self, index: int) -> None:
248
276
  self._config.update_channel = 'dev' if index == 1 else 'stable'
249
277
  self._persist()
@@ -2,11 +2,10 @@
2
2
 
3
3
  import asyncio
4
4
  import logging
5
- from pathlib import Path
5
+ from collections.abc import Callable
6
6
 
7
7
  from porringer.api import API
8
- from porringer.schema import SetupParameters, SyncStrategy
9
- from PySide6.QtCore import QThread, QTimer, Signal
8
+ from PySide6.QtCore import QThread, QTimer
10
9
  from PySide6.QtGui import QAction
11
10
  from PySide6.QtWidgets import (
12
11
  QApplication,
@@ -19,6 +18,7 @@ from PySide6.QtWidgets import (
19
18
  from synodic_client.application.icon import app_icon
20
19
  from synodic_client.application.screen.screen import MainWindow
21
20
  from synodic_client.application.screen.settings import SettingsWindow
21
+ from synodic_client.application.workers import ToolUpdateWorker, UpdateCheckWorker, UpdateDownloadWorker
22
22
  from synodic_client.client import Client
23
23
  from synodic_client.config import GlobalConfiguration
24
24
  from synodic_client.resolution import (
@@ -27,106 +27,11 @@ from synodic_client.resolution import (
27
27
  resolve_update_config,
28
28
  update_and_resolve,
29
29
  )
30
- from synodic_client.updater import UpdateChannel, UpdateInfo
30
+ from synodic_client.updater import UpdateInfo
31
31
 
32
32
  logger = logging.getLogger(__name__)
33
33
 
34
34
 
35
- class UpdateCheckWorker(QThread):
36
- """Worker for checking updates in a background thread."""
37
-
38
- finished = Signal(object) # UpdateInfo
39
- error = Signal(str)
40
-
41
- def __init__(self, client: Client) -> None:
42
- """Initialize the worker."""
43
- super().__init__()
44
- self._client = client
45
-
46
- def run(self) -> None:
47
- """Run the update check."""
48
- try:
49
- result = self._client.check_for_update()
50
- self.finished.emit(result)
51
- except Exception as e:
52
- logger.exception('Update check failed')
53
- self.error.emit(str(e))
54
-
55
-
56
- class UpdateDownloadWorker(QThread):
57
- """Worker for downloading updates in a background thread."""
58
-
59
- finished = Signal(bool) # success status
60
- progress = Signal(int) # percentage (0-100)
61
- error = Signal(str)
62
-
63
- def __init__(self, client: Client) -> None:
64
- """Initialize the worker."""
65
- super().__init__()
66
- self._client = client
67
-
68
- def run(self) -> None:
69
- """Run the update download."""
70
- try:
71
-
72
- def progress_callback(percentage: int) -> None:
73
- self.progress.emit(percentage)
74
-
75
- success = self._client.download_update(progress_callback)
76
- self.finished.emit(success)
77
- except Exception as e:
78
- logger.exception('Update download failed')
79
- self.error.emit(str(e))
80
-
81
-
82
- class ToolUpdateWorker(QThread):
83
- """Worker for re-syncing manifest-declared tools in a background thread."""
84
-
85
- finished = Signal(int) # number of manifests processed
86
- error = Signal(str)
87
-
88
- def __init__(self, porringer: API, plugins: list[str] | None = None) -> None:
89
- """Initialize the worker.
90
-
91
- Args:
92
- porringer: The porringer API instance.
93
- plugins: Optional include-list of plugin names. When set, only
94
- actions handled by these plugins are executed. ``None``
95
- means all plugins.
96
- """
97
- super().__init__()
98
- self._porringer = porringer
99
- self._plugins = plugins
100
-
101
- def run(self) -> None:
102
- """Re-sync all cached project manifests."""
103
- try:
104
- directories = self._porringer.cache.list_directories()
105
- count = 0
106
- for directory in directories:
107
- path = Path(directory.path)
108
- if not self._porringer.sync.has_manifest(path):
109
- logger.debug('Skipping path without manifest: %s', path)
110
- continue
111
- params = SetupParameters(
112
- paths=[path],
113
- project_directory=path if path.is_dir() else None,
114
- strategy=SyncStrategy.LATEST,
115
- plugins=self._plugins,
116
- )
117
- asyncio.run(self._sync(params))
118
- count += 1
119
- self.finished.emit(count)
120
- except Exception as e:
121
- logger.exception('Tool update failed')
122
- self.error.emit(str(e))
123
-
124
- async def _sync(self, params: SetupParameters) -> None:
125
- """Execute a sync stream for the given parameters."""
126
- async for _event in self._porringer.sync.execute_stream(params):
127
- pass # consume events to completion
128
-
129
-
130
35
  class TrayScreen:
131
36
  """Tray screen for the application."""
132
37
 
@@ -169,17 +74,18 @@ class TrayScreen:
169
74
  # Settings window (created once, shown/hidden on demand)
170
75
  self._settings_window = SettingsWindow(self._resolve_config())
171
76
  self._settings_window.settings_changed.connect(self._on_settings_changed)
77
+ self._settings_window.check_updates_requested.connect(self._on_check_updates)
172
78
 
173
79
  # MainWindow gear button → open settings
174
80
  window.settings_requested.connect(self._show_settings)
175
81
 
176
82
  # Periodic auto-update checking
177
83
  self._auto_update_timer: QTimer | None = None
178
- self._start_auto_update_timer()
84
+ self._restart_auto_update_timer()
179
85
 
180
86
  # Periodic tool update checking
181
87
  self._tool_update_timer: QTimer | None = None
182
- self._start_tool_update_timer()
88
+ self._restart_tool_update_timer()
183
89
 
184
90
  # Connect PluginsView signals when available
185
91
  plugins_view = window.plugins_view
@@ -201,23 +107,6 @@ class TrayScreen:
201
107
  self.update_action.triggered.connect(self._on_check_updates)
202
108
  self.menu.addAction(self.update_action)
203
109
 
204
- # Update Channel submenu
205
- self.channel_menu = QMenu('Update Channel', self.menu)
206
- self.menu.addMenu(self.channel_menu)
207
-
208
- self._channel_stable_action = QAction('Stable', self.channel_menu)
209
- self._channel_stable_action.setCheckable(True)
210
- self._channel_stable_action.triggered.connect(lambda: self._on_channel_changed(UpdateChannel.STABLE))
211
- self.channel_menu.addAction(self._channel_stable_action)
212
-
213
- self._channel_dev_action = QAction('Development', self.channel_menu)
214
- self._channel_dev_action.setCheckable(True)
215
- self._channel_dev_action.triggered.connect(lambda: self._on_channel_changed(UpdateChannel.DEVELOPMENT))
216
- self.channel_menu.addAction(self._channel_dev_action)
217
-
218
- # Set initial channel check state from config
219
- self._sync_channel_checks()
220
-
221
110
  self.menu.addSeparator()
222
111
 
223
112
  self.settings_action = QAction('Settings\u2026', self.menu)
@@ -240,50 +129,57 @@ class TrayScreen:
240
129
  return self._config
241
130
  return resolve_config()
242
131
 
243
- def _start_auto_update_timer(self) -> None:
244
- """Start (or restart) the periodic auto-update timer from config."""
245
- if self._auto_update_timer is not None:
246
- self._auto_update_timer.stop()
247
- self._auto_update_timer = None
248
-
249
- config = resolve_update_config(self._resolve_config())
250
- interval_minutes = config.auto_update_interval_minutes
251
- if interval_minutes <= 0:
252
- logger.info('Automatic update checking is disabled')
253
- return
132
+ def _restart_timer(
133
+ self,
134
+ current: QTimer | None,
135
+ interval_minutes: int,
136
+ slot: Callable[[], None],
137
+ label: str,
138
+ ) -> QTimer | None:
139
+ """Stop *current* and return a new periodic timer, or ``None``.
254
140
 
255
- interval_ms = interval_minutes * 60 * 1000
256
- self._auto_update_timer = QTimer()
257
- self._auto_update_timer.setInterval(interval_ms)
258
- self._auto_update_timer.timeout.connect(self._on_auto_check_updates)
259
- self._auto_update_timer.start()
260
- logger.info('Automatic update checking enabled (every %d minute(s))', interval_minutes)
141
+ Args:
142
+ current: The existing timer to stop (may be ``None``).
143
+ interval_minutes: Interval in minutes. ``0`` disables.
144
+ slot: The callable to invoke on each tick.
145
+ label: Human-readable name for log messages.
261
146
 
262
- def _start_tool_update_timer(self) -> None:
263
- """Start (or restart) the periodic tool update timer from config."""
264
- if self._tool_update_timer is not None:
265
- self._tool_update_timer.stop()
266
- self._tool_update_timer = None
147
+ Returns:
148
+ A running ``QTimer``, or ``None`` when disabled.
149
+ """
150
+ if current is not None:
151
+ current.stop()
267
152
 
268
- config = resolve_update_config(self._resolve_config())
269
- interval_minutes = config.tool_update_interval_minutes
270
153
  if interval_minutes <= 0:
271
- logger.info('Automatic tool updating is disabled')
272
- return
154
+ logger.info('%s is disabled', label)
155
+ return None
273
156
 
274
- interval_ms = interval_minutes * 60 * 1000
275
- self._tool_update_timer = QTimer()
276
- self._tool_update_timer.setInterval(interval_ms)
277
- self._tool_update_timer.timeout.connect(self._on_tool_update)
278
- self._tool_update_timer.start()
279
- logger.info('Automatic tool updating enabled (every %d minute(s))', interval_minutes)
157
+ timer = QTimer()
158
+ timer.setInterval(interval_minutes * 60 * 1000)
159
+ timer.timeout.connect(slot)
160
+ timer.start()
161
+ logger.info('%s enabled (every %d minute(s))', label, interval_minutes)
162
+ return timer
280
163
 
281
- def _sync_channel_checks(self) -> None:
282
- """Synchronize channel checkmarks with the current config."""
283
- config = self._resolve_config()
284
- is_dev = config.update_channel == 'dev'
285
- self._channel_stable_action.setChecked(not is_dev)
286
- self._channel_dev_action.setChecked(is_dev)
164
+ def _restart_auto_update_timer(self) -> None:
165
+ """Start (or restart) the periodic auto-update timer from config."""
166
+ config = resolve_update_config(self._resolve_config())
167
+ self._auto_update_timer = self._restart_timer(
168
+ self._auto_update_timer,
169
+ config.auto_update_interval_minutes,
170
+ self._on_auto_check_updates,
171
+ 'Automatic update checking',
172
+ )
173
+
174
+ def _restart_tool_update_timer(self) -> None:
175
+ """Start (or restart) the periodic tool update timer from config."""
176
+ config = resolve_update_config(self._resolve_config())
177
+ self._tool_update_timer = self._restart_timer(
178
+ self._tool_update_timer,
179
+ config.tool_update_interval_minutes,
180
+ self._on_tool_update,
181
+ 'Automatic tool updating',
182
+ )
287
183
 
288
184
  def _on_tray_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
289
185
  """Handle tray icon activation (e.g. double-click)."""
@@ -300,25 +196,13 @@ class TrayScreen:
300
196
  """React to a change made in the settings window."""
301
197
  config = self._resolve_config()
302
198
  self._reinitialize_updater(config)
303
- self._sync_channel_checks()
304
-
305
- def _on_channel_changed(self, channel: UpdateChannel) -> None:
306
- """Handle channel selection change from the tray submenu."""
307
- config = self._resolve_config()
308
- config.update_channel = 'dev' if channel == UpdateChannel.DEVELOPMENT else 'stable'
309
- logger.info('Update channel changed to: %s', config.update_channel)
310
- self._sync_channel_checks()
311
- self._reinitialize_updater(config)
312
- # Keep the settings window in sync if it is visible
313
- if self._settings_window.isVisible():
314
- self._settings_window.sync_from_config()
315
199
 
316
200
  def _reinitialize_updater(self, config: GlobalConfiguration) -> None:
317
201
  """Re-derive update settings and restart the updater and timers."""
318
202
  update_cfg = update_and_resolve(config)
319
203
  self._client.initialize_updater(update_cfg)
320
- self._start_auto_update_timer()
321
- self._start_tool_update_timer()
204
+ self._restart_auto_update_timer()
205
+ self._restart_tool_update_timer()
322
206
  logger.info('Updater re-initialized (channel: %s, source: %s)', update_cfg.channel.name, update_cfg.repo_url)
323
207
 
324
208
  def _reset_update_action(self) -> None:
@@ -361,9 +245,11 @@ class TrayScreen:
361
245
  )
362
246
  return
363
247
 
364
- # Disable the action while checking
248
+ # Disable both the tray action and the settings button while checking
365
249
  self.update_action.setEnabled(False)
366
250
  self.update_action.setText('Checking for Updates...')
251
+ self._settings_window._check_updates_btn.setEnabled(False)
252
+ self._settings_window.set_update_status('Checking\u2026')
367
253
 
368
254
  worker = UpdateCheckWorker(self._client)
369
255
  worker.finished.connect(lambda result: self._on_update_check_finished(result, silent=silent))
@@ -375,8 +261,10 @@ class TrayScreen:
375
261
  def _on_update_check_finished(self, result: UpdateInfo | None, *, silent: bool = False) -> None:
376
262
  """Handle update check completion."""
377
263
  self._reset_update_action()
264
+ self._settings_window.reset_check_updates_button()
378
265
 
379
266
  if result is None:
267
+ self._settings_window.set_update_status('Check failed')
380
268
  if not silent:
381
269
  self.tray.showMessage(
382
270
  'Update Check Failed',
@@ -388,6 +276,7 @@ class TrayScreen:
388
276
  return
389
277
 
390
278
  if result.error:
279
+ self._settings_window.set_update_status(result.error)
391
280
  if not silent:
392
281
  # Distinguish informational messages (no releases for channel)
393
282
  # from genuine failures.
@@ -402,6 +291,9 @@ class TrayScreen:
402
291
  return
403
292
 
404
293
  if not result.available:
294
+ self._settings_window.set_update_status(
295
+ f'Up to date ({result.current_version})',
296
+ )
405
297
  if not silent:
406
298
  self.tray.showMessage(
407
299
  'No Updates Available',
@@ -414,6 +306,9 @@ class TrayScreen:
414
306
 
415
307
  # Update available - always show notification, clicking it starts download
416
308
  self._pending_update_info = result
309
+ self._settings_window.set_update_status(
310
+ f'Update available: {result.latest_version}',
311
+ )
417
312
  self.tray.showMessage(
418
313
  'Update Available',
419
314
  f'Version {result.latest_version} is available (current: {result.current_version}).\nClick to download.',
@@ -423,6 +318,8 @@ class TrayScreen:
423
318
  def _on_update_check_error(self, error: str, *, silent: bool = False) -> None:
424
319
  """Handle update check error."""
425
320
  self._reset_update_action()
321
+ self._settings_window.reset_check_updates_button()
322
+ self._settings_window.set_update_status(f'Error: {error}')
426
323
 
427
324
  if not silent:
428
325
  self.tray.showMessage(
@@ -0,0 +1,112 @@
1
+ """Background worker threads for the Synodic Client application.
2
+
3
+ Each worker wraps an off-main-thread operation and communicates results
4
+ back via Qt signals so that callers remain responsive.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ from pathlib import Path
10
+
11
+ from porringer.api import API
12
+ from porringer.schema import SetupParameters, SyncStrategy
13
+ from PySide6.QtCore import QThread, Signal
14
+
15
+ from synodic_client.client import Client
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class UpdateCheckWorker(QThread):
21
+ """Worker for checking updates in a background thread."""
22
+
23
+ finished = Signal(object) # UpdateInfo
24
+ error = Signal(str)
25
+
26
+ def __init__(self, client: Client) -> None:
27
+ """Initialize the worker."""
28
+ super().__init__()
29
+ self._client = client
30
+
31
+ def run(self) -> None:
32
+ """Run the update check."""
33
+ try:
34
+ result = self._client.check_for_update()
35
+ self.finished.emit(result)
36
+ except Exception as e:
37
+ logger.exception('Update check failed')
38
+ self.error.emit(str(e))
39
+
40
+
41
+ class UpdateDownloadWorker(QThread):
42
+ """Worker for downloading updates in a background thread."""
43
+
44
+ finished = Signal(bool) # success status
45
+ progress = Signal(int) # percentage (0-100)
46
+ error = Signal(str)
47
+
48
+ def __init__(self, client: Client) -> None:
49
+ """Initialize the worker."""
50
+ super().__init__()
51
+ self._client = client
52
+
53
+ def run(self) -> None:
54
+ """Run the update download."""
55
+ try:
56
+
57
+ def progress_callback(percentage: int) -> None:
58
+ self.progress.emit(percentage)
59
+
60
+ success = self._client.download_update(progress_callback)
61
+ self.finished.emit(success)
62
+ except Exception as e:
63
+ logger.exception('Update download failed')
64
+ self.error.emit(str(e))
65
+
66
+
67
+ class ToolUpdateWorker(QThread):
68
+ """Worker for re-syncing manifest-declared tools in a background thread."""
69
+
70
+ finished = Signal(int) # number of manifests processed
71
+ error = Signal(str)
72
+
73
+ def __init__(self, porringer: API, plugins: list[str] | None = None) -> None:
74
+ """Initialize the worker.
75
+
76
+ Args:
77
+ porringer: The porringer API instance.
78
+ plugins: Optional include-list of plugin names. When set, only
79
+ actions handled by these plugins are executed. ``None``
80
+ means all plugins.
81
+ """
82
+ super().__init__()
83
+ self._porringer = porringer
84
+ self._plugins = plugins
85
+
86
+ def run(self) -> None:
87
+ """Re-sync all cached project manifests."""
88
+ try:
89
+ directories = self._porringer.cache.list_directories()
90
+ count = 0
91
+ for directory in directories:
92
+ path = Path(directory.path)
93
+ if not self._porringer.sync.has_manifest(path):
94
+ logger.debug('Skipping path without manifest: %s', path)
95
+ continue
96
+ params = SetupParameters(
97
+ paths=[path],
98
+ project_directory=path if path.is_dir() else None,
99
+ strategy=SyncStrategy.LATEST,
100
+ plugins=self._plugins,
101
+ )
102
+ asyncio.run(self._sync(params))
103
+ count += 1
104
+ self.finished.emit(count)
105
+ except Exception as e:
106
+ logger.exception('Tool update failed')
107
+ self.error.emit(str(e))
108
+
109
+ async def _sync(self, params: SetupParameters) -> None:
110
+ """Execute a sync stream for the given parameters."""
111
+ async for _event in self._porringer.sync.execute_stream(params):
112
+ pass # consume events to completion
@@ -210,7 +210,7 @@ class Updater:
210
210
  velopack_info = manager.check_for_updates()
211
211
 
212
212
  if velopack_info is not None:
213
- latest = Version(velopack_info.target_full_release.version)
213
+ latest = Version(velopack_info.TargetFullRelease.Version)
214
214
 
215
215
  self._update_info = UpdateInfo(
216
216
  available=True,
@@ -306,3 +306,51 @@ class TestSyncDoesNotEmit:
306
306
  window.sync_from_config()
307
307
 
308
308
  signal_spy.assert_not_called()
309
+
310
+
311
+ # ---------------------------------------------------------------------------
312
+ # Check for Updates button
313
+ # ---------------------------------------------------------------------------
314
+
315
+
316
+ class TestCheckForUpdatesButton:
317
+ """Verify the Check for Updates button and inline status label."""
318
+
319
+ @staticmethod
320
+ def test_button_and_label_exist() -> None:
321
+ """Window has the check-updates button and status label."""
322
+ window = _make_window()
323
+ assert hasattr(window, '_check_updates_btn')
324
+ assert hasattr(window, '_update_status_label')
325
+ assert window._check_updates_btn.text() == 'Check for Updates\u2026'
326
+ assert window._update_status_label.text() == ''
327
+
328
+ @staticmethod
329
+ def test_click_emits_signal_and_disables() -> None:
330
+ """Clicking the button emits check_updates_requested and disables it."""
331
+ window = _make_window()
332
+ signal_spy = MagicMock()
333
+ window.check_updates_requested.connect(signal_spy)
334
+
335
+ window._check_updates_btn.click()
336
+
337
+ signal_spy.assert_called_once()
338
+ assert window._check_updates_btn.isEnabled() is False
339
+ assert window._update_status_label.text() == 'Checking\u2026'
340
+
341
+ @staticmethod
342
+ def test_set_update_status() -> None:
343
+ """set_update_status sets the label text."""
344
+ window = _make_window()
345
+ window.set_update_status('Up to date (v1.0.0)')
346
+ assert window._update_status_label.text() == 'Up to date (v1.0.0)'
347
+
348
+ @staticmethod
349
+ def test_reset_check_updates_button() -> None:
350
+ """reset_check_updates_button re-enables the button."""
351
+ window = _make_window()
352
+ window._check_updates_btn.setEnabled(False)
353
+
354
+ window.reset_check_updates_button()
355
+
356
+ assert window._check_updates_btn.isEnabled() is True
@@ -3,6 +3,7 @@
3
3
  from unittest.mock import MagicMock, PropertyMock, patch
4
4
 
5
5
  import pytest
6
+ import velopack
6
7
  from packaging.version import Version
7
8
 
8
9
  from synodic_client.updater import (
@@ -119,7 +120,7 @@ class TestUpdater:
119
120
  @staticmethod
120
121
  def test_is_installed_with_velopack(updater: Updater) -> None:
121
122
  """Verify is_installed returns True when Velopack manager available."""
122
- mock_manager = MagicMock()
123
+ mock_manager = MagicMock(spec=velopack.UpdateManager)
123
124
  with patch.object(updater, '_get_velopack_manager', return_value=mock_manager):
124
125
  assert updater.is_installed is True
125
126
 
@@ -146,7 +147,7 @@ class TestUpdaterCheckForUpdate:
146
147
  @staticmethod
147
148
  def test_check_no_update(updater: Updater) -> None:
148
149
  """Verify check_for_update handles no update available."""
149
- mock_manager = MagicMock()
150
+ mock_manager = MagicMock(spec=velopack.UpdateManager)
150
151
  mock_manager.check_for_updates.return_value = None
151
152
 
152
153
  with patch.object(updater, '_get_velopack_manager', return_value=mock_manager):
@@ -159,10 +160,12 @@ class TestUpdaterCheckForUpdate:
159
160
  @staticmethod
160
161
  def test_check_update_available(updater: Updater) -> None:
161
162
  """Verify check_for_update handles update available."""
162
- mock_velopack_info = MagicMock()
163
- mock_velopack_info.target_full_release.version = '2.0.0'
163
+ mock_target = MagicMock(spec=velopack.VelopackAsset)
164
+ mock_target.Version = '2.0.0'
165
+ mock_velopack_info = MagicMock(spec=velopack.UpdateInfo)
166
+ mock_velopack_info.TargetFullRelease = mock_target
164
167
 
165
- mock_manager = MagicMock()
168
+ mock_manager = MagicMock(spec=velopack.UpdateManager)
166
169
  mock_manager.check_for_updates.return_value = mock_velopack_info
167
170
 
168
171
  with patch.object(updater, '_get_velopack_manager', return_value=mock_manager):
@@ -176,7 +179,7 @@ class TestUpdaterCheckForUpdate:
176
179
  @staticmethod
177
180
  def test_check_error(updater: Updater) -> None:
178
181
  """Verify check_for_update handles errors gracefully."""
179
- mock_manager = MagicMock()
182
+ mock_manager = MagicMock(spec=velopack.UpdateManager)
180
183
  mock_manager.check_for_updates.side_effect = Exception('Network error')
181
184
 
182
185
  with patch.object(updater, '_get_velopack_manager', return_value=mock_manager):
@@ -189,7 +192,7 @@ class TestUpdaterCheckForUpdate:
189
192
  @staticmethod
190
193
  def test_check_404_returns_friendly_message(updater: Updater) -> None:
191
194
  """Verify a 404 from GitHub returns a friendly no-releases message."""
192
- mock_manager = MagicMock()
195
+ mock_manager = MagicMock(spec=velopack.UpdateManager)
193
196
  mock_manager.check_for_updates.side_effect = RuntimeError('Network error: Http error: http status: 404')
194
197
 
195
198
  with patch.object(updater, '_get_velopack_manager', return_value=mock_manager):
@@ -205,7 +208,7 @@ class TestUpdaterCheckForUpdate:
205
208
  @staticmethod
206
209
  def test_check_non_404_http_error_is_failed(updater: Updater) -> None:
207
210
  """Verify non-404 HTTP errors still produce FAILED state."""
208
- mock_manager = MagicMock()
211
+ mock_manager = MagicMock(spec=velopack.UpdateManager)
209
212
  mock_manager.check_for_updates.side_effect = RuntimeError('Network error: Http error: http status: 500')
210
213
 
211
214
  with patch.object(updater, '_get_velopack_manager', return_value=mock_manager):
@@ -238,7 +241,7 @@ class TestUpdaterDownloadUpdate:
238
241
  @staticmethod
239
242
  def test_download_success(updater: Updater) -> None:
240
243
  """Verify download_update succeeds with valid update info."""
241
- mock_velopack_info = MagicMock()
244
+ mock_velopack_info = MagicMock(spec=velopack.UpdateInfo)
242
245
  updater._update_info = UpdateInfo(
243
246
  available=True,
244
247
  current_version=Version('1.0.0'),
@@ -247,7 +250,7 @@ class TestUpdaterDownloadUpdate:
247
250
  )
248
251
  updater._state = UpdateState.UPDATE_AVAILABLE
249
252
 
250
- mock_manager = MagicMock()
253
+ mock_manager = MagicMock(spec=velopack.UpdateManager)
251
254
 
252
255
  with (
253
256
  patch.object(Updater, 'is_installed', new_callable=PropertyMock, return_value=True),
@@ -262,7 +265,7 @@ class TestUpdaterDownloadUpdate:
262
265
  @staticmethod
263
266
  def test_download_with_progress_callback(updater: Updater) -> None:
264
267
  """Verify download_update passes progress callback."""
265
- mock_velopack_info = MagicMock()
268
+ mock_velopack_info = MagicMock(spec=velopack.UpdateInfo)
266
269
  updater._update_info = UpdateInfo(
267
270
  available=True,
268
271
  current_version=Version('1.0.0'),
@@ -271,7 +274,7 @@ class TestUpdaterDownloadUpdate:
271
274
  )
272
275
  updater._state = UpdateState.UPDATE_AVAILABLE
273
276
 
274
- mock_manager = MagicMock()
277
+ mock_manager = MagicMock(spec=velopack.UpdateManager)
275
278
  progress_cb = MagicMock()
276
279
 
277
280
  with (
@@ -286,7 +289,7 @@ class TestUpdaterDownloadUpdate:
286
289
  @staticmethod
287
290
  def test_download_error(updater: Updater) -> None:
288
291
  """Verify download_update handles errors gracefully."""
289
- mock_velopack_info = MagicMock()
292
+ mock_velopack_info = MagicMock(spec=velopack.UpdateInfo)
290
293
  updater._update_info = UpdateInfo(
291
294
  available=True,
292
295
  current_version=Version('1.0.0'),
@@ -295,7 +298,7 @@ class TestUpdaterDownloadUpdate:
295
298
  )
296
299
  updater._state = UpdateState.UPDATE_AVAILABLE
297
300
 
298
- mock_manager = MagicMock()
301
+ mock_manager = MagicMock(spec=velopack.UpdateManager)
299
302
  mock_manager.download_updates.side_effect = Exception('Download failed')
300
303
 
301
304
  with (
@@ -351,7 +354,7 @@ class TestUpdaterApplyUpdate:
351
354
  @staticmethod
352
355
  def test_apply_on_exit_success(updater: Updater) -> None:
353
356
  """Verify apply_update_on_exit schedules update."""
354
- mock_velopack_info = MagicMock()
357
+ mock_velopack_info = MagicMock(spec=velopack.UpdateInfo)
355
358
  updater._update_info = UpdateInfo(
356
359
  available=True,
357
360
  current_version=Version('1.0.0'),
@@ -360,7 +363,7 @@ class TestUpdaterApplyUpdate:
360
363
  )
361
364
  updater._state = UpdateState.DOWNLOADED
362
365
 
363
- mock_manager = MagicMock()
366
+ mock_manager = MagicMock(spec=velopack.UpdateManager)
364
367
 
365
368
  with (
366
369
  patch.object(Updater, 'is_installed', new_callable=PropertyMock, return_value=True),
@@ -374,7 +377,7 @@ class TestUpdaterApplyUpdate:
374
377
  @staticmethod
375
378
  def test_apply_on_exit_no_restart(updater: Updater) -> None:
376
379
  """Verify apply_update_on_exit can disable restart (note: not supported by Velopack)."""
377
- mock_velopack_info = MagicMock()
380
+ mock_velopack_info = MagicMock(spec=velopack.UpdateInfo)
378
381
  updater._update_info = UpdateInfo(
379
382
  available=True,
380
383
  current_version=Version('1.0.0'),
@@ -383,7 +386,7 @@ class TestUpdaterApplyUpdate:
383
386
  )
384
387
  updater._state = UpdateState.DOWNLOADED
385
388
 
386
- mock_manager = MagicMock()
389
+ mock_manager = MagicMock(spec=velopack.UpdateManager)
387
390
 
388
391
  with (
389
392
  patch.object(Updater, 'is_installed', new_callable=PropertyMock, return_value=True),
@@ -401,7 +404,7 @@ class TestInitializeVelopack:
401
404
  @staticmethod
402
405
  def test_initialize_success() -> None:
403
406
  """Verify initialize_velopack calls App().run()."""
404
- mock_app = MagicMock()
407
+ mock_app = MagicMock(spec=velopack.App)
405
408
  with patch('synodic_client.updater.velopack.App', return_value=mock_app) as mock_app_class:
406
409
  initialize_velopack()
407
410
  mock_app_class.assert_called_once()
@@ -410,7 +413,7 @@ class TestInitializeVelopack:
410
413
  @staticmethod
411
414
  def test_initialize_handles_exception() -> None:
412
415
  """Verify initialize_velopack handles exceptions gracefully."""
413
- mock_app = MagicMock()
416
+ mock_app = MagicMock(spec=velopack.App)
414
417
  mock_app.run.side_effect = Exception('Test')
415
418
  with patch('synodic_client.updater.velopack.App', return_value=mock_app):
416
419
  # Should not raise
@@ -1 +0,0 @@
1
- __version__ = '0.0.1.dev31'