synodic-client 0.0.1.dev41__tar.gz → 0.0.1.dev44__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 (73) hide show
  1. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/pyproject.toml +1 -1
  3. synodic_client-0.0.1.dev44/synodic_client/_version.py +1 -0
  4. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/application/bootstrap.py +3 -13
  5. synodic_client-0.0.1.dev44/synodic_client/application/init.py +61 -0
  6. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/application/qt.py +4 -16
  7. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/settings.py +26 -3
  8. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/tray.py +13 -186
  9. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/application/theme.py +15 -0
  10. synodic_client-0.0.1.dev44/synodic_client/application/update_controller.py +312 -0
  11. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/config.py +4 -0
  12. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/resolution.py +3 -0
  13. synodic_client-0.0.1.dev44/synodic_client/startup.py +156 -0
  14. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/updater.py +11 -3
  15. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_gather_packages.py +1 -0
  16. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_settings.py +5 -3
  17. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_tray_window_show.py +1 -0
  18. synodic_client-0.0.1.dev44/tests/unit/qt/test_update_controller.py +298 -0
  19. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/test_config.py +1 -0
  20. synodic_client-0.0.1.dev44/tests/unit/test_init.py +103 -0
  21. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/test_resolution.py +1 -0
  22. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/test_updater.py +16 -0
  23. synodic_client-0.0.1.dev44/tests/unit/windows/test_startup.py +240 -0
  24. synodic_client-0.0.1.dev41/synodic_client/_version.py +0 -1
  25. synodic_client-0.0.1.dev41/synodic_client/startup.py +0 -91
  26. synodic_client-0.0.1.dev41/tests/unit/windows/test_startup.py +0 -121
  27. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/LICENSE.md +0 -0
  28. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/README.md +0 -0
  29. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/__init__.py +0 -0
  30. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/__main__.py +0 -0
  31. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/application/__init__.py +0 -0
  32. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/application/data.py +0 -0
  33. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/application/icon.py +0 -0
  34. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/application/instance.py +0 -0
  35. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/__init__.py +0 -0
  36. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/action_card.py +0 -0
  37. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/card.py +0 -0
  38. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/install.py +0 -0
  39. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/log_panel.py +0 -0
  40. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/screen.py +0 -0
  41. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/sidebar.py +0 -0
  42. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/spinner.py +0 -0
  43. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/update_banner.py +0 -0
  44. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/application/uri.py +0 -0
  45. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/application/workers.py +0 -0
  46. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/cli.py +0 -0
  47. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/client.py +0 -0
  48. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/logging.py +0 -0
  49. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/protocol.py +0 -0
  50. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/synodic_client/py.typed +0 -0
  51. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/__init__.py +0 -0
  52. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/conftest.py +0 -0
  53. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/__init__.py +0 -0
  54. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/qt/__init__.py +0 -0
  55. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/qt/conftest.py +0 -0
  56. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_action_card.py +0 -0
  57. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_install_preview.py +0 -0
  58. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_log_panel.py +0 -0
  59. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_logging.py +0 -0
  60. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_preview_model.py +0 -0
  61. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_sidebar.py +0 -0
  62. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_update_banner.py +0 -0
  63. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_update_feedback.py +0 -0
  64. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/test_cli.py +0 -0
  65. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/test_client_updater.py +0 -0
  66. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/test_client_version.py +0 -0
  67. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/test_examples.py +0 -0
  68. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/test_install.py +0 -0
  69. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/test_uri.py +0 -0
  70. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/test_workers.py +0 -0
  71. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/windows/__init__.py +0 -0
  72. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/windows/conftest.py +0 -0
  73. {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev44}/tests/unit/windows/test_protocol.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.dev44
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.dev44"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -0,0 +1 @@
1
+ __version__ = '0.0.1.dev44'
@@ -9,7 +9,7 @@ Import order matters:
9
9
  1. stdlib + config (pure-Python, fast)
10
10
  2. configure_logging() — now Qt-free
11
11
  3. initialize_velopack() — hooks run with logging active
12
- 4. register_protocol() — stdlib only
12
+ 4. run_startup_preamble() — protocol, config seed, auto-startup
13
13
  5. import qt.application — PySide6 / porringer loaded here
14
14
  """
15
15
 
@@ -17,9 +17,6 @@ import sys
17
17
 
18
18
  from synodic_client.config import set_dev_mode
19
19
  from synodic_client.logging import configure_logging
20
- from synodic_client.protocol import register_protocol
21
- from synodic_client.resolution import resolve_config, seed_user_config_from_build
22
- from synodic_client.startup import register_startup, remove_startup
23
20
  from synodic_client.updater import initialize_velopack
24
21
 
25
22
  _PROTOCOL_SCHEME = 'synodic'
@@ -32,16 +29,9 @@ configure_logging()
32
29
  initialize_velopack()
33
30
 
34
31
  if not _dev_mode:
35
- # Seed user config from the build config (one-time propagation).
36
- seed_user_config_from_build()
32
+ from synodic_client.application.init import run_startup_preamble
37
33
 
38
- register_protocol(sys.executable)
39
-
40
- _config = resolve_config()
41
- if _config.auto_start:
42
- register_startup(sys.executable)
43
- else:
44
- remove_startup()
34
+ run_startup_preamble(sys.executable)
45
35
 
46
36
  # Heavy imports happen here — PySide6, porringer, etc.
47
37
  from synodic_client.application.qt import application
@@ -0,0 +1,61 @@
1
+ """Shared startup preamble for frozen and CLI entry points.
2
+
3
+ Encapsulates the one-time initialisation that both
4
+ :mod:`synodic_client.application.bootstrap` (PyInstaller) and
5
+ :mod:`synodic_client.application.qt` (CLI / dev-script) need to
6
+ perform before the GUI event loop starts:
7
+
8
+ 1. Seed user config from the build config (one-time propagation).
9
+ 2. Register the ``synodic://`` URI protocol handler.
10
+ 3. Synchronise the Windows auto-startup registry entry with the
11
+ persisted ``auto_start`` preference.
12
+
13
+ Heavy dependencies (PySide6, porringer) are **not** imported here so
14
+ that the bootstrap path can call this before loading them.
15
+ """
16
+
17
+ import logging
18
+ import sys
19
+
20
+ from synodic_client.protocol import register_protocol
21
+ from synodic_client.resolution import resolve_config, seed_user_config_from_build
22
+ from synodic_client.startup import register_startup, remove_startup
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _preamble_done = False
27
+
28
+
29
+ def run_startup_preamble(exe_path: str | None = None) -> None:
30
+ """Run the shared startup preamble for non-dev-mode launches.
31
+
32
+ Both the frozen entry point
33
+ (:mod:`~synodic_client.application.bootstrap`) and the CLI entry
34
+ point (:func:`~synodic_client.application.qt.application`) call
35
+ this unconditionally. An internal guard ensures the work only
36
+ executes once per process.
37
+
38
+ Args:
39
+ exe_path: Absolute path to the application executable. Defaults
40
+ to ``sys.executable`` when not supplied.
41
+ """
42
+ global _preamble_done # noqa: PLW0603
43
+ if _preamble_done:
44
+ return
45
+ _preamble_done = True
46
+
47
+ if exe_path is None:
48
+ exe_path = sys.executable
49
+
50
+ # Seed user config from the build config (one-time propagation).
51
+ seed_user_config_from_build()
52
+
53
+ register_protocol(exe_path)
54
+
55
+ config = resolve_config()
56
+ if config.auto_start:
57
+ register_startup(exe_path)
58
+ else:
59
+ remove_startup()
60
+
61
+ logger.info('Startup preamble complete (auto_start=%s)', config.auto_start)
@@ -15,6 +15,7 @@ from PySide6.QtCore import Qt, QTimer
15
15
  from PySide6.QtWidgets import QApplication
16
16
 
17
17
  from synodic_client.application.icon import app_icon
18
+ from synodic_client.application.init import run_startup_preamble
18
19
  from synodic_client.application.instance import SingleInstance
19
20
  from synodic_client.application.screen.install import InstallPreviewWindow
20
21
  from synodic_client.application.screen.screen import Screen
@@ -23,15 +24,12 @@ from synodic_client.application.uri import parse_uri
23
24
  from synodic_client.client import Client
24
25
  from synodic_client.config import set_dev_mode
25
26
  from synodic_client.logging import configure_logging
26
- from synodic_client.protocol import register_protocol
27
27
  from synodic_client.resolution import (
28
28
  ResolvedConfig,
29
29
  resolve_config,
30
30
  resolve_update_config,
31
31
  resolve_version,
32
- seed_user_config_from_build,
33
32
  )
34
- from synodic_client.startup import register_startup, remove_startup
35
33
  from synodic_client.updater import initialize_velopack
36
34
 
37
35
 
@@ -139,20 +137,10 @@ def application(*, uri: str | None = None, dev_mode: bool = False) -> None:
139
137
  _install_exception_hook(logger)
140
138
 
141
139
  if not dev_mode:
142
- # Initialize Velopack early, before any UI.
143
- # Console window suppression for subprocesses is handled by the
144
- # PyInstaller runtime hook (rthook_no_console.py).
140
+ # All three functions are idempotent — safe to call even when
141
+ # bootstrap.py has already executed them before heavy imports.
145
142
  initialize_velopack()
146
- register_protocol(sys.executable)
147
-
148
- # Seed user config from build config (one-time propagation).
149
- seed_user_config_from_build()
150
-
151
- startup_config = resolve_config()
152
- if startup_config.auto_start:
153
- register_startup(sys.executable)
154
- else:
155
- remove_startup()
143
+ run_startup_preamble(sys.executable)
156
144
 
157
145
  if uri:
158
146
  logger.info('Received URI: %s', uri)
@@ -27,9 +27,10 @@ from PySide6.QtWidgets import (
27
27
  QWidget,
28
28
  )
29
29
 
30
+ from synodic_client._version import __version__
30
31
  from synodic_client.application.icon import app_icon
31
32
  from synodic_client.application.screen.card import CardFrame
32
- from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE
33
+ from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE, UPDATE_STATUS_CHECKING_STYLE
33
34
  from synodic_client.logging import log_path
34
35
  from synodic_client.resolution import ResolvedConfig, update_user_config
35
36
  from synodic_client.startup import is_startup_registered, register_startup, remove_startup
@@ -92,6 +93,11 @@ class SettingsWindow(QMainWindow):
92
93
  layout.addWidget(self._build_advanced_section())
93
94
  layout.addStretch()
94
95
 
96
+ version_label = QLabel(f'Version {__version__}')
97
+ version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
98
+ version_label.setStyleSheet('color: rgba(255, 255, 255, 0.4); font-size: 11px;')
99
+ layout.addWidget(version_label)
100
+
95
101
  scroll.setWidget(container)
96
102
  self.setCentralWidget(scroll)
97
103
 
@@ -163,6 +169,11 @@ class SettingsWindow(QMainWindow):
163
169
  self._detect_updates_check.toggled.connect(self._on_detect_updates_changed)
164
170
  content.addWidget(self._detect_updates_check)
165
171
 
172
+ # Automatically apply updates
173
+ self._auto_apply_check = QCheckBox('Automatically apply updates')
174
+ self._auto_apply_check.toggled.connect(self._on_auto_apply_changed)
175
+ content.addWidget(self._auto_apply_check)
176
+
166
177
  # Check for Updates
167
178
  row = QHBoxLayout()
168
179
  self._check_updates_btn = QPushButton('Check for Updates\u2026')
@@ -218,16 +229,24 @@ class SettingsWindow(QMainWindow):
218
229
 
219
230
  # Checkboxes
220
231
  self._detect_updates_check.setChecked(config.detect_updates)
232
+ self._auto_apply_check.setChecked(config.auto_apply)
221
233
  self._auto_start_check.setChecked(is_startup_registered())
222
234
 
223
- def set_update_status(self, text: str) -> None:
224
- """Set the inline status text next to the *Check for Updates* button."""
235
+ def set_update_status(self, text: str, style: str = '') -> None:
236
+ """Set the inline status text next to the *Check for Updates* button.
237
+
238
+ Args:
239
+ text: The status message.
240
+ style: Optional stylesheet for the label (e.g. color).
241
+ """
225
242
  self._update_status_label.setText(text)
243
+ self._update_status_label.setStyleSheet(style)
226
244
 
227
245
  def set_checking(self) -> None:
228
246
  """Enter the *checking* state — disable button and show status."""
229
247
  self._check_updates_btn.setEnabled(False)
230
248
  self._update_status_label.setText('Checking\u2026')
249
+ self._update_status_label.setStyleSheet(UPDATE_STATUS_CHECKING_STYLE)
231
250
 
232
251
  def reset_check_updates_button(self) -> None:
233
252
  """Re-enable the *Check for Updates* button after a check completes."""
@@ -262,6 +281,7 @@ class SettingsWindow(QMainWindow):
262
281
  self._auto_update_spin,
263
282
  self._tool_update_spin,
264
283
  self._detect_updates_check,
284
+ self._auto_apply_check,
265
285
  self._auto_start_check,
266
286
  self._check_updates_btn,
267
287
  )
@@ -301,6 +321,9 @@ class SettingsWindow(QMainWindow):
301
321
  def _on_detect_updates_changed(self, checked: bool) -> None:
302
322
  self._persist(detect_updates=checked)
303
323
 
324
+ def _on_auto_apply_changed(self, checked: bool) -> None:
325
+ self._persist(auto_apply=checked)
326
+
304
327
  def _on_auto_start_changed(self, checked: bool) -> None:
305
328
  self._config = update_user_config(auto_start=checked)
306
329
  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
  # ---------------------------------------------------------------------------