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.
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/PKG-INFO +1 -1
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/pyproject.toml +1 -1
- synodic_client-0.0.1.dev42/synodic_client/_version.py +1 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/settings.py +20 -3
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/tray.py +13 -186
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/theme.py +15 -0
- synodic_client-0.0.1.dev42/synodic_client/application/update_controller.py +312 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/config.py +4 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/resolution.py +3 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_gather_packages.py +1 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_settings.py +5 -3
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_tray_window_show.py +1 -0
- synodic_client-0.0.1.dev42/tests/unit/qt/test_update_controller.py +298 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/test_config.py +1 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/test_resolution.py +1 -0
- synodic_client-0.0.1.dev41/synodic_client/_version.py +0 -1
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/LICENSE.md +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/README.md +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/__init__.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/__main__.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/__init__.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/bootstrap.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/data.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/icon.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/instance.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/qt.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/__init__.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/action_card.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/card.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/install.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/log_panel.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/screen.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/sidebar.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/spinner.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/update_banner.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/uri.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/workers.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/cli.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/client.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/logging.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/protocol.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/py.typed +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/startup.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/updater.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/__init__.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/conftest.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/__init__.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/__init__.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/conftest.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_action_card.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_install_preview.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_log_panel.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_logging.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_preview_model.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_sidebar.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_update_banner.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_update_feedback.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/test_cli.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/test_client_updater.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/test_client_version.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/test_examples.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/test_install.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/test_updater.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/test_uri.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/test_workers.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/windows/__init__.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/windows/conftest.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/windows/test_protocol.py +0 -0
- {synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/windows/test_startup.py +0 -0
|
@@ -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:
|
{synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/tray.py
RENAMED
|
@@ -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
|
-
#
|
|
83
|
-
self.
|
|
84
|
-
self.
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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}')
|
{synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/theme.py
RENAMED
|
@@ -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
|
|
|
@@ -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
|
|
356
|
-
assert window._update_status_label.text() == 'Up to date
|
|
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:
|
{synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_tray_window_show.py
RENAMED
|
@@ -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'
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = '0.0.1.dev41'
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/__init__.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/bootstrap.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/data.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/icon.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/instance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/card.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/synodic_client/application/workers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_install_preview.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_preview_model.py
RENAMED
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_update_banner.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_update_feedback.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/windows/test_protocol.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev41 → synodic_client-0.0.1.dev42}/tests/unit/windows/test_startup.py
RENAMED
|
File without changes
|