synodic-client 0.0.1.dev41__tar.gz → 0.0.1.dev42__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 (69) hide show
  1. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/pyproject.toml +1 -1
  3. synodic_client-0.0.1.dev42/synodic_client/_version.py +1 -0
  4. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/settings.py +20 -3
  5. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/tray.py +13 -186
  6. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/theme.py +15 -0
  7. synodic_client-0.0.1.dev42/synodic_client/application/update_controller.py +312 -0
  8. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/config.py +4 -0
  9. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/resolution.py +3 -0
  10. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_gather_packages.py +1 -0
  11. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_settings.py +5 -3
  12. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_tray_window_show.py +1 -0
  13. synodic_client-0.0.1.dev42/tests/unit/qt/test_update_controller.py +298 -0
  14. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/test_config.py +1 -0
  15. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/test_resolution.py +1 -0
  16. synodic_client-0.0.1.dev41/synodic_client/_version.py +0 -1
  17. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/LICENSE.md +0 -0
  18. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/README.md +0 -0
  19. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/__init__.py +0 -0
  20. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/__main__.py +0 -0
  21. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/__init__.py +0 -0
  22. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/bootstrap.py +0 -0
  23. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/data.py +0 -0
  24. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/icon.py +0 -0
  25. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/instance.py +0 -0
  26. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/qt.py +0 -0
  27. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/__init__.py +0 -0
  28. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/action_card.py +0 -0
  29. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/card.py +0 -0
  30. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/install.py +0 -0
  31. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/log_panel.py +0 -0
  32. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/screen.py +0 -0
  33. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/sidebar.py +0 -0
  34. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/spinner.py +0 -0
  35. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/update_banner.py +0 -0
  36. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/uri.py +0 -0
  37. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/workers.py +0 -0
  38. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/cli.py +0 -0
  39. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/client.py +0 -0
  40. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/logging.py +0 -0
  41. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/protocol.py +0 -0
  42. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/py.typed +0 -0
  43. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/startup.py +0 -0
  44. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/updater.py +0 -0
  45. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/__init__.py +0 -0
  46. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/conftest.py +0 -0
  47. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/__init__.py +0 -0
  48. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/__init__.py +0 -0
  49. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/conftest.py +0 -0
  50. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_action_card.py +0 -0
  51. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_install_preview.py +0 -0
  52. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_log_panel.py +0 -0
  53. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_logging.py +0 -0
  54. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_preview_model.py +0 -0
  55. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_sidebar.py +0 -0
  56. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_update_banner.py +0 -0
  57. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_update_feedback.py +0 -0
  58. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/test_cli.py +0 -0
  59. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/test_client_updater.py +0 -0
  60. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/test_client_version.py +0 -0
  61. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/test_examples.py +0 -0
  62. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/test_install.py +0 -0
  63. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/test_updater.py +0 -0
  64. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/test_uri.py +0 -0
  65. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/test_workers.py +0 -0
  66. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/windows/__init__.py +0 -0
  67. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/windows/conftest.py +0 -0
  68. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/windows/test_protocol.py +0 -0
  69. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/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.dev41
3
+ Version: 0.0.1.dev42
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
@@ -15,7 +15,7 @@ dependencies = [
15
15
  "velopack>=0.0.1444.dev49733",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev41"
18
+ version = "0.0.1.dev42"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -0,0 +1 @@
1
+ __version__ = '0.0.1.dev42'
@@ -29,7 +29,7 @@ from PySide6.QtWidgets import (
29
29
 
30
30
  from synodic_client.application.icon import app_icon
31
31
  from synodic_client.application.screen.card import CardFrame
32
- from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE
32
+ from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE, UPDATE_STATUS_CHECKING_STYLE
33
33
  from synodic_client.logging import log_path
34
34
  from synodic_client.resolution import ResolvedConfig, update_user_config
35
35
  from synodic_client.startup import is_startup_registered, register_startup, remove_startup
@@ -163,6 +163,11 @@ class SettingsWindow(QMainWindow):
163
163
  self._detect_updates_check.toggled.connect(self._on_detect_updates_changed)
164
164
  content.addWidget(self._detect_updates_check)
165
165
 
166
+ # Automatically apply updates
167
+ self._auto_apply_check = QCheckBox('Automatically apply updates')
168
+ self._auto_apply_check.toggled.connect(self._on_auto_apply_changed)
169
+ content.addWidget(self._auto_apply_check)
170
+
166
171
  # Check for Updates
167
172
  row = QHBoxLayout()
168
173
  self._check_updates_btn = QPushButton('Check for Updates\u2026')
@@ -218,16 +223,24 @@ class SettingsWindow(QMainWindow):
218
223
 
219
224
  # Checkboxes
220
225
  self._detect_updates_check.setChecked(config.detect_updates)
226
+ self._auto_apply_check.setChecked(config.auto_apply)
221
227
  self._auto_start_check.setChecked(is_startup_registered())
222
228
 
223
- def set_update_status(self, text: str) -> None:
224
- """Set the inline status text next to the *Check for Updates* button."""
229
+ def set_update_status(self, text: str, style: str = '') -> None:
230
+ """Set the inline status text next to the *Check for Updates* button.
231
+
232
+ Args:
233
+ text: The status message.
234
+ style: Optional stylesheet for the label (e.g. color).
235
+ """
225
236
  self._update_status_label.setText(text)
237
+ self._update_status_label.setStyleSheet(style)
226
238
 
227
239
  def set_checking(self) -> None:
228
240
  """Enter the *checking* state — disable button and show status."""
229
241
  self._check_updates_btn.setEnabled(False)
230
242
  self._update_status_label.setText('Checking\u2026')
243
+ self._update_status_label.setStyleSheet(UPDATE_STATUS_CHECKING_STYLE)
231
244
 
232
245
  def reset_check_updates_button(self) -> None:
233
246
  """Re-enable the *Check for Updates* button after a check completes."""
@@ -262,6 +275,7 @@ class SettingsWindow(QMainWindow):
262
275
  self._auto_update_spin,
263
276
  self._tool_update_spin,
264
277
  self._detect_updates_check,
278
+ self._auto_apply_check,
265
279
  self._auto_start_check,
266
280
  self._check_updates_btn,
267
281
  )
@@ -301,6 +315,9 @@ class SettingsWindow(QMainWindow):
301
315
  def _on_detect_updates_changed(self, checked: bool) -> None:
302
316
  self._persist(detect_updates=checked)
303
317
 
318
+ def _on_auto_apply_changed(self, checked: bool) -> None:
319
+ self._persist(auto_apply=checked)
320
+
304
321
  def _on_auto_start_changed(self, checked: bool) -> None:
305
322
  self._config = update_user_config(auto_start=checked)
306
323
  if checked:
@@ -17,10 +17,9 @@ from PySide6.QtWidgets import (
17
17
  from synodic_client.application.icon import app_icon
18
18
  from synodic_client.application.screen.screen import MainWindow, ToolsView
19
19
  from synodic_client.application.screen.settings import SettingsWindow
20
+ from synodic_client.application.update_controller import UpdateController
20
21
  from synodic_client.application.workers import (
21
22
  ToolUpdateResult,
22
- check_for_update,
23
- download_update,
24
23
  run_package_remove,
25
24
  run_tool_updates,
26
25
  )
@@ -31,7 +30,6 @@ from synodic_client.resolution import (
31
30
  resolve_config,
32
31
  resolve_update_config,
33
32
  )
34
- from synodic_client.updater import UpdateInfo
35
33
 
36
34
  logger = logging.getLogger(__name__)
37
35
 
@@ -59,7 +57,6 @@ class TrayScreen:
59
57
  self._client = client
60
58
  self._window = window
61
59
  self._config = config
62
- self._update_task: asyncio.Task[None] | None = None
63
60
  self._tool_task: asyncio.Task[None] | None = None
64
61
 
65
62
  self.tray_icon = app_icon()
@@ -74,14 +71,19 @@ class TrayScreen:
74
71
  # Settings window (created once, shown/hidden on demand)
75
72
  self._settings_window = SettingsWindow(self._resolve_config())
76
73
  self._settings_window.settings_changed.connect(self._on_settings_changed)
77
- self._settings_window.check_updates_requested.connect(self._on_check_updates)
78
74
 
79
75
  # MainWindow gear button → open settings
80
76
  window.settings_requested.connect(self._show_settings)
81
77
 
82
- # Periodic auto-update checking
83
- self._auto_update_timer: QTimer | None = None
84
- self._restart_auto_update_timer()
78
+ # Update controller — owns the self-update lifecycle & timer
79
+ self._banner = window.update_banner
80
+ self._update_controller = UpdateController(
81
+ app,
82
+ client,
83
+ self._banner,
84
+ self._settings_window,
85
+ config,
86
+ )
85
87
 
86
88
  # Periodic tool update checking
87
89
  self._tool_update_timer: QTimer | None = None
@@ -90,11 +92,6 @@ class TrayScreen:
90
92
  # Connect ToolsView signals — deferred because ToolsView is created lazily
91
93
  window.tools_view_created.connect(self._connect_tools_view)
92
94
 
93
- # Connect update banner signals
94
- self._banner = window.update_banner
95
- self._banner.restart_requested.connect(self._apply_update)
96
- self._banner.retry_requested.connect(lambda: self._do_check_updates(silent=True))
97
-
98
95
  def _build_menu(self, app: QApplication, window: MainWindow) -> None:
99
96
  """Build the tray context menu."""
100
97
  self.menu = QMenu()
@@ -105,12 +102,6 @@ class TrayScreen:
105
102
 
106
103
  self.menu.addSeparator()
107
104
 
108
- self.update_action = QAction('Check for Updates...', self.menu)
109
- self.update_action.triggered.connect(self._on_check_updates)
110
- self.menu.addAction(self.update_action)
111
-
112
- self.menu.addSeparator()
113
-
114
105
  self.settings_action = QAction('Settings\u2026', self.menu)
115
106
  self.settings_action.triggered.connect(self._show_settings)
116
107
  self.menu.addAction(self.settings_action)
@@ -172,16 +163,6 @@ class TrayScreen:
172
163
  logger.info('%s enabled (every %d minute(s))', label, interval_minutes)
173
164
  return timer
174
165
 
175
- def _restart_auto_update_timer(self) -> None:
176
- """Start (or restart) the periodic auto-update timer from config."""
177
- config = resolve_update_config(self._resolve_config())
178
- self._auto_update_timer = self._restart_timer(
179
- self._auto_update_timer,
180
- config.auto_update_interval_minutes,
181
- self._on_auto_check_updates,
182
- 'Automatic update checking',
183
- )
184
-
185
166
  def _restart_tool_update_timer(self) -> None:
186
167
  """Start (or restart) the periodic tool update timer from config."""
187
168
  config = resolve_update_config(self._resolve_config())
@@ -206,116 +187,10 @@ class TrayScreen:
206
187
  def _on_settings_changed(self, config: ResolvedConfig) -> None:
207
188
  """React to a change made in the settings window."""
208
189
  self._config = config
209
- self._reinitialize_updater(config)
210
-
211
- def _reinitialize_updater(self, config: ResolvedConfig) -> None:
212
- """Re-derive update settings and restart the updater and timers.
213
-
214
- The new ``Updater`` starts with the ``importlib.metadata``
215
- version which may be stale after a Velopack update. The
216
- authoritative Velopack version is recovered automatically on
217
- the first ``_get_velopack_manager()`` call (i.e. the next
218
- update check), so no special handling is required here.
219
- """
220
- update_cfg = resolve_update_config(config)
221
- self._client.initialize_updater(update_cfg)
222
- self._restart_auto_update_timer()
190
+ # Delegate updater reinit + immediate check to the controller
191
+ self._update_controller.on_settings_changed(config)
192
+ # Restart tool-update timer with new config
223
193
  self._restart_tool_update_timer()
224
- logger.info('Updater re-initialized (channel: %s, source: %s)', update_cfg.channel.name, update_cfg.repo_url)
225
-
226
- def _reset_update_action(self) -> None:
227
- """Restore the 'Check for Updates' action to its idle state."""
228
- self.update_action.setEnabled(True)
229
- self.update_action.setText('Check for Updates...')
230
-
231
- def _on_check_updates(self) -> None:
232
- """Handle manual check for updates action."""
233
- self._do_check_updates(silent=False)
234
-
235
- def _on_auto_check_updates(self) -> None:
236
- """Handle automatic (periodic) check for updates.
237
-
238
- Failures and no-update results are logged silently without
239
- showing the in-app error banner.
240
- """
241
- self._do_check_updates(silent=True)
242
-
243
- def _do_check_updates(self, *, silent: bool) -> None:
244
- """Run an update check.
245
-
246
- Args:
247
- silent: When ``True``, suppress the in-app error banner
248
- for failures and no-update results. The banner is
249
- always shown when an update *is* available.
250
- """
251
- if self._client.updater is None:
252
- if not silent:
253
- self._banner.show_error('Updater is not initialized.')
254
- return
255
-
256
- # Disable both the tray action and the settings button while checking
257
- self.update_action.setEnabled(False)
258
- self.update_action.setText('Checking for Updates...')
259
- self._settings_window.set_checking()
260
-
261
- self._update_task = asyncio.create_task(self._async_check_updates(silent=silent))
262
-
263
- async def _async_check_updates(self, *, silent: bool) -> None:
264
- """Run the update check coroutine and route results."""
265
- try:
266
- result = await check_for_update(self._client)
267
- self._on_update_check_finished(result, silent=silent)
268
- except Exception as exc:
269
- logger.exception('Update check failed')
270
- self._on_update_check_error(str(exc), silent=silent)
271
-
272
- def _on_update_check_finished(self, result: UpdateInfo | None, *, silent: bool = False) -> None:
273
- """Handle update check completion."""
274
- self._reset_update_action()
275
- self._settings_window.reset_check_updates_button()
276
-
277
- if result is None:
278
- self._settings_window.set_update_status('Check failed')
279
- if not silent:
280
- self._banner.show_error('Failed to check for updates.')
281
- else:
282
- logger.warning('Automatic update check failed (no result)')
283
- return
284
-
285
- if result.error:
286
- self._settings_window.set_update_status(result.error)
287
- if not silent:
288
- self._banner.show_error(result.error)
289
- else:
290
- logger.warning('Automatic update check failed: %s', result.error)
291
- return
292
-
293
- if not result.available:
294
- self._settings_window.set_update_status(
295
- f'Up to date ({result.current_version})',
296
- )
297
- if not silent:
298
- logger.info('No updates available (current: %s)', result.current_version)
299
- else:
300
- logger.debug('Automatic update check: no update available')
301
- return
302
-
303
- # Update available — show banner and start download automatically
304
- version = str(result.latest_version)
305
- self._settings_window.set_update_status(f'Update available: {version}')
306
- self._banner.show_downloading(version)
307
- self._start_download(version)
308
-
309
- def _on_update_check_error(self, error: str, *, silent: bool = False) -> None:
310
- """Handle update check error."""
311
- self._reset_update_action()
312
- self._settings_window.reset_check_updates_button()
313
- self._settings_window.set_update_status(f'Error: {error}')
314
-
315
- if not silent:
316
- self._banner.show_error(f'Update check error: {error}')
317
- else:
318
- logger.warning('Automatic update check error: %s', error)
319
194
 
320
195
  # -- Tool update helpers --
321
196
 
@@ -571,51 +446,3 @@ class TrayScreen:
571
446
  tools_view.refresh()
572
447
 
573
448
  self._window.show()
574
-
575
- # -- Self-update download & apply --
576
-
577
- def _start_download(self, version: str) -> None:
578
- """Start downloading the update in the background.
579
-
580
- Args:
581
- version: The version string being downloaded (for banner display).
582
- """
583
- self._update_task = asyncio.create_task(self._async_download(version))
584
-
585
- async def _async_download(self, version: str) -> None:
586
- """Run the download coroutine and route results."""
587
- try:
588
- success = await download_update(
589
- self._client,
590
- on_progress=self._banner.show_downloading_progress,
591
- )
592
- self._on_download_finished(success, version)
593
- except Exception as exc:
594
- logger.exception('Update download failed')
595
- self._on_download_error(str(exc))
596
-
597
- def _on_download_finished(self, success: bool, version: str) -> None:
598
- """Handle download completion — transition banner to ready state."""
599
- if not success:
600
- self._banner.show_error('Download failed. Please try again later.')
601
- return
602
-
603
- self._banner.show_ready(version)
604
- self._settings_window.set_update_status(f'Ready to install: {version}')
605
-
606
- def _on_download_error(self, error: str) -> None:
607
- """Handle download error — show error banner."""
608
- self._banner.show_error(f'Download error: {error}')
609
-
610
- def _apply_update(self) -> None:
611
- """Apply the downloaded update and restart."""
612
- if self._client.updater is None:
613
- return
614
-
615
- try:
616
- self._client.apply_update_on_exit(restart=True)
617
- logger.info('Update scheduled — restarting application')
618
- self._app.quit()
619
- except Exception as e:
620
- logger.error('Failed to apply update: %s', e)
621
- self._banner.show_error(f'Failed to apply update: {e}')
@@ -436,6 +436,21 @@ SETTINGS_GEAR_STYLE = (
436
436
  )
437
437
  """Gear button style for the MainWindow tab corner widget."""
438
438
 
439
+ # ---------------------------------------------------------------------------
440
+ # Settings inline update-status colours
441
+ # ---------------------------------------------------------------------------
442
+ UPDATE_STATUS_UP_TO_DATE_STYLE = 'color: #89d185; font-size: 12px;'
443
+ """Green text for 'Up to date' / 'Ready' status."""
444
+
445
+ UPDATE_STATUS_AVAILABLE_STYLE = 'color: #cca700; font-size: 12px;'
446
+ """Orange text for 'Update available' status."""
447
+
448
+ UPDATE_STATUS_ERROR_STYLE = 'color: #f48771; font-size: 12px;'
449
+ """Red text for error / check-failed status."""
450
+
451
+ UPDATE_STATUS_CHECKING_STYLE = 'color: #808080; font-size: 12px; font-style: italic;'
452
+ """Grey italic text for 'Checking…' status."""
453
+
439
454
  # ---------------------------------------------------------------------------
440
455
  # Update banner (in-app self-update notification)
441
456
  # ---------------------------------------------------------------------------
@@ -0,0 +1,312 @@
1
+ """Self-update orchestration controller.
2
+
3
+ Owns the full update lifecycle — check → download → apply — and the
4
+ periodic auto-update timer. Extracted from :class:`TrayScreen` so
5
+ that tray, settings, and banner concerns are cleanly separated from
6
+ the update state-machine.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ from typing import TYPE_CHECKING
14
+
15
+ from PySide6.QtCore import QTimer
16
+ from PySide6.QtWidgets import QApplication
17
+
18
+ from synodic_client.application.screen.update_banner import UpdateBanner
19
+ from synodic_client.application.theme import (
20
+ UPDATE_STATUS_AVAILABLE_STYLE,
21
+ UPDATE_STATUS_ERROR_STYLE,
22
+ UPDATE_STATUS_UP_TO_DATE_STYLE,
23
+ )
24
+ from synodic_client.application.workers import check_for_update, download_update
25
+ from synodic_client.resolution import (
26
+ ResolvedConfig,
27
+ resolve_config,
28
+ resolve_update_config,
29
+ )
30
+ from synodic_client.updater import UpdateInfo
31
+
32
+ if TYPE_CHECKING:
33
+ from synodic_client.application.screen.settings import SettingsWindow
34
+ from synodic_client.client import Client
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ class UpdateController:
40
+ """Manages the self-update lifecycle: check → download → apply.
41
+
42
+ Parameters
43
+ ----------
44
+ app:
45
+ The running ``QApplication`` (needed for ``quit()`` on auto-apply).
46
+ client:
47
+ The Synodic Client service facade.
48
+ banner:
49
+ The in-app ``UpdateBanner`` widget.
50
+ settings_window:
51
+ The ``SettingsWindow`` (receives status text + colour).
52
+ config:
53
+ Optional pre-resolved configuration. ``None`` resolves from disk.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ app: QApplication,
59
+ client: Client,
60
+ banner: UpdateBanner,
61
+ settings_window: SettingsWindow,
62
+ config: ResolvedConfig | None = None,
63
+ ) -> None:
64
+ """Initialise the controller and start the periodic timer.
65
+
66
+ Args:
67
+ app: The running ``QApplication``.
68
+ client: The Synodic Client service facade.
69
+ banner: The in-app ``UpdateBanner`` widget.
70
+ settings_window: The settings window for status feedback.
71
+ config: Optional pre-resolved configuration.
72
+ """
73
+ self._app = app
74
+ self._client = client
75
+ self._banner = banner
76
+ self._settings_window = settings_window
77
+ self._config = config
78
+ self._update_task: asyncio.Task[None] | None = None
79
+
80
+ # Derive auto-apply preference from config
81
+ resolved = self._resolve_config()
82
+ self._auto_apply: bool = resolved.auto_apply
83
+
84
+ # Periodic auto-update timer
85
+ self._auto_update_timer: QTimer | None = None
86
+ self._restart_auto_update_timer()
87
+
88
+ # Wire banner signals
89
+ self._banner.restart_requested.connect(self._apply_update)
90
+ self._banner.retry_requested.connect(lambda: self.check_now(silent=True))
91
+
92
+ # Wire settings check-updates button
93
+ self._settings_window.check_updates_requested.connect(self._on_manual_check)
94
+
95
+ # ------------------------------------------------------------------
96
+ # Config helpers
97
+ # ------------------------------------------------------------------
98
+
99
+ def _resolve_config(self) -> ResolvedConfig:
100
+ """Return the injected config or resolve from disk."""
101
+ if self._config is not None:
102
+ return self._config
103
+ return resolve_config()
104
+
105
+ # ------------------------------------------------------------------
106
+ # Timer management
107
+ # ------------------------------------------------------------------
108
+
109
+ def _restart_auto_update_timer(self) -> None:
110
+ """Start (or restart) the periodic auto-update timer from config."""
111
+ config = resolve_update_config(self._resolve_config())
112
+
113
+ if self._auto_update_timer is not None:
114
+ self._auto_update_timer.stop()
115
+
116
+ interval = config.auto_update_interval_minutes
117
+ if interval <= 0:
118
+ logger.info('Automatic update checking is disabled')
119
+ self._auto_update_timer = None
120
+ return
121
+
122
+ timer = QTimer()
123
+ timer.setInterval(interval * 60 * 1000)
124
+ timer.timeout.connect(self._on_auto_check)
125
+ timer.start()
126
+ logger.info('Automatic update checking enabled (every %d minute(s))', interval)
127
+ self._auto_update_timer = timer
128
+
129
+ # ------------------------------------------------------------------
130
+ # Public API
131
+ # ------------------------------------------------------------------
132
+
133
+ def check_now(self, *, silent: bool = False) -> None:
134
+ """Trigger an update check.
135
+
136
+ Args:
137
+ silent: When ``True``, suppress the in-app error banner
138
+ for failures and no-update results.
139
+ """
140
+ self._do_check(silent=silent)
141
+
142
+ def on_settings_changed(self, config: ResolvedConfig) -> None:
143
+ """React to a settings change — reinitialise the updater and timers.
144
+
145
+ Also triggers an immediate (silent) check so the user gets
146
+ feedback after switching channels.
147
+ """
148
+ self._config = config
149
+ self._auto_apply = config.auto_apply
150
+ self._reinitialize_updater(config)
151
+ self.check_now(silent=True)
152
+
153
+ # ------------------------------------------------------------------
154
+ # Updater re-initialisation
155
+ # ------------------------------------------------------------------
156
+
157
+ def _reinitialize_updater(self, config: ResolvedConfig) -> None:
158
+ """Re-derive update settings and restart the updater and timer."""
159
+ update_cfg = resolve_update_config(config)
160
+ self._client.initialize_updater(update_cfg)
161
+ self._restart_auto_update_timer()
162
+ logger.info(
163
+ 'Updater re-initialized (channel: %s, source: %s)',
164
+ update_cfg.channel.name,
165
+ update_cfg.repo_url,
166
+ )
167
+
168
+ # ------------------------------------------------------------------
169
+ # Check flow
170
+ # ------------------------------------------------------------------
171
+
172
+ def _on_manual_check(self) -> None:
173
+ """Handle manual check-for-updates (from settings button)."""
174
+ self._do_check(silent=False)
175
+
176
+ def _on_auto_check(self) -> None:
177
+ """Handle automatic (periodic) check — silent."""
178
+ self._do_check(silent=True)
179
+
180
+ def _do_check(self, *, silent: bool) -> None:
181
+ """Run an update check."""
182
+ if self._client.updater is None:
183
+ if not silent:
184
+ self._banner.show_error('Updater is not initialized.')
185
+ return
186
+
187
+ # Show checking state in settings
188
+ self._settings_window.set_checking()
189
+
190
+ self._update_task = asyncio.create_task(self._async_check(silent=silent))
191
+
192
+ async def _async_check(self, *, silent: bool) -> None:
193
+ """Run the update check coroutine and route results."""
194
+ try:
195
+ result = await check_for_update(self._client)
196
+ self._on_check_finished(result, silent=silent)
197
+ except Exception as exc:
198
+ logger.exception('Update check failed')
199
+ self._on_check_error(str(exc), silent=silent)
200
+
201
+ def _on_check_finished(self, result: UpdateInfo | None, *, silent: bool = False) -> None:
202
+ """Route the update-check result."""
203
+ self._settings_window.reset_check_updates_button()
204
+
205
+ if result is None:
206
+ self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE)
207
+ if not silent:
208
+ self._banner.show_error('Failed to check for updates.')
209
+ else:
210
+ logger.warning('Automatic update check failed (no result)')
211
+ return
212
+
213
+ if result.error:
214
+ self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE)
215
+ if not silent:
216
+ self._banner.show_error(result.error)
217
+ else:
218
+ logger.warning('Automatic update check failed: %s', result.error)
219
+ return
220
+
221
+ if not result.available:
222
+ self._settings_window.set_update_status('Up to date', UPDATE_STATUS_UP_TO_DATE_STYLE)
223
+ if not silent:
224
+ logger.info('No updates available (current: %s)', result.current_version)
225
+ else:
226
+ logger.debug('Automatic update check: no update available')
227
+ return
228
+
229
+ # Update available — show status and start download
230
+ version = str(result.latest_version)
231
+ self._settings_window.set_update_status(
232
+ f'v{version} available',
233
+ UPDATE_STATUS_AVAILABLE_STYLE,
234
+ )
235
+ self._banner.show_downloading(version)
236
+ self._start_download(version)
237
+
238
+ def _on_check_error(self, error: str, *, silent: bool = False) -> None:
239
+ """Handle unexpected exception during update check."""
240
+ self._settings_window.reset_check_updates_button()
241
+ self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE)
242
+
243
+ if not silent:
244
+ self._banner.show_error(f'Update check error: {error}')
245
+ else:
246
+ logger.warning('Automatic update check error: %s', error)
247
+
248
+ # ------------------------------------------------------------------
249
+ # Download flow
250
+ # ------------------------------------------------------------------
251
+
252
+ def _start_download(self, version: str) -> None:
253
+ """Start downloading the update in the background."""
254
+ self._update_task = asyncio.create_task(self._async_download(version))
255
+
256
+ async def _async_download(self, version: str) -> None:
257
+ """Run the download coroutine and route results."""
258
+ try:
259
+ success = await download_update(
260
+ self._client,
261
+ on_progress=self._banner.show_downloading_progress,
262
+ )
263
+ self._on_download_finished(success, version)
264
+ except Exception as exc:
265
+ logger.exception('Update download failed')
266
+ self._on_download_error(str(exc))
267
+
268
+ def _on_download_finished(self, success: bool, version: str) -> None:
269
+ """Handle download completion."""
270
+ if not success:
271
+ self._banner.show_error('Download failed. Please try again later.')
272
+ self._settings_window.set_update_status('Download failed', UPDATE_STATUS_ERROR_STYLE)
273
+ return
274
+
275
+ if self._auto_apply:
276
+ # Silently apply and restart — no banner, no user interaction
277
+ logger.info('Auto-applying update v%s', version)
278
+ self._settings_window.set_update_status(
279
+ f'v{version} installing\u2026',
280
+ UPDATE_STATUS_AVAILABLE_STYLE,
281
+ )
282
+ self._apply_update()
283
+ return
284
+
285
+ # Manual mode — show ready banner and let user choose when to restart
286
+ self._banner.show_ready(version)
287
+ self._settings_window.set_update_status(
288
+ f'v{version} ready',
289
+ UPDATE_STATUS_UP_TO_DATE_STYLE,
290
+ )
291
+
292
+ def _on_download_error(self, error: str) -> None:
293
+ """Handle download error — show error banner."""
294
+ self._banner.show_error(f'Download error: {error}')
295
+ self._settings_window.set_update_status('Download failed', UPDATE_STATUS_ERROR_STYLE)
296
+
297
+ # ------------------------------------------------------------------
298
+ # Apply
299
+ # ------------------------------------------------------------------
300
+
301
+ def _apply_update(self) -> None:
302
+ """Apply the downloaded update and restart."""
303
+ if self._client.updater is None:
304
+ return
305
+
306
+ try:
307
+ self._client.apply_update_on_exit(restart=True)
308
+ logger.info('Update scheduled — restarting application')
309
+ self._app.quit()
310
+ except Exception as e:
311
+ logger.error('Failed to apply update: %s', e)
312
+ self._banner.show_error(f'Failed to apply update: {e}')
@@ -130,6 +130,10 @@ class UserConfig(BaseModel):
130
130
  # no overrides anywhere.
131
131
  prerelease_packages: dict[str, list[str]] | None = None
132
132
 
133
+ # Whether downloaded updates should be applied and restarted
134
+ # automatically without user interaction. None resolves to True.
135
+ auto_apply: bool | None = None
136
+
133
137
  # Whether the application should start automatically with the OS.
134
138
  # None means use the default (enabled). Explicitly False disables
135
139
  # auto-startup.
@@ -61,6 +61,7 @@ class ResolvedConfig:
61
61
  plugin_auto_update: dict[str, bool | dict[str, bool]] | None
62
62
  detect_updates: bool
63
63
  prerelease_packages: dict[str, list[str]] | None
64
+ auto_apply: bool
64
65
  auto_start: bool
65
66
 
66
67
 
@@ -140,6 +141,7 @@ def _resolve_from_user(user: UserConfig) -> ResolvedConfig:
140
141
  if tool_interval is None:
141
142
  tool_interval = DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES
142
143
 
144
+ auto_apply = user.auto_apply if user.auto_apply is not None else True
143
145
  auto_start = user.auto_start if user.auto_start is not None else True
144
146
 
145
147
  return ResolvedConfig(
@@ -150,6 +152,7 @@ def _resolve_from_user(user: UserConfig) -> ResolvedConfig:
150
152
  plugin_auto_update=user.plugin_auto_update,
151
153
  detect_updates=user.detect_updates,
152
154
  prerelease_packages=user.prerelease_packages,
155
+ auto_apply=auto_apply,
153
156
  auto_start=auto_start,
154
157
  )
155
158
 
@@ -41,6 +41,7 @@ def _make_config() -> ResolvedConfig:
41
41
  plugin_auto_update=None,
42
42
  detect_updates=False,
43
43
  prerelease_packages=None,
44
+ auto_apply=True,
44
45
  auto_start=False,
45
46
  )
46
47
 
@@ -25,6 +25,7 @@ def _make_config(**overrides: Any) -> ResolvedConfig:
25
25
  'plugin_auto_update': None,
26
26
  'detect_updates': True,
27
27
  'prerelease_packages': None,
28
+ 'auto_apply': True,
28
29
  'auto_start': True,
29
30
  }
30
31
  defaults.update(overrides)
@@ -350,10 +351,11 @@ class TestCheckForUpdatesButton:
350
351
 
351
352
  @staticmethod
352
353
  def test_set_update_status() -> None:
353
- """set_update_status sets the label text."""
354
+ """set_update_status sets the label text and style."""
354
355
  window = _make_window()
355
- window.set_update_status('Up to date (v1.0.0)')
356
- assert window._update_status_label.text() == 'Up to date (v1.0.0)'
356
+ window.set_update_status('Up to date', 'color: green;')
357
+ assert window._update_status_label.text() == 'Up to date'
358
+ assert 'green' in window._update_status_label.styleSheet()
357
359
 
358
360
  @staticmethod
359
361
  def test_reset_check_updates_button() -> None:
@@ -16,6 +16,7 @@ def tray_screen():
16
16
  with (
17
17
  patch('synodic_client.application.screen.tray.resolve_config'),
18
18
  patch('synodic_client.application.screen.tray.resolve_update_config') as mock_ucfg,
19
+ patch('synodic_client.application.screen.tray.UpdateController'),
19
20
  ):
20
21
  # Disable timers by setting intervals to 0
21
22
  mock_ucfg.return_value = MagicMock(
@@ -0,0 +1,298 @@
1
+ """Tests for the UpdateController self-update orchestrator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from unittest.mock import MagicMock, patch
7
+
8
+ from packaging.version import Version
9
+
10
+ from synodic_client.application.screen.update_banner import UpdateBanner
11
+ from synodic_client.application.theme import (
12
+ UPDATE_STATUS_AVAILABLE_STYLE,
13
+ UPDATE_STATUS_ERROR_STYLE,
14
+ UPDATE_STATUS_UP_TO_DATE_STYLE,
15
+ )
16
+ from synodic_client.application.update_controller import UpdateController
17
+ from synodic_client.resolution import ResolvedConfig
18
+ from synodic_client.updater import (
19
+ DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES,
20
+ DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES,
21
+ UpdateInfo,
22
+ )
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Helpers
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ def _make_config(**overrides: Any) -> ResolvedConfig:
30
+ """Create a ``ResolvedConfig`` with sensible defaults and optional overrides."""
31
+ defaults: dict[str, Any] = {
32
+ 'update_source': None,
33
+ 'update_channel': 'stable',
34
+ 'auto_update_interval_minutes': DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES,
35
+ 'tool_update_interval_minutes': DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES,
36
+ 'plugin_auto_update': None,
37
+ 'detect_updates': True,
38
+ 'prerelease_packages': None,
39
+ 'auto_apply': True,
40
+ 'auto_start': True,
41
+ }
42
+ defaults.update(overrides)
43
+ return ResolvedConfig(**defaults)
44
+
45
+
46
+ def _make_controller(
47
+ *,
48
+ auto_apply: bool = True,
49
+ auto_update_interval_minutes: int = 0,
50
+ ) -> tuple[UpdateController, MagicMock, MagicMock, UpdateBanner, MagicMock]:
51
+ """Build an ``UpdateController`` with mocked collaborators.
52
+
53
+ Returns (controller, app_mock, client_mock, banner, settings_mock).
54
+ """
55
+ config = _make_config(
56
+ auto_apply=auto_apply,
57
+ auto_update_interval_minutes=auto_update_interval_minutes,
58
+ )
59
+
60
+ app = MagicMock()
61
+ client = MagicMock()
62
+ client.updater = MagicMock()
63
+ banner = UpdateBanner()
64
+ settings = MagicMock()
65
+
66
+ with patch('synodic_client.application.update_controller.resolve_update_config') as mock_ucfg:
67
+ mock_ucfg.return_value = MagicMock(
68
+ auto_update_interval_minutes=auto_update_interval_minutes,
69
+ )
70
+ controller = UpdateController(app, client, banner, settings, config)
71
+
72
+ return controller, app, client, banner, settings
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Check result routing
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ class TestCheckFinished:
81
+ """Verify _on_check_finished routes results correctly."""
82
+
83
+ @staticmethod
84
+ def test_none_result_sets_error_status() -> None:
85
+ """A None result should set 'Check failed' in red."""
86
+ ctrl, _app, _client, banner, settings = _make_controller()
87
+ ctrl._on_check_finished(None, silent=False)
88
+
89
+ settings.reset_check_updates_button.assert_called_once()
90
+ settings.set_update_status.assert_called_once_with('Check failed', UPDATE_STATUS_ERROR_STYLE)
91
+
92
+ @staticmethod
93
+ def test_none_result_shows_banner_when_not_silent() -> None:
94
+ """A None result with silent=False should show the error banner."""
95
+ ctrl, _app, _client, banner, settings = _make_controller()
96
+ ctrl._on_check_finished(None, silent=False)
97
+
98
+ assert banner.state.name == 'ERROR'
99
+
100
+ @staticmethod
101
+ def test_none_result_no_banner_when_silent() -> None:
102
+ """A None result with silent=True should NOT show the error banner."""
103
+ ctrl, _app, _client, banner, settings = _make_controller()
104
+ ctrl._on_check_finished(None, silent=True)
105
+
106
+ assert banner.state.name == 'HIDDEN'
107
+
108
+ @staticmethod
109
+ def test_error_result_sets_error_status() -> None:
110
+ """An error result should set 'Check failed' status."""
111
+ ctrl, _app, _client, banner, settings = _make_controller()
112
+ result = UpdateInfo(available=False, current_version=Version('1.0.0'), error='No releases found')
113
+ ctrl._on_check_finished(result, silent=False)
114
+
115
+ settings.set_update_status.assert_called_once_with('Check failed', UPDATE_STATUS_ERROR_STYLE)
116
+
117
+ @staticmethod
118
+ def test_no_update_sets_up_to_date() -> None:
119
+ """No update available should set 'Up to date' in green."""
120
+ ctrl, _app, _client, banner, settings = _make_controller()
121
+ result = UpdateInfo(available=False, current_version=Version('1.0.0'))
122
+ ctrl._on_check_finished(result, silent=False)
123
+
124
+ settings.set_update_status.assert_called_once_with('Up to date', UPDATE_STATUS_UP_TO_DATE_STYLE)
125
+
126
+ @staticmethod
127
+ def test_update_available_sets_status_and_starts_download() -> None:
128
+ """Available update should set orange status and start download."""
129
+ ctrl, _app, _client, banner, settings = _make_controller()
130
+ result = UpdateInfo(available=True, current_version=Version('1.0.0'), latest_version=Version('2.0.0'))
131
+
132
+ with patch.object(ctrl, '_start_download') as mock_dl:
133
+ ctrl._on_check_finished(result, silent=False)
134
+
135
+ settings.set_update_status.assert_called_once_with(
136
+ 'v2.0.0 available',
137
+ UPDATE_STATUS_AVAILABLE_STYLE,
138
+ )
139
+ mock_dl.assert_called_once_with('2.0.0')
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Download completion — auto-apply vs manual
144
+ # ---------------------------------------------------------------------------
145
+
146
+
147
+ class TestDownloadFinished:
148
+ """Verify _on_download_finished behaviour with auto-apply on/off."""
149
+
150
+ @staticmethod
151
+ def test_auto_apply_calls_apply_update() -> None:
152
+ """When auto_apply=True, a successful download should call _apply_update."""
153
+ ctrl, app, client, banner, settings = _make_controller(auto_apply=True)
154
+
155
+ with patch.object(ctrl, '_apply_update') as mock_apply:
156
+ ctrl._on_download_finished(True, '2.0.0')
157
+
158
+ mock_apply.assert_called_once()
159
+
160
+ @staticmethod
161
+ def test_auto_apply_does_not_show_ready_banner() -> None:
162
+ """When auto_apply=True, the ready banner should NOT be shown."""
163
+ ctrl, app, client, banner, settings = _make_controller(auto_apply=True)
164
+
165
+ with patch.object(ctrl, '_apply_update'):
166
+ ctrl._on_download_finished(True, '2.0.0')
167
+
168
+ # Banner should not be in READY state
169
+ assert banner.state.name != 'READY'
170
+
171
+ @staticmethod
172
+ def test_no_auto_apply_shows_ready_banner() -> None:
173
+ """When auto_apply=False, a successful download should show the ready banner."""
174
+ ctrl, app, client, banner, settings = _make_controller(auto_apply=False)
175
+ ctrl._on_download_finished(True, '2.0.0')
176
+
177
+ assert banner.state.name == 'READY'
178
+
179
+ @staticmethod
180
+ def test_no_auto_apply_sets_ready_status() -> None:
181
+ """When auto_apply=False, status should show 'v2.0.0 ready' in green."""
182
+ ctrl, app, client, banner, settings = _make_controller(auto_apply=False)
183
+ ctrl._on_download_finished(True, '2.0.0')
184
+
185
+ settings.set_update_status.assert_called_with(
186
+ 'v2.0.0 ready',
187
+ UPDATE_STATUS_UP_TO_DATE_STYLE,
188
+ )
189
+
190
+ @staticmethod
191
+ def test_download_failure_shows_error() -> None:
192
+ """A failed download should show an error banner."""
193
+ ctrl, app, client, banner, settings = _make_controller()
194
+ ctrl._on_download_finished(False, '2.0.0')
195
+
196
+ assert banner.state.name == 'ERROR'
197
+ settings.set_update_status.assert_called_with('Download failed', UPDATE_STATUS_ERROR_STYLE)
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # Apply update
202
+ # ---------------------------------------------------------------------------
203
+
204
+
205
+ class TestApplyUpdate:
206
+ """Verify _apply_update delegates to client and quits."""
207
+
208
+ @staticmethod
209
+ def test_apply_update_calls_client_and_quits() -> None:
210
+ """_apply_update should call client.apply_update_on_exit and app.quit."""
211
+ ctrl, app, client, banner, settings = _make_controller()
212
+ ctrl._apply_update()
213
+
214
+ client.apply_update_on_exit.assert_called_once_with(restart=True)
215
+ app.quit.assert_called_once()
216
+
217
+ @staticmethod
218
+ def test_apply_update_noop_without_updater() -> None:
219
+ """_apply_update should be a no-op when client.updater is None."""
220
+ ctrl, app, client, banner, settings = _make_controller()
221
+ client.updater = None
222
+ ctrl._apply_update()
223
+
224
+ client.apply_update_on_exit.assert_not_called()
225
+ app.quit.assert_not_called()
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Settings changed → immediate check
230
+ # ---------------------------------------------------------------------------
231
+
232
+
233
+ class TestSettingsChanged:
234
+ """Verify on_settings_changed triggers reinit and immediate check."""
235
+
236
+ @staticmethod
237
+ def test_settings_changed_triggers_reinit_and_check() -> None:
238
+ """Changing settings should reinitialise the updater and check."""
239
+ ctrl, app, client, banner, settings = _make_controller()
240
+
241
+ new_config = _make_config(update_channel='dev')
242
+
243
+ with (
244
+ patch.object(ctrl, '_reinitialize_updater') as mock_reinit,
245
+ patch.object(ctrl, 'check_now') as mock_check,
246
+ ):
247
+ ctrl.on_settings_changed(new_config)
248
+
249
+ mock_reinit.assert_called_once_with(new_config)
250
+ mock_check.assert_called_once_with(silent=True)
251
+
252
+ @staticmethod
253
+ def test_settings_changed_updates_auto_apply() -> None:
254
+ """Changing settings should update the auto_apply flag."""
255
+ ctrl, app, client, banner, settings = _make_controller(auto_apply=True)
256
+
257
+ new_config = _make_config(auto_apply=False)
258
+
259
+ with (
260
+ patch.object(ctrl, '_reinitialize_updater'),
261
+ patch.object(ctrl, 'check_now'),
262
+ ):
263
+ ctrl.on_settings_changed(new_config)
264
+
265
+ assert ctrl._auto_apply is False
266
+
267
+
268
+ # ---------------------------------------------------------------------------
269
+ # Check error
270
+ # ---------------------------------------------------------------------------
271
+
272
+
273
+ class TestCheckError:
274
+ """Verify _on_check_error routes errors correctly."""
275
+
276
+ @staticmethod
277
+ def test_check_error_sets_failed_status() -> None:
278
+ """An exception during check should set 'Check failed' status."""
279
+ ctrl, app, client, banner, settings = _make_controller()
280
+ ctrl._on_check_error('connection refused', silent=False)
281
+
282
+ settings.set_update_status.assert_called_with('Check failed', UPDATE_STATUS_ERROR_STYLE)
283
+
284
+ @staticmethod
285
+ def test_check_error_shows_banner_when_not_silent() -> None:
286
+ """An exception during check should show banner when not silent."""
287
+ ctrl, app, client, banner, settings = _make_controller()
288
+ ctrl._on_check_error('timeout', silent=False)
289
+
290
+ assert banner.state.name == 'ERROR'
291
+
292
+ @staticmethod
293
+ def test_check_error_no_banner_when_silent() -> None:
294
+ """An exception during check should NOT show banner when silent."""
295
+ ctrl, app, client, banner, settings = _make_controller()
296
+ ctrl._on_check_error('timeout', silent=True)
297
+
298
+ assert banner.state.name == 'HIDDEN'
@@ -47,6 +47,7 @@ class TestUserConfig:
47
47
  assert config.plugin_auto_update is None
48
48
  assert config.detect_updates is True
49
49
  assert config.prerelease_packages is None
50
+ assert config.auto_apply is None
50
51
  assert config.auto_start is None
51
52
 
52
53
  @staticmethod
@@ -39,6 +39,7 @@ def _make_resolved(**overrides: Any) -> ResolvedConfig:
39
39
  'plugin_auto_update': None,
40
40
  'detect_updates': True,
41
41
  'prerelease_packages': None,
42
+ 'auto_apply': True,
42
43
  'auto_start': True,
43
44
  }
44
45
  defaults.update(overrides)
@@ -1 +0,0 @@
1
- __version__ = '0.0.1.dev41'