synodic-client 0.0.1.dev34__tar.gz → 0.0.1.dev36__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.dev34 → synodic_client-0.0.1.dev36}/PKG-INFO +2 -2
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/pyproject.toml +2 -2
- synodic_client-0.0.1.dev36/synodic_client/_version.py +1 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/screen/action_card.py +17 -1
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/screen/screen.py +17 -1
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/screen/settings.py +5 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/screen/tray.py +40 -139
- synodic_client-0.0.1.dev36/synodic_client/application/screen/update_banner.py +305 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/theme.py +70 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/unit/qt/test_settings.py +8 -0
- synodic_client-0.0.1.dev36/tests/unit/qt/test_update_banner.py +185 -0
- synodic_client-0.0.1.dev34/synodic_client/_version.py +0 -1
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/LICENSE.md +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/README.md +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/__init__.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/__main__.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/__init__.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/bootstrap.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/icon.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/instance.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/qt.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/screen/__init__.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/screen/card.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/screen/install.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/screen/log_panel.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/screen/spinner.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/uri.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/workers.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/cli.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/client.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/config.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/logging.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/protocol.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/py.typed +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/resolution.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/startup.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/updater.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/__init__.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/conftest.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/unit/__init__.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/unit/qt/__init__.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/unit/qt/conftest.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/unit/qt/test_action_card.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/unit/qt/test_install_preview.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/unit/qt/test_log_panel.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/unit/qt/test_logging.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/unit/test_cli.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/unit/test_client_updater.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/unit/test_client_version.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/unit/test_config.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/unit/test_examples.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/unit/test_install.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/unit/test_resolution.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/unit/test_updater.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/unit/test_uri.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/unit/windows/__init__.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/unit/windows/conftest.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/unit/windows/test_protocol.py +0 -0
- {synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/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.
|
|
3
|
+
Version: 0.0.1.dev36
|
|
4
4
|
Author-Email: Synodic Software <contact@synodic.software>
|
|
5
5
|
License: LGPL-3.0-or-later
|
|
6
6
|
Project-URL: homepage, https://github.com/synodic/synodic-client
|
|
@@ -8,7 +8,7 @@ Project-URL: repository, https://github.com/synodic/synodic-client
|
|
|
8
8
|
Requires-Python: <3.15,>=3.14
|
|
9
9
|
Requires-Dist: pyside6>=6.10.2
|
|
10
10
|
Requires-Dist: packaging>=26.0
|
|
11
|
-
Requires-Dist: porringer>=0.2.1.
|
|
11
|
+
Requires-Dist: porringer>=0.2.1.dev52
|
|
12
12
|
Requires-Dist: qasync>=0.28.0
|
|
13
13
|
Requires-Dist: velopack>=0.0.1442.dev64255
|
|
14
14
|
Requires-Dist: typer>=0.24.1
|
|
@@ -10,12 +10,12 @@ requires-python = ">=3.14, <3.15"
|
|
|
10
10
|
dependencies = [
|
|
11
11
|
"pyside6>=6.10.2",
|
|
12
12
|
"packaging>=26.0",
|
|
13
|
-
"porringer>=0.2.1.
|
|
13
|
+
"porringer>=0.2.1.dev52",
|
|
14
14
|
"qasync>=0.28.0",
|
|
15
15
|
"velopack>=0.0.1442.dev64255",
|
|
16
16
|
"typer>=0.24.1",
|
|
17
17
|
]
|
|
18
|
-
version = "0.0.1.
|
|
18
|
+
version = "0.0.1.dev36"
|
|
19
19
|
|
|
20
20
|
[project.license]
|
|
21
21
|
text = "LGPL-3.0-or-later"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.0.1.dev36'
|
|
@@ -297,6 +297,9 @@ class ActionCard(QFrame):
|
|
|
297
297
|
|
|
298
298
|
self._package_label = QLabel()
|
|
299
299
|
self._package_label.setStyleSheet(ACTION_CARD_PACKAGE_STYLE)
|
|
300
|
+
self._package_label.setTextInteractionFlags(
|
|
301
|
+
Qt.TextInteractionFlag.TextSelectableByMouse,
|
|
302
|
+
)
|
|
300
303
|
top.addWidget(self._package_label)
|
|
301
304
|
|
|
302
305
|
top.addStretch()
|
|
@@ -327,6 +330,9 @@ class ActionCard(QFrame):
|
|
|
327
330
|
self._desc_label = QLabel()
|
|
328
331
|
self._desc_label.setStyleSheet(ACTION_CARD_DESC_STYLE)
|
|
329
332
|
self._desc_label.setWordWrap(True)
|
|
333
|
+
self._desc_label.setTextInteractionFlags(
|
|
334
|
+
Qt.TextInteractionFlag.TextSelectableByMouse,
|
|
335
|
+
)
|
|
330
336
|
return self._desc_label
|
|
331
337
|
|
|
332
338
|
def _build_command_row(self) -> QWidget:
|
|
@@ -377,9 +383,13 @@ class ActionCard(QFrame):
|
|
|
377
383
|
"""Toggle the inline log body on click."""
|
|
378
384
|
if self._is_skeleton or not hasattr(self, '_log_output'):
|
|
379
385
|
return
|
|
380
|
-
# Don't toggle the log when clicking
|
|
386
|
+
# Don't toggle the log when clicking interactive child widgets
|
|
381
387
|
if hasattr(self, '_copy_btn') and self._copy_btn.underMouse():
|
|
382
388
|
return
|
|
389
|
+
if hasattr(self, '_package_label') and self._package_label.underMouse():
|
|
390
|
+
return
|
|
391
|
+
if hasattr(self, '_desc_label') and self._desc_label.underMouse():
|
|
392
|
+
return
|
|
383
393
|
self._toggle_log()
|
|
384
394
|
|
|
385
395
|
def _toggle_log(self) -> None:
|
|
@@ -560,6 +570,12 @@ class ActionCard(QFrame):
|
|
|
560
570
|
self._status_label.setText(label)
|
|
561
571
|
self._status_label.setStyleSheet(ACTION_CARD_STATUS_NEEDED)
|
|
562
572
|
|
|
573
|
+
# Surface diagnostic detail (e.g. SCM URL mismatch) as a tooltip
|
|
574
|
+
if result.message:
|
|
575
|
+
self._status_label.setToolTip(result.message)
|
|
576
|
+
else:
|
|
577
|
+
self._status_label.setToolTip('')
|
|
578
|
+
|
|
563
579
|
# Version column
|
|
564
580
|
self._check_available_version = result.available_version
|
|
565
581
|
if result.installed_version and result.available_version:
|
|
@@ -38,6 +38,7 @@ from synodic_client.application.screen.install import (
|
|
|
38
38
|
normalize_manifest_key,
|
|
39
39
|
)
|
|
40
40
|
from synodic_client.application.screen.spinner import SpinnerWidget
|
|
41
|
+
from synodic_client.application.screen.update_banner import UpdateBanner
|
|
41
42
|
from synodic_client.application.theme import (
|
|
42
43
|
CARD_SPACING,
|
|
43
44
|
COMPACT_MARGINS,
|
|
@@ -815,6 +816,9 @@ class MainWindow(QMainWindow):
|
|
|
815
816
|
self.setMinimumSize(*MAIN_WINDOW_MIN_SIZE)
|
|
816
817
|
self.setWindowIcon(app_icon())
|
|
817
818
|
|
|
819
|
+
# Update banner — always available, starts hidden.
|
|
820
|
+
self._update_banner = UpdateBanner(self)
|
|
821
|
+
|
|
818
822
|
@property
|
|
819
823
|
def porringer(self) -> API | None:
|
|
820
824
|
"""Return the porringer API instance, if available."""
|
|
@@ -825,6 +829,11 @@ class MainWindow(QMainWindow):
|
|
|
825
829
|
"""Return the plugins view, if initialised."""
|
|
826
830
|
return self._plugins_view
|
|
827
831
|
|
|
832
|
+
@property
|
|
833
|
+
def update_banner(self) -> UpdateBanner:
|
|
834
|
+
"""Return the update banner widget."""
|
|
835
|
+
return self._update_banner
|
|
836
|
+
|
|
828
837
|
def show(self) -> None:
|
|
829
838
|
"""Show the window, initializing UI lazily on first show."""
|
|
830
839
|
if self._tabs is None and self._porringer is not None and self._config is not None:
|
|
@@ -843,7 +852,14 @@ class MainWindow(QMainWindow):
|
|
|
843
852
|
gear_btn.clicked.connect(self.settings_requested.emit)
|
|
844
853
|
self._tabs.setCornerWidget(gear_btn)
|
|
845
854
|
|
|
846
|
-
|
|
855
|
+
# Container: banner above tabs
|
|
856
|
+
container = QWidget(self)
|
|
857
|
+
container_layout = QVBoxLayout(container)
|
|
858
|
+
container_layout.setContentsMargins(0, 0, 0, 0)
|
|
859
|
+
container_layout.setSpacing(0)
|
|
860
|
+
container_layout.addWidget(self._update_banner)
|
|
861
|
+
container_layout.addWidget(self._tabs)
|
|
862
|
+
self.setCentralWidget(container)
|
|
847
863
|
|
|
848
864
|
# Paint the window immediately, then refresh data asynchronously
|
|
849
865
|
super().show()
|
|
@@ -224,6 +224,11 @@ class SettingsWindow(QMainWindow):
|
|
|
224
224
|
"""Set the inline status text next to the *Check for Updates* button."""
|
|
225
225
|
self._update_status_label.setText(text)
|
|
226
226
|
|
|
227
|
+
def set_checking(self) -> None:
|
|
228
|
+
"""Enter the *checking* state — disable button and show status."""
|
|
229
|
+
self._check_updates_btn.setEnabled(False)
|
|
230
|
+
self._update_status_label.setText('Checking\u2026')
|
|
231
|
+
|
|
227
232
|
def reset_check_updates_button(self) -> None:
|
|
228
233
|
"""Re-enable the *Check for Updates* button after a check completes."""
|
|
229
234
|
self._check_updates_btn.setEnabled(True)
|
{synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/screen/tray.py
RENAMED
|
@@ -10,8 +10,6 @@ from PySide6.QtGui import QAction
|
|
|
10
10
|
from PySide6.QtWidgets import (
|
|
11
11
|
QApplication,
|
|
12
12
|
QMenu,
|
|
13
|
-
QMessageBox,
|
|
14
|
-
QProgressDialog,
|
|
15
13
|
QSystemTrayIcon,
|
|
16
14
|
)
|
|
17
15
|
|
|
@@ -56,15 +54,11 @@ class TrayScreen:
|
|
|
56
54
|
self._config = config
|
|
57
55
|
self._runner: QThread | None = None
|
|
58
56
|
self._tool_runner: QThread | None = None
|
|
59
|
-
self._progress_dialog: QProgressDialog | None = None
|
|
60
|
-
self._pending_update_info: UpdateInfo | None = None
|
|
61
|
-
self._download_cancelled = False
|
|
62
57
|
|
|
63
58
|
self.tray_icon = app_icon()
|
|
64
59
|
|
|
65
60
|
self.tray = QSystemTrayIcon()
|
|
66
61
|
self.tray.setIcon(self.tray_icon)
|
|
67
|
-
self.tray.messageClicked.connect(self._on_notification_clicked)
|
|
68
62
|
self.tray.activated.connect(self._on_tray_activated)
|
|
69
63
|
self.tray.setVisible(True)
|
|
70
64
|
|
|
@@ -92,6 +86,11 @@ class TrayScreen:
|
|
|
92
86
|
plugins_view.update_all_requested.connect(self._on_tool_update)
|
|
93
87
|
plugins_view.plugin_update_requested.connect(self._on_single_plugin_update)
|
|
94
88
|
|
|
89
|
+
# Connect update banner signals
|
|
90
|
+
self._banner = window.update_banner
|
|
91
|
+
self._banner.restart_requested.connect(self._apply_update)
|
|
92
|
+
self._banner.retry_requested.connect(lambda: self._do_check_updates(silent=True))
|
|
93
|
+
|
|
95
94
|
def _build_menu(self, app: QApplication, window: MainWindow) -> None:
|
|
96
95
|
"""Build the tray context menu."""
|
|
97
96
|
self.menu = QMenu()
|
|
@@ -216,12 +215,6 @@ class TrayScreen:
|
|
|
216
215
|
self.update_action.setEnabled(True)
|
|
217
216
|
self.update_action.setText('Check for Updates...')
|
|
218
217
|
|
|
219
|
-
def _close_progress(self) -> None:
|
|
220
|
-
"""Close and discard the download progress dialog, if open."""
|
|
221
|
-
if self._progress_dialog:
|
|
222
|
-
self._progress_dialog.close()
|
|
223
|
-
self._progress_dialog = None
|
|
224
|
-
|
|
225
218
|
def _on_check_updates(self) -> None:
|
|
226
219
|
"""Handle manual check for updates action."""
|
|
227
220
|
self._do_check_updates(silent=False)
|
|
@@ -230,7 +223,7 @@ class TrayScreen:
|
|
|
230
223
|
"""Handle automatic (periodic) check for updates.
|
|
231
224
|
|
|
232
225
|
Failures and no-update results are logged silently without
|
|
233
|
-
showing
|
|
226
|
+
showing the in-app error banner.
|
|
234
227
|
"""
|
|
235
228
|
self._do_check_updates(silent=True)
|
|
236
229
|
|
|
@@ -238,24 +231,19 @@ class TrayScreen:
|
|
|
238
231
|
"""Run an update check.
|
|
239
232
|
|
|
240
233
|
Args:
|
|
241
|
-
silent: When ``True``, suppress
|
|
242
|
-
and no-update results.
|
|
243
|
-
when an update *is* available.
|
|
234
|
+
silent: When ``True``, suppress the in-app error banner
|
|
235
|
+
for failures and no-update results. The banner is
|
|
236
|
+
always shown when an update *is* available.
|
|
244
237
|
"""
|
|
245
238
|
if self._client.updater is None:
|
|
246
239
|
if not silent:
|
|
247
|
-
self.
|
|
248
|
-
'Update Error',
|
|
249
|
-
'Updater is not initialized.',
|
|
250
|
-
QSystemTrayIcon.MessageIcon.Warning,
|
|
251
|
-
)
|
|
240
|
+
self._banner.show_error('Updater is not initialized.')
|
|
252
241
|
return
|
|
253
242
|
|
|
254
243
|
# Disable both the tray action and the settings button while checking
|
|
255
244
|
self.update_action.setEnabled(False)
|
|
256
245
|
self.update_action.setText('Checking for Updates...')
|
|
257
|
-
self._settings_window.
|
|
258
|
-
self._settings_window.set_update_status('Checking\u2026')
|
|
246
|
+
self._settings_window.set_checking()
|
|
259
247
|
|
|
260
248
|
worker = UpdateCheckWorker(self._client)
|
|
261
249
|
worker.finished.connect(lambda result: self._on_update_check_finished(result, silent=silent))
|
|
@@ -272,11 +260,7 @@ class TrayScreen:
|
|
|
272
260
|
if result is None:
|
|
273
261
|
self._settings_window.set_update_status('Check failed')
|
|
274
262
|
if not silent:
|
|
275
|
-
self.
|
|
276
|
-
'Update Check Failed',
|
|
277
|
-
'Failed to check for updates. Please try again later.',
|
|
278
|
-
QSystemTrayIcon.MessageIcon.Warning,
|
|
279
|
-
)
|
|
263
|
+
self._banner.show_error('Failed to check for updates.')
|
|
280
264
|
else:
|
|
281
265
|
logger.warning('Automatic update check failed (no result)')
|
|
282
266
|
return
|
|
@@ -284,14 +268,7 @@ class TrayScreen:
|
|
|
284
268
|
if result.error:
|
|
285
269
|
self._settings_window.set_update_status(result.error)
|
|
286
270
|
if not silent:
|
|
287
|
-
|
|
288
|
-
# from genuine failures.
|
|
289
|
-
is_no_releases = 'No releases found' in result.error
|
|
290
|
-
title = 'No Updates Available' if is_no_releases else 'Update Check Failed'
|
|
291
|
-
icon = (
|
|
292
|
-
QSystemTrayIcon.MessageIcon.Information if is_no_releases else QSystemTrayIcon.MessageIcon.Warning
|
|
293
|
-
)
|
|
294
|
-
self.tray.showMessage(title, result.error, icon)
|
|
271
|
+
self._banner.show_error(result.error)
|
|
295
272
|
else:
|
|
296
273
|
logger.warning('Automatic update check failed: %s', result.error)
|
|
297
274
|
return
|
|
@@ -301,25 +278,16 @@ class TrayScreen:
|
|
|
301
278
|
f'Up to date ({result.current_version})',
|
|
302
279
|
)
|
|
303
280
|
if not silent:
|
|
304
|
-
|
|
305
|
-
'No Updates Available',
|
|
306
|
-
f'You are running the latest version ({result.current_version}).',
|
|
307
|
-
QSystemTrayIcon.MessageIcon.Information,
|
|
308
|
-
)
|
|
281
|
+
logger.info('No updates available (current: %s)', result.current_version)
|
|
309
282
|
else:
|
|
310
283
|
logger.debug('Automatic update check: no update available')
|
|
311
284
|
return
|
|
312
285
|
|
|
313
|
-
# Update available
|
|
314
|
-
|
|
315
|
-
self._settings_window.set_update_status(
|
|
316
|
-
|
|
317
|
-
)
|
|
318
|
-
self.tray.showMessage(
|
|
319
|
-
'Update Available',
|
|
320
|
-
f'Version {result.latest_version} is available (current: {result.current_version}).\nClick to download.',
|
|
321
|
-
QSystemTrayIcon.MessageIcon.Information,
|
|
322
|
-
)
|
|
286
|
+
# Update available — show banner and start download automatically
|
|
287
|
+
version = str(result.latest_version)
|
|
288
|
+
self._settings_window.set_update_status(f'Update available: {version}')
|
|
289
|
+
self._banner.show_downloading(version)
|
|
290
|
+
self._start_download(version)
|
|
323
291
|
|
|
324
292
|
def _on_update_check_error(self, error: str, *, silent: bool = False) -> None:
|
|
325
293
|
"""Handle update check error."""
|
|
@@ -328,11 +296,7 @@ class TrayScreen:
|
|
|
328
296
|
self._settings_window.set_update_status(f'Error: {error}')
|
|
329
297
|
|
|
330
298
|
if not silent:
|
|
331
|
-
self.
|
|
332
|
-
'Update Check Error',
|
|
333
|
-
f'An error occurred: {error}',
|
|
334
|
-
QSystemTrayIcon.MessageIcon.Critical,
|
|
335
|
-
)
|
|
299
|
+
self._banner.show_error(f'Update check error: {error}')
|
|
336
300
|
else:
|
|
337
301
|
logger.warning('Automatic update check error: %s', error)
|
|
338
302
|
|
|
@@ -393,107 +357,44 @@ class TrayScreen:
|
|
|
393
357
|
QSystemTrayIcon.MessageIcon.Warning,
|
|
394
358
|
)
|
|
395
359
|
|
|
396
|
-
|
|
397
|
-
"""Handle notification click - starts download if update is pending."""
|
|
398
|
-
if self._pending_update_info is not None and self._pending_update_info.available:
|
|
399
|
-
self._pending_update_info = None
|
|
400
|
-
self._start_download()
|
|
401
|
-
|
|
402
|
-
def _start_download(self) -> None:
|
|
403
|
-
"""Start downloading the update."""
|
|
404
|
-
# Create progress dialog
|
|
405
|
-
self._progress_dialog = QProgressDialog(
|
|
406
|
-
'Downloading update...',
|
|
407
|
-
'Cancel',
|
|
408
|
-
0,
|
|
409
|
-
100,
|
|
410
|
-
self._window,
|
|
411
|
-
)
|
|
412
|
-
self._progress_dialog.setWindowTitle('Downloading Update')
|
|
413
|
-
self._progress_dialog.setAutoClose(False)
|
|
414
|
-
self._progress_dialog.setAutoReset(False)
|
|
415
|
-
self._progress_dialog.canceled.connect(self._on_download_cancelled)
|
|
416
|
-
self._download_cancelled = False
|
|
417
|
-
self._progress_dialog.show()
|
|
360
|
+
# -- Self-update download & apply --
|
|
418
361
|
|
|
362
|
+
def _start_download(self, version: str) -> None:
|
|
363
|
+
"""Start downloading the update in the background.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
version: The version string being downloaded (for banner display).
|
|
367
|
+
"""
|
|
419
368
|
worker = UpdateDownloadWorker(self._client)
|
|
420
|
-
worker.finished.connect(self._on_download_finished)
|
|
421
|
-
worker.progress.connect(self.
|
|
369
|
+
worker.finished.connect(lambda success: self._on_download_finished(success, version))
|
|
370
|
+
worker.progress.connect(self._banner.show_downloading_progress)
|
|
422
371
|
worker.error.connect(self._on_download_error)
|
|
423
372
|
|
|
424
373
|
self._runner = worker
|
|
425
374
|
self._runner.start()
|
|
426
375
|
|
|
427
|
-
def
|
|
428
|
-
"""Handle
|
|
429
|
-
self._download_cancelled = True
|
|
430
|
-
self._close_progress()
|
|
431
|
-
logger.info('Update download cancelled by user')
|
|
432
|
-
|
|
433
|
-
def _on_download_progress(self, percentage: int) -> None:
|
|
434
|
-
"""Handle download progress update."""
|
|
435
|
-
if self._progress_dialog and not self._download_cancelled:
|
|
436
|
-
self._progress_dialog.setValue(percentage)
|
|
437
|
-
self._progress_dialog.setLabelText(f'Downloading update... {percentage}%')
|
|
438
|
-
|
|
439
|
-
def _on_download_finished(self, success: bool) -> None:
|
|
440
|
-
"""Handle download completion."""
|
|
441
|
-
self._close_progress()
|
|
442
|
-
|
|
443
|
-
if self._download_cancelled:
|
|
444
|
-
return
|
|
445
|
-
|
|
376
|
+
def _on_download_finished(self, success: bool, version: str) -> None:
|
|
377
|
+
"""Handle download completion — transition banner to ready state."""
|
|
446
378
|
if not success:
|
|
447
|
-
self.
|
|
448
|
-
'Download Failed',
|
|
449
|
-
'Failed to download the update. Please try again later.',
|
|
450
|
-
QSystemTrayIcon.MessageIcon.Warning,
|
|
451
|
-
)
|
|
379
|
+
self._banner.show_error('Download failed. Please try again later.')
|
|
452
380
|
return
|
|
453
381
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
self._window if self._window.isVisible() else None,
|
|
457
|
-
'Download Complete',
|
|
458
|
-
'The update has been downloaded.\n\n'
|
|
459
|
-
'Would you like to install it now?\n'
|
|
460
|
-
'The application will restart after installation.',
|
|
461
|
-
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
462
|
-
QMessageBox.StandardButton.Yes,
|
|
463
|
-
)
|
|
464
|
-
|
|
465
|
-
if reply == QMessageBox.StandardButton.Yes:
|
|
466
|
-
self._apply_update()
|
|
382
|
+
self._banner.show_ready(version)
|
|
383
|
+
self._settings_window.set_update_status(f'Ready to install: {version}')
|
|
467
384
|
|
|
468
385
|
def _on_download_error(self, error: str) -> None:
|
|
469
|
-
"""Handle download error."""
|
|
470
|
-
self.
|
|
471
|
-
|
|
472
|
-
self.tray.showMessage(
|
|
473
|
-
'Download Error',
|
|
474
|
-
f'An error occurred while downloading: {error}',
|
|
475
|
-
QSystemTrayIcon.MessageIcon.Critical,
|
|
476
|
-
)
|
|
386
|
+
"""Handle download error — show error banner."""
|
|
387
|
+
self._banner.show_error(f'Download error: {error}')
|
|
477
388
|
|
|
478
389
|
def _apply_update(self) -> None:
|
|
479
|
-
"""Apply the downloaded update."""
|
|
390
|
+
"""Apply the downloaded update and restart."""
|
|
480
391
|
if self._client.updater is None:
|
|
481
392
|
return
|
|
482
393
|
|
|
483
394
|
try:
|
|
484
|
-
# Schedule update to apply on exit, then quit the app
|
|
485
395
|
self._client.apply_update_on_exit(restart=True)
|
|
486
|
-
|
|
487
|
-
self.tray.showMessage(
|
|
488
|
-
'Update Ready',
|
|
489
|
-
'The update will be applied when the application closes.\nThe application will restart automatically.',
|
|
490
|
-
QSystemTrayIcon.MessageIcon.Information,
|
|
491
|
-
)
|
|
396
|
+
logger.info('Update scheduled — restarting application')
|
|
492
397
|
self._app.quit()
|
|
493
|
-
|
|
494
398
|
except Exception as e:
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
f'Failed to apply the update: {e}',
|
|
498
|
-
QSystemTrayIcon.MessageIcon.Warning,
|
|
499
|
-
)
|
|
399
|
+
logger.error('Failed to apply update: %s', e)
|
|
400
|
+
self._banner.show_error(f'Failed to apply update: {e}')
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""In-app update banner for the self-update lifecycle.
|
|
2
|
+
|
|
3
|
+
Replaces Windows tray balloon notifications and modal dialogs with a
|
|
4
|
+
persistent, non-intrusive banner displayed at the top of the main
|
|
5
|
+
window. The banner transitions through three visual states:
|
|
6
|
+
|
|
7
|
+
* **downloading** — update detected, auto-downloading in the background.
|
|
8
|
+
* **ready** — download complete; user can restart at their convenience.
|
|
9
|
+
* **error** — check or download failed with a retry option.
|
|
10
|
+
|
|
11
|
+
The banner slides in/out using a ``QPropertyAnimation`` on
|
|
12
|
+
``maximumHeight`` for a polished feel.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from enum import Enum, auto
|
|
19
|
+
|
|
20
|
+
from PySide6.QtCore import (
|
|
21
|
+
QEasingCurve,
|
|
22
|
+
QPropertyAnimation,
|
|
23
|
+
Qt,
|
|
24
|
+
QTimer,
|
|
25
|
+
Signal,
|
|
26
|
+
)
|
|
27
|
+
from PySide6.QtWidgets import (
|
|
28
|
+
QFrame,
|
|
29
|
+
QHBoxLayout,
|
|
30
|
+
QLabel,
|
|
31
|
+
QProgressBar,
|
|
32
|
+
QPushButton,
|
|
33
|
+
QSizePolicy,
|
|
34
|
+
QVBoxLayout,
|
|
35
|
+
QWidget,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
from synodic_client.application.theme import (
|
|
39
|
+
UPDATE_BANNER_ANIMATION_MS,
|
|
40
|
+
UPDATE_BANNER_BTN_STYLE,
|
|
41
|
+
UPDATE_BANNER_DISMISS_STYLE,
|
|
42
|
+
UPDATE_BANNER_ERROR_DISMISS_MS,
|
|
43
|
+
UPDATE_BANNER_ERROR_STYLE,
|
|
44
|
+
UPDATE_BANNER_MESSAGE_STYLE,
|
|
45
|
+
UPDATE_BANNER_PROGRESS_STYLE,
|
|
46
|
+
UPDATE_BANNER_READY_STYLE,
|
|
47
|
+
UPDATE_BANNER_STYLE,
|
|
48
|
+
UPDATE_BANNER_VERSION_STYLE,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class UpdateBannerState(Enum):
|
|
55
|
+
"""Visual states for the update banner."""
|
|
56
|
+
|
|
57
|
+
HIDDEN = auto()
|
|
58
|
+
DOWNLOADING = auto()
|
|
59
|
+
READY = auto()
|
|
60
|
+
ERROR = auto()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Height of the banner content (progress variant is slightly taller).
|
|
64
|
+
_BANNER_HEIGHT = 38
|
|
65
|
+
_BANNER_HEIGHT_WITH_PROGRESS = 44
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class UpdateBanner(QFrame):
|
|
69
|
+
"""Non-intrusive in-app banner for the self-update lifecycle.
|
|
70
|
+
|
|
71
|
+
Signals:
|
|
72
|
+
restart_requested: User clicked "Restart Now".
|
|
73
|
+
retry_requested: User clicked "Retry" on an error banner.
|
|
74
|
+
dismissed: User clicked the dismiss (×) button.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
restart_requested = Signal()
|
|
78
|
+
retry_requested = Signal()
|
|
79
|
+
dismissed = Signal()
|
|
80
|
+
|
|
81
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
82
|
+
super().__init__(parent)
|
|
83
|
+
self.setObjectName('updateBanner')
|
|
84
|
+
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
|
85
|
+
|
|
86
|
+
self._state = UpdateBannerState.HIDDEN
|
|
87
|
+
self._target_version: str = ''
|
|
88
|
+
|
|
89
|
+
# --- Layout ---
|
|
90
|
+
self._outer = QVBoxLayout(self)
|
|
91
|
+
self._outer.setContentsMargins(0, 0, 0, 0)
|
|
92
|
+
self._outer.setSpacing(0)
|
|
93
|
+
|
|
94
|
+
# Row: icon · message · [progress_label] · [action_btn] · dismiss
|
|
95
|
+
self._row = QHBoxLayout()
|
|
96
|
+
self._row.setContentsMargins(12, 6, 12, 4)
|
|
97
|
+
self._row.setSpacing(8)
|
|
98
|
+
self._outer.addLayout(self._row)
|
|
99
|
+
|
|
100
|
+
self._icon_label = QLabel('\U0001f504') # 🔄
|
|
101
|
+
self._icon_label.setFixedWidth(18)
|
|
102
|
+
self._row.addWidget(self._icon_label)
|
|
103
|
+
|
|
104
|
+
self._message = QLabel()
|
|
105
|
+
self._message.setStyleSheet(UPDATE_BANNER_MESSAGE_STYLE)
|
|
106
|
+
self._row.addWidget(self._message)
|
|
107
|
+
|
|
108
|
+
self._row.addStretch()
|
|
109
|
+
|
|
110
|
+
self._action_btn = QPushButton()
|
|
111
|
+
self._action_btn.setStyleSheet(UPDATE_BANNER_BTN_STYLE)
|
|
112
|
+
self._action_btn.clicked.connect(self._on_action)
|
|
113
|
+
self._action_btn.hide()
|
|
114
|
+
self._row.addWidget(self._action_btn)
|
|
115
|
+
|
|
116
|
+
self._dismiss_btn = QPushButton('\u00d7') # ×
|
|
117
|
+
self._dismiss_btn.setStyleSheet(UPDATE_BANNER_DISMISS_STYLE)
|
|
118
|
+
self._dismiss_btn.setFixedWidth(24)
|
|
119
|
+
self._dismiss_btn.clicked.connect(self._on_dismiss)
|
|
120
|
+
self._row.addWidget(self._dismiss_btn)
|
|
121
|
+
|
|
122
|
+
# Thin progress bar (only visible during download)
|
|
123
|
+
self._progress = QProgressBar()
|
|
124
|
+
self._progress.setStyleSheet(UPDATE_BANNER_PROGRESS_STYLE)
|
|
125
|
+
self._progress.setTextVisible(False)
|
|
126
|
+
self._progress.setRange(0, 0) # indeterminate
|
|
127
|
+
self._progress.setFixedHeight(3)
|
|
128
|
+
self._progress.hide()
|
|
129
|
+
self._outer.addWidget(self._progress)
|
|
130
|
+
|
|
131
|
+
# Start fully collapsed
|
|
132
|
+
self.setMaximumHeight(0)
|
|
133
|
+
self.setVisible(False)
|
|
134
|
+
|
|
135
|
+
# Animation for slide-in / slide-out
|
|
136
|
+
self._anim = QPropertyAnimation(self, b'maximumHeight')
|
|
137
|
+
self._anim.setEasingCurve(QEasingCurve.Type.OutCubic)
|
|
138
|
+
self._anim.setDuration(UPDATE_BANNER_ANIMATION_MS)
|
|
139
|
+
|
|
140
|
+
# --- Public API ---
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def state(self) -> UpdateBannerState:
|
|
144
|
+
"""Current visual state of the banner."""
|
|
145
|
+
return self._state
|
|
146
|
+
|
|
147
|
+
def show_downloading(self, version: str) -> None:
|
|
148
|
+
"""Transition to the *downloading* state.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
version: The version string being downloaded (e.g. ``"0.0.1.dev35"``).
|
|
152
|
+
"""
|
|
153
|
+
self._configure(
|
|
154
|
+
state=UpdateBannerState.DOWNLOADING,
|
|
155
|
+
version=version,
|
|
156
|
+
style=UPDATE_BANNER_STYLE,
|
|
157
|
+
icon='\u2b07',
|
|
158
|
+
text=f'Downloading update <b>{version}</b>\u2026',
|
|
159
|
+
text_style=UPDATE_BANNER_MESSAGE_STYLE,
|
|
160
|
+
show_progress=True,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def show_downloading_progress(self, percentage: int) -> None:
|
|
164
|
+
"""Update the progress bar during download.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
percentage: Download progress 0–100.
|
|
168
|
+
"""
|
|
169
|
+
if self._state != UpdateBannerState.DOWNLOADING:
|
|
170
|
+
return
|
|
171
|
+
if self._progress.maximum() == 0:
|
|
172
|
+
# Switch from indeterminate to determinate on first real value
|
|
173
|
+
self._progress.setRange(0, 100)
|
|
174
|
+
self._progress.setValue(percentage)
|
|
175
|
+
|
|
176
|
+
def show_ready(self, version: str) -> None:
|
|
177
|
+
"""Transition to the *ready* state.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
version: The version that is ready to install.
|
|
181
|
+
"""
|
|
182
|
+
self._configure(
|
|
183
|
+
state=UpdateBannerState.READY,
|
|
184
|
+
version=version,
|
|
185
|
+
style=UPDATE_BANNER_READY_STYLE,
|
|
186
|
+
icon='\u2705',
|
|
187
|
+
text=f'Update <b>{version}</b> is ready \u2014 restart to finish installing',
|
|
188
|
+
text_style=UPDATE_BANNER_VERSION_STYLE,
|
|
189
|
+
action_label='Restart Now',
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def show_error(self, message: str) -> None:
|
|
193
|
+
"""Transition to the *error* state.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
message: Human-readable error description.
|
|
197
|
+
"""
|
|
198
|
+
self._configure(
|
|
199
|
+
state=UpdateBannerState.ERROR,
|
|
200
|
+
style=UPDATE_BANNER_ERROR_STYLE,
|
|
201
|
+
icon='\u26a0',
|
|
202
|
+
text=message,
|
|
203
|
+
text_style=UPDATE_BANNER_MESSAGE_STYLE,
|
|
204
|
+
action_label='Retry',
|
|
205
|
+
)
|
|
206
|
+
QTimer.singleShot(UPDATE_BANNER_ERROR_DISMISS_MS, self._auto_dismiss_error)
|
|
207
|
+
|
|
208
|
+
def hide_banner(self) -> None:
|
|
209
|
+
"""Slide the banner out and reset to hidden."""
|
|
210
|
+
if self._state == UpdateBannerState.HIDDEN:
|
|
211
|
+
return
|
|
212
|
+
self._state = UpdateBannerState.HIDDEN
|
|
213
|
+
self._slide_out()
|
|
214
|
+
|
|
215
|
+
# --- Internal ---
|
|
216
|
+
|
|
217
|
+
def _configure(
|
|
218
|
+
self,
|
|
219
|
+
*,
|
|
220
|
+
state: UpdateBannerState,
|
|
221
|
+
style: str,
|
|
222
|
+
icon: str,
|
|
223
|
+
text: str,
|
|
224
|
+
text_style: str,
|
|
225
|
+
version: str = '',
|
|
226
|
+
action_label: str = '',
|
|
227
|
+
show_progress: bool = False,
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Apply common visual configuration and slide the banner in.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
state: The new banner state.
|
|
233
|
+
style: QSS for the banner frame.
|
|
234
|
+
icon: Single character displayed as the leading icon.
|
|
235
|
+
text: Message (may contain HTML).
|
|
236
|
+
text_style: QSS for the message label.
|
|
237
|
+
version: Version string to store (optional).
|
|
238
|
+
action_label: Text for the action button; hidden when empty.
|
|
239
|
+
show_progress: Whether to show the progress bar.
|
|
240
|
+
"""
|
|
241
|
+
self._state = state
|
|
242
|
+
self._target_version = version
|
|
243
|
+
|
|
244
|
+
self.setStyleSheet(style)
|
|
245
|
+
self._icon_label.setText(icon)
|
|
246
|
+
self._message.setText(text)
|
|
247
|
+
self._message.setStyleSheet(text_style)
|
|
248
|
+
|
|
249
|
+
if action_label:
|
|
250
|
+
self._action_btn.setText(action_label)
|
|
251
|
+
self._action_btn.show()
|
|
252
|
+
else:
|
|
253
|
+
self._action_btn.hide()
|
|
254
|
+
|
|
255
|
+
if show_progress:
|
|
256
|
+
self._progress.setRange(0, 0) # indeterminate
|
|
257
|
+
self._progress.show()
|
|
258
|
+
else:
|
|
259
|
+
self._progress.hide()
|
|
260
|
+
|
|
261
|
+
target_height = _BANNER_HEIGHT_WITH_PROGRESS if show_progress else _BANNER_HEIGHT
|
|
262
|
+
self._slide_in(target_height)
|
|
263
|
+
|
|
264
|
+
def _slide_in(self, target_height: int) -> None:
|
|
265
|
+
"""Animate the banner from collapsed to *target_height*."""
|
|
266
|
+
self.setVisible(True)
|
|
267
|
+
self._anim.stop()
|
|
268
|
+
self._anim.setStartValue(self.maximumHeight())
|
|
269
|
+
self._anim.setEndValue(target_height)
|
|
270
|
+
self._anim.start()
|
|
271
|
+
|
|
272
|
+
def _slide_out(self) -> None:
|
|
273
|
+
"""Animate the banner down to zero height, then hide."""
|
|
274
|
+
self._anim.stop()
|
|
275
|
+
self._anim.setStartValue(self.maximumHeight())
|
|
276
|
+
self._anim.setEndValue(0)
|
|
277
|
+
# Use a one-shot connection to avoid accumulating slots.
|
|
278
|
+
self._anim.finished.connect(
|
|
279
|
+
self._on_slide_out_done,
|
|
280
|
+
type=Qt.ConnectionType.SingleShotConnection,
|
|
281
|
+
)
|
|
282
|
+
self._anim.start()
|
|
283
|
+
|
|
284
|
+
def _on_slide_out_done(self) -> None:
|
|
285
|
+
"""Hide the widget once the slide-out animation completes."""
|
|
286
|
+
if self._state == UpdateBannerState.HIDDEN:
|
|
287
|
+
self.setVisible(False)
|
|
288
|
+
|
|
289
|
+
def _on_action(self) -> None:
|
|
290
|
+
"""Handle the primary action button click."""
|
|
291
|
+
if self._state == UpdateBannerState.READY:
|
|
292
|
+
self.restart_requested.emit()
|
|
293
|
+
elif self._state == UpdateBannerState.ERROR:
|
|
294
|
+
self.hide_banner()
|
|
295
|
+
self.retry_requested.emit()
|
|
296
|
+
|
|
297
|
+
def _on_dismiss(self) -> None:
|
|
298
|
+
"""Handle the dismiss (×) button click."""
|
|
299
|
+
self.hide_banner()
|
|
300
|
+
self.dismissed.emit()
|
|
301
|
+
|
|
302
|
+
def _auto_dismiss_error(self) -> None:
|
|
303
|
+
"""Auto-dismiss the error banner if it's still showing."""
|
|
304
|
+
if self._state == UpdateBannerState.ERROR:
|
|
305
|
+
self.hide_banner()
|
{synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/theme.py
RENAMED
|
@@ -286,3 +286,73 @@ SETTINGS_GEAR_STYLE = (
|
|
|
286
286
|
'QPushButton:hover { background: palette(midlight); border-radius: 3px; }'
|
|
287
287
|
)
|
|
288
288
|
"""Gear button style for the MainWindow tab corner widget."""
|
|
289
|
+
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
# Update banner (in-app self-update notification)
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
UPDATE_BANNER_ANIMATION_MS = 250
|
|
294
|
+
"""Duration of the slide-in / slide-out animation (ms)."""
|
|
295
|
+
|
|
296
|
+
UPDATE_BANNER_ERROR_DISMISS_MS = 10000
|
|
297
|
+
"""Auto-dismiss delay for the error banner (ms)."""
|
|
298
|
+
|
|
299
|
+
UPDATE_BANNER_STYLE = (
|
|
300
|
+
'QFrame#updateBanner { background: #1e3a5f; border-bottom: 1px solid #2a5a8f; padding: 6px 12px;}'
|
|
301
|
+
)
|
|
302
|
+
"""Default banner style — subtle blue tint for downloading state."""
|
|
303
|
+
|
|
304
|
+
UPDATE_BANNER_READY_STYLE = (
|
|
305
|
+
'QFrame#updateBanner { background: #1e3f2e; border-bottom: 1px solid #2a6f3f; padding: 6px 12px;}'
|
|
306
|
+
)
|
|
307
|
+
"""Green-tinted banner for "ready to restart" state."""
|
|
308
|
+
|
|
309
|
+
UPDATE_BANNER_ERROR_STYLE = (
|
|
310
|
+
'QFrame#updateBanner { background: #3f1e1e; border-bottom: 1px solid #6f2a2a; padding: 6px 12px;}'
|
|
311
|
+
)
|
|
312
|
+
"""Red-tinted banner for error state."""
|
|
313
|
+
|
|
314
|
+
UPDATE_BANNER_MESSAGE_STYLE = 'color: #d4d4d4; font-size: 12px;'
|
|
315
|
+
"""Style for the banner message text."""
|
|
316
|
+
|
|
317
|
+
UPDATE_BANNER_VERSION_STYLE = 'color: #d4d4d4; font-size: 12px; font-weight: bold;'
|
|
318
|
+
"""Style for the version number in the banner."""
|
|
319
|
+
|
|
320
|
+
UPDATE_BANNER_BTN_STYLE = (
|
|
321
|
+
'QPushButton {'
|
|
322
|
+
' background: #0e639c;'
|
|
323
|
+
' color: white;'
|
|
324
|
+
' border: none;'
|
|
325
|
+
' border-radius: 3px;'
|
|
326
|
+
' padding: 4px 12px;'
|
|
327
|
+
' font-size: 11px;'
|
|
328
|
+
' font-weight: bold;'
|
|
329
|
+
'}'
|
|
330
|
+
'QPushButton:hover { background: #1177bb; }'
|
|
331
|
+
'QPushButton:pressed { background: #0d5689; }'
|
|
332
|
+
)
|
|
333
|
+
"""Primary action button style (Restart Now, Retry)."""
|
|
334
|
+
|
|
335
|
+
UPDATE_BANNER_DISMISS_STYLE = (
|
|
336
|
+
'QPushButton {'
|
|
337
|
+
' color: #808080;'
|
|
338
|
+
' border: none;'
|
|
339
|
+
' font-size: 14px;'
|
|
340
|
+
' padding: 2px 6px;'
|
|
341
|
+
'}'
|
|
342
|
+
'QPushButton:hover { color: #d4d4d4; }'
|
|
343
|
+
)
|
|
344
|
+
"""Dismiss (×) button style."""
|
|
345
|
+
|
|
346
|
+
UPDATE_BANNER_PROGRESS_STYLE = (
|
|
347
|
+
'QProgressBar {'
|
|
348
|
+
' background: #2a2d2e;'
|
|
349
|
+
' border: none;'
|
|
350
|
+
' border-radius: 2px;'
|
|
351
|
+
' max-height: 3px;'
|
|
352
|
+
'}'
|
|
353
|
+
'QProgressBar::chunk {'
|
|
354
|
+
' background: #0e639c;'
|
|
355
|
+
' border-radius: 2px;'
|
|
356
|
+
'}'
|
|
357
|
+
)
|
|
358
|
+
"""Thin inline progress bar for the downloading state."""
|
|
@@ -369,3 +369,11 @@ class TestCheckForUpdatesButton:
|
|
|
369
369
|
window.reset_check_updates_button()
|
|
370
370
|
|
|
371
371
|
assert window._check_updates_btn.isEnabled() is True
|
|
372
|
+
|
|
373
|
+
@staticmethod
|
|
374
|
+
def test_set_checking() -> None:
|
|
375
|
+
"""set_checking disables the button and shows 'Checking\u2026' status."""
|
|
376
|
+
window = _make_window()
|
|
377
|
+
window.set_checking()
|
|
378
|
+
assert window._check_updates_btn.isEnabled() is False
|
|
379
|
+
assert window._update_status_label.text() == 'Checking\u2026'
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Tests for the UpdateBanner widget."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from PySide6.QtWidgets import QApplication
|
|
8
|
+
|
|
9
|
+
from synodic_client.application.screen.update_banner import UpdateBanner, UpdateBannerState
|
|
10
|
+
|
|
11
|
+
_app = QApplication.instance() or QApplication(sys.argv)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Construction
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestUpdateBannerConstruction:
|
|
20
|
+
"""Basic construction and default state."""
|
|
21
|
+
|
|
22
|
+
def test_starts_hidden(self) -> None:
|
|
23
|
+
banner = UpdateBanner()
|
|
24
|
+
assert banner.state == UpdateBannerState.HIDDEN
|
|
25
|
+
assert banner.maximumHeight() == 0
|
|
26
|
+
assert not banner.isVisible()
|
|
27
|
+
|
|
28
|
+
def test_progress_bar_hidden_initially(self) -> None:
|
|
29
|
+
banner = UpdateBanner()
|
|
30
|
+
assert not banner._progress.isVisible()
|
|
31
|
+
|
|
32
|
+
def test_action_btn_hidden_initially(self) -> None:
|
|
33
|
+
banner = UpdateBanner()
|
|
34
|
+
assert not banner._action_btn.isVisible()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# State transitions
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TestUpdateBannerStateTransitions:
|
|
43
|
+
"""Verify visual state transitions."""
|
|
44
|
+
|
|
45
|
+
def test_show_downloading(self) -> None:
|
|
46
|
+
banner = UpdateBanner()
|
|
47
|
+
banner.show_downloading('1.2.3')
|
|
48
|
+
assert banner.state == UpdateBannerState.DOWNLOADING
|
|
49
|
+
assert banner._target_version == '1.2.3'
|
|
50
|
+
assert banner._progress.isVisible()
|
|
51
|
+
assert not banner._action_btn.isVisible()
|
|
52
|
+
assert '1.2.3' in banner._message.text()
|
|
53
|
+
|
|
54
|
+
def test_show_downloading_progress(self) -> None:
|
|
55
|
+
banner = UpdateBanner()
|
|
56
|
+
banner.show_downloading('1.0.0')
|
|
57
|
+
# First progress value switches from indeterminate to determinate
|
|
58
|
+
banner.show_downloading_progress(42)
|
|
59
|
+
assert banner._progress.maximum() == 100
|
|
60
|
+
assert banner._progress.value() == 42
|
|
61
|
+
|
|
62
|
+
def test_show_downloading_progress_ignored_when_not_downloading(self) -> None:
|
|
63
|
+
banner = UpdateBanner()
|
|
64
|
+
banner.show_ready('1.0.0')
|
|
65
|
+
# Should be a no-op, not crash
|
|
66
|
+
banner.show_downloading_progress(50)
|
|
67
|
+
assert banner.state == UpdateBannerState.READY
|
|
68
|
+
|
|
69
|
+
def test_show_ready(self) -> None:
|
|
70
|
+
banner = UpdateBanner()
|
|
71
|
+
banner.show_ready('2.0.0')
|
|
72
|
+
assert banner.state == UpdateBannerState.READY
|
|
73
|
+
assert banner._action_btn.isVisible()
|
|
74
|
+
assert banner._action_btn.text() == 'Restart Now'
|
|
75
|
+
assert not banner._progress.isVisible()
|
|
76
|
+
assert '2.0.0' in banner._message.text()
|
|
77
|
+
|
|
78
|
+
def test_show_error(self) -> None:
|
|
79
|
+
banner = UpdateBanner()
|
|
80
|
+
banner.show_error('Something broke')
|
|
81
|
+
assert banner.state == UpdateBannerState.ERROR
|
|
82
|
+
assert banner._action_btn.isVisible()
|
|
83
|
+
assert banner._action_btn.text() == 'Retry'
|
|
84
|
+
assert 'Something broke' in banner._message.text()
|
|
85
|
+
|
|
86
|
+
def test_hide_banner(self) -> None:
|
|
87
|
+
banner = UpdateBanner()
|
|
88
|
+
banner.show_ready('1.0.0')
|
|
89
|
+
assert banner.state == UpdateBannerState.READY
|
|
90
|
+
banner.hide_banner()
|
|
91
|
+
assert banner.state == UpdateBannerState.HIDDEN
|
|
92
|
+
|
|
93
|
+
def test_hide_banner_noop_when_already_hidden(self) -> None:
|
|
94
|
+
banner = UpdateBanner()
|
|
95
|
+
banner.hide_banner() # should not raise
|
|
96
|
+
assert banner.state == UpdateBannerState.HIDDEN
|
|
97
|
+
|
|
98
|
+
def test_downloading_to_ready_transition(self) -> None:
|
|
99
|
+
banner = UpdateBanner()
|
|
100
|
+
banner.show_downloading('3.0.0')
|
|
101
|
+
assert banner.state == UpdateBannerState.DOWNLOADING
|
|
102
|
+
banner.show_ready('3.0.0')
|
|
103
|
+
assert banner.state == UpdateBannerState.READY
|
|
104
|
+
assert not banner._progress.isVisible()
|
|
105
|
+
|
|
106
|
+
def test_error_to_downloading_transition(self) -> None:
|
|
107
|
+
banner = UpdateBanner()
|
|
108
|
+
banner.show_error('fail')
|
|
109
|
+
assert banner.state == UpdateBannerState.ERROR
|
|
110
|
+
banner.show_downloading('4.0.0')
|
|
111
|
+
assert banner.state == UpdateBannerState.DOWNLOADING
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Signals
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TestUpdateBannerSignals:
|
|
120
|
+
"""Verify signal emissions from user actions."""
|
|
121
|
+
|
|
122
|
+
def test_restart_signal_on_ready_action(self) -> None:
|
|
123
|
+
banner = UpdateBanner()
|
|
124
|
+
banner.show_ready('1.0.0')
|
|
125
|
+
|
|
126
|
+
received = []
|
|
127
|
+
banner.restart_requested.connect(lambda: received.append(True))
|
|
128
|
+
banner._action_btn.click()
|
|
129
|
+
assert received == [True]
|
|
130
|
+
|
|
131
|
+
def test_retry_signal_on_error_action(self) -> None:
|
|
132
|
+
banner = UpdateBanner()
|
|
133
|
+
banner.show_error('oops')
|
|
134
|
+
|
|
135
|
+
received = []
|
|
136
|
+
banner.retry_requested.connect(lambda: received.append(True))
|
|
137
|
+
banner._action_btn.click()
|
|
138
|
+
assert received == [True]
|
|
139
|
+
|
|
140
|
+
def test_dismissed_signal_on_dismiss(self) -> None:
|
|
141
|
+
banner = UpdateBanner()
|
|
142
|
+
banner.show_ready('1.0.0')
|
|
143
|
+
|
|
144
|
+
received = []
|
|
145
|
+
banner.dismissed.connect(lambda: received.append(True))
|
|
146
|
+
banner._dismiss_btn.click()
|
|
147
|
+
assert received == [True]
|
|
148
|
+
assert banner.state == UpdateBannerState.HIDDEN
|
|
149
|
+
|
|
150
|
+
def test_action_btn_click_when_hidden_is_noop(self) -> None:
|
|
151
|
+
"""Clicking the action button when hidden should emit no signal."""
|
|
152
|
+
banner = UpdateBanner()
|
|
153
|
+
|
|
154
|
+
received: list[str] = []
|
|
155
|
+
banner.restart_requested.connect(lambda: received.append('restart'))
|
|
156
|
+
banner.retry_requested.connect(lambda: received.append('retry'))
|
|
157
|
+
banner._action_btn.click()
|
|
158
|
+
assert received == []
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Error auto-dismiss
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class TestUpdateBannerAutoDismiss:
|
|
167
|
+
"""Verify the error banner auto-dismiss timer."""
|
|
168
|
+
|
|
169
|
+
def test_error_auto_dismiss_resets_to_hidden(self) -> None:
|
|
170
|
+
"""The error banner should auto-dismiss after the configured delay."""
|
|
171
|
+
banner = UpdateBanner()
|
|
172
|
+
banner.show_error('transient error')
|
|
173
|
+
assert banner.state == UpdateBannerState.ERROR
|
|
174
|
+
|
|
175
|
+
# Directly invoke the auto-dismiss slot instead of waiting
|
|
176
|
+
banner._auto_dismiss_error()
|
|
177
|
+
assert banner.state == UpdateBannerState.HIDDEN
|
|
178
|
+
|
|
179
|
+
def test_auto_dismiss_noop_if_state_changed(self) -> None:
|
|
180
|
+
"""If the state changed before the timer fires, it's a no-op."""
|
|
181
|
+
banner = UpdateBanner()
|
|
182
|
+
banner.show_error('oops')
|
|
183
|
+
banner.show_ready('1.0.0') # state changed to READY
|
|
184
|
+
banner._auto_dismiss_error() # should not reset to HIDDEN
|
|
185
|
+
assert banner.state == UpdateBannerState.READY
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = '0.0.1.dev34'
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/__init__.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/bootstrap.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/icon.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/instance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/synodic_client/application/screen/card.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/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
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/unit/qt/test_install_preview.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.dev34 → synodic_client-0.0.1.dev36}/tests/unit/windows/test_protocol.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev34 → synodic_client-0.0.1.dev36}/tests/unit/windows/test_startup.py
RENAMED
|
File without changes
|