synodic-client 0.0.1.dev65__tar.gz → 0.0.1.dev66__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/pyproject.toml +1 -1
  3. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/screen/schema.py +0 -61
  4. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/screen/settings.py +28 -34
  5. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/screen/tray.py +15 -2
  6. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/screen/update_banner.py +64 -2
  7. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/update_controller.py +105 -75
  8. synodic_client-0.0.1.dev66/synodic_client/application/update_model.py +120 -0
  9. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/qt/test_settings.py +27 -16
  10. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/qt/test_update_banner.py +1 -2
  11. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/qt/test_update_controller.py +80 -62
  12. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/LICENSE.md +0 -0
  13. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/README.md +0 -0
  14. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/__init__.py +0 -0
  15. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/__main__.py +0 -0
  16. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/__init__.py +0 -0
  17. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/bootstrap.py +0 -0
  18. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/config_store.py +0 -0
  19. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/data.py +0 -0
  20. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/icon.py +0 -0
  21. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/init.py +0 -0
  22. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/instance.py +0 -0
  23. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/package_state.py +0 -0
  24. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/qt.py +0 -0
  25. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/schema.py +0 -0
  26. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/screen/__init__.py +0 -0
  27. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/screen/action_card.py +0 -0
  28. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/screen/card.py +0 -0
  29. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/screen/install.py +0 -0
  30. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/screen/install_workers.py +0 -0
  31. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/screen/log_panel.py +0 -0
  32. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/screen/plugin_row.py +0 -0
  33. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/screen/projects.py +0 -0
  34. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/screen/screen.py +0 -0
  35. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/screen/sidebar.py +0 -0
  36. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/screen/spinner.py +0 -0
  37. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/screen/tool_update_controller.py +0 -0
  38. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/theme.py +0 -0
  39. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/uri.py +0 -0
  40. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/application/workers.py +0 -0
  41. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/cli.py +0 -0
  42. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/client.py +0 -0
  43. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/config.py +0 -0
  44. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/logging.py +0 -0
  45. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/protocol.py +0 -0
  46. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/py.typed +0 -0
  47. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/resolution.py +0 -0
  48. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/schema.py +0 -0
  49. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/startup.py +0 -0
  50. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/subprocess_patch.py +0 -0
  51. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/synodic_client/updater.py +0 -0
  52. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/__init__.py +0 -0
  53. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/conftest.py +0 -0
  54. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/__init__.py +0 -0
  55. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/qt/__init__.py +0 -0
  56. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/qt/conftest.py +0 -0
  57. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/qt/test_action_card.py +0 -0
  58. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/qt/test_gather_packages.py +0 -0
  59. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/qt/test_install_preview.py +0 -0
  60. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/qt/test_log_panel.py +0 -0
  61. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/qt/test_logging.py +0 -0
  62. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/qt/test_preview_model.py +0 -0
  63. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/qt/test_sidebar.py +0 -0
  64. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/qt/test_tray_window_show.py +0 -0
  65. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/qt/test_update_feedback.py +0 -0
  66. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/test_cli.py +0 -0
  67. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/test_client_updater.py +0 -0
  68. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/test_client_version.py +0 -0
  69. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/test_config.py +0 -0
  70. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/test_examples.py +0 -0
  71. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/test_init.py +0 -0
  72. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/test_install.py +0 -0
  73. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/test_resolution.py +0 -0
  74. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/test_updater.py +0 -0
  75. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/test_uri.py +0 -0
  76. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/test_workers.py +0 -0
  77. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/windows/__init__.py +0 -0
  78. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/windows/conftest.py +0 -0
  79. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/tests/unit/windows/test_protocol.py +0 -0
  80. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev66}/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.dev65
3
+ Version: 0.0.1.dev66
4
4
  Author-Email: Synodic Software <contact@synodic.software>
5
5
  License: LGPL-3.0-or-later
6
6
  Project-URL: homepage, https://github.com/synodic/synodic-client
@@ -15,7 +15,7 @@ dependencies = [
15
15
  "velopack>=0.0.1444.dev49733",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev65"
18
+ version = "0.0.1.dev66"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -11,9 +11,7 @@ from __future__ import annotations
11
11
  import enum
12
12
  from collections.abc import Callable
13
13
  from dataclasses import dataclass, field
14
- from enum import Enum, auto
15
14
  from pathlib import Path
16
- from typing import Protocol, runtime_checkable
17
15
 
18
16
  from porringer.schema import (
19
17
  PluginInfo,
@@ -343,62 +341,3 @@ class _DispatchState:
343
341
 
344
342
  action_index: dict[int, int] = field(default_factory=dict)
345
343
  got_parsed: bool = False
346
-
347
-
348
- # ---------------------------------------------------------------------------
349
- # Update view protocol & banner data models
350
- # ---------------------------------------------------------------------------
351
-
352
-
353
- @runtime_checkable
354
- class UpdateView(Protocol):
355
- """Minimal display contract for the self-update lifecycle.
356
-
357
- :class:`UpdateBanner` satisfies this protocol implicitly via
358
- structural typing. The controller broadcasts state transitions
359
- through a ``list[UpdateView]`` so that every window showing update
360
- status stays in sync.
361
- """
362
-
363
- def show_downloading(self, version: str) -> None:
364
- """Indicate that *version* is being downloaded."""
365
- ...
366
-
367
- def show_downloading_progress(self, percentage: int) -> None:
368
- """Update the download progress indicator."""
369
- ...
370
-
371
- def show_ready(self, version: str) -> None:
372
- """Indicate that *version* is downloaded and ready to install."""
373
- ...
374
-
375
- def show_error(self, message: str) -> None:
376
- """Display an error *message* in the update area."""
377
- ...
378
-
379
- def hide_banner(self) -> None:
380
- """Hide the update banner."""
381
- ...
382
-
383
-
384
- class UpdateBannerState(Enum):
385
- """Visual states for the update banner."""
386
-
387
- HIDDEN = auto()
388
- DOWNLOADING = auto()
389
- READY = auto()
390
- ERROR = auto()
391
-
392
-
393
- @dataclass(frozen=True, slots=True)
394
- class _BannerConfig:
395
- """Bundled visual configuration for a banner state transition."""
396
-
397
- state: UpdateBannerState
398
- style: str
399
- icon: str
400
- text: str
401
- text_style: str
402
- version: str = ''
403
- action_label: str = ''
404
- show_progress: bool = False
@@ -32,7 +32,8 @@ from synodic_client.application.config_store import ConfigStore
32
32
  from synodic_client.application.icon import app_icon
33
33
  from synodic_client.application.screen import _format_relative_time
34
34
  from synodic_client.application.screen.card import CardFrame
35
- from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE, UPDATE_STATUS_CHECKING_STYLE
35
+ from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE
36
+ from synodic_client.application.update_model import UpdateModel
36
37
  from synodic_client.logging import log_path, set_debug_level
37
38
  from synodic_client.schema import GITHUB_REPO_URL
38
39
  from synodic_client.startup import is_startup_registered, register_startup, remove_startup
@@ -237,6 +238,32 @@ class SettingsWindow(QMainWindow):
237
238
  card.content_layout.addLayout(row)
238
239
  return card
239
240
 
241
+ # ------------------------------------------------------------------
242
+ # Model binding
243
+ # ------------------------------------------------------------------
244
+
245
+ def connect_model(self, model: UpdateModel) -> None:
246
+ """Connect to an :class:`UpdateModel` for state observation.
247
+
248
+ The model's settings-facing signals drive the update status
249
+ label, check button, restart button, and timestamp label.
250
+ """
251
+ model.status_text_changed.connect(self._on_status_changed)
252
+ model.check_button_enabled_changed.connect(self._check_updates_btn.setEnabled)
253
+ model.restart_visible_changed.connect(self._restart_btn.setVisible)
254
+ model.last_checked_changed.connect(self._on_last_checked_changed)
255
+
256
+ def _on_status_changed(self, text: str, style: str) -> None:
257
+ """Apply a status text and style from the model."""
258
+ self._update_status_label.setText(text)
259
+ self._update_status_label.setStyleSheet(style)
260
+
261
+ def _on_last_checked_changed(self, timestamp: str) -> None:
262
+ """Apply a *last updated* timestamp from the model."""
263
+ relative = _format_relative_time(timestamp)
264
+ self._last_client_update_label.setText(f'Last updated: {relative}')
265
+ self._last_client_update_label.setToolTip(f'Last updated: {timestamp}')
266
+
240
267
  # ------------------------------------------------------------------
241
268
  # Public API
242
269
  # ------------------------------------------------------------------
@@ -275,37 +302,6 @@ class SettingsWindow(QMainWindow):
275
302
  else:
276
303
  self._last_client_update_label.setText('')
277
304
 
278
- def set_update_status(self, text: str, style: str = '') -> None:
279
- """Set the inline status text next to the *Check for Updates* button.
280
-
281
- Args:
282
- text: The status message.
283
- style: Optional stylesheet for the label (e.g. color).
284
- """
285
- self._update_status_label.setText(text)
286
- self._update_status_label.setStyleSheet(style)
287
-
288
- def set_checking(self) -> None:
289
- """Enter the *checking* state — disable button and show status."""
290
- self._check_updates_btn.setEnabled(False)
291
- self._restart_btn.hide()
292
- self._update_status_label.setText('Checking\u2026')
293
- self._update_status_label.setStyleSheet(UPDATE_STATUS_CHECKING_STYLE)
294
-
295
- def reset_check_updates_button(self) -> None:
296
- """Re-enable the *Check for Updates* button after a check completes."""
297
- self._check_updates_btn.setEnabled(True)
298
-
299
- def set_last_checked(self, timestamp: str) -> None:
300
- """Update the *last updated* label from an ISO 8601 timestamp."""
301
- relative = _format_relative_time(timestamp)
302
- self._last_client_update_label.setText(f'Last updated: {relative}')
303
- self._last_client_update_label.setToolTip(f'Last updated: {timestamp}')
304
-
305
- def show_restart_button(self) -> None:
306
- """Show the *Restart & Update* button."""
307
- self._restart_btn.show()
308
-
309
305
  def show(self) -> None:
310
306
  """Sync controls from config, size to content, then show the window."""
311
307
  self.sync_from_config()
@@ -360,8 +356,6 @@ class SettingsWindow(QMainWindow):
360
356
 
361
357
  def _on_check_updates_clicked(self) -> None:
362
358
  """Handle the *Check for Updates* button click."""
363
- self._check_updates_btn.setEnabled(False)
364
- self._update_status_label.setText('Checking\u2026')
365
359
  self.check_updates_requested.emit()
366
360
 
367
361
  def _on_channel_changed(self, index: int) -> None:
@@ -16,6 +16,7 @@ from synodic_client.application.screen.screen import MainWindow
16
16
  from synodic_client.application.screen.settings import SettingsWindow
17
17
  from synodic_client.application.screen.tool_update_controller import ToolUpdateOrchestrator
18
18
  from synodic_client.application.update_controller import UpdateController
19
+ from synodic_client.application.update_model import UpdateModel
19
20
  from synodic_client.client import Client
20
21
 
21
22
  if TYPE_CHECKING:
@@ -66,17 +67,29 @@ class TrayScreen:
66
67
  # MainWindow gear button -> open settings
67
68
  window.settings_requested.connect(self._show_settings)
68
69
 
70
+ # Update model — centralised observable state for the update lifecycle
71
+ self._update_model = UpdateModel()
72
+
69
73
  # Update controller - owns the self-update lifecycle & timer
70
74
  self._banner = window.update_banner
71
75
  self._update_controller = UpdateController(
72
76
  app,
73
77
  client,
74
- [self._banner],
75
- settings_window=self._settings_window,
78
+ self._update_model,
76
79
  store=self._store,
77
80
  )
78
81
  self._update_controller.set_user_active_predicate(self._is_user_active)
79
82
 
83
+ # Connect views to the model
84
+ self._banner.connect_model(self._update_model)
85
+ self._settings_window.connect_model(self._update_model)
86
+
87
+ # Wire user-action signals back to the controller
88
+ self._banner.restart_requested.connect(self._update_controller.request_apply)
89
+ self._banner.retry_requested.connect(self._update_controller.request_retry)
90
+ self._settings_window.check_updates_requested.connect(self._update_controller.request_check)
91
+ self._settings_window.restart_requested.connect(self._update_controller.request_apply)
92
+
80
93
  # Tool update orchestrator - owns tool/package update lifecycle
81
94
  self._tool_orchestrator = ToolUpdateOrchestrator(
82
95
  window,
@@ -15,6 +15,8 @@ The banner slides in/out using a ``QPropertyAnimation`` on
15
15
  from __future__ import annotations
16
16
 
17
17
  import logging
18
+ from dataclasses import dataclass
19
+ from enum import Enum, auto
18
20
 
19
21
  from PySide6.QtCore import (
20
22
  QEasingCurve,
@@ -34,7 +36,6 @@ from PySide6.QtWidgets import (
34
36
  QWidget,
35
37
  )
36
38
 
37
- from synodic_client.application.screen.schema import UpdateBannerState, _BannerConfig
38
39
  from synodic_client.application.theme import (
39
40
  UPDATE_BANNER_ANIMATION_MS,
40
41
  UPDATE_BANNER_BTN_STYLE,
@@ -47,10 +48,34 @@ from synodic_client.application.theme import (
47
48
  UPDATE_BANNER_STYLE,
48
49
  UPDATE_BANNER_VERSION_STYLE,
49
50
  )
51
+ from synodic_client.application.update_model import UpdateModel, UpdatePhase
50
52
 
51
53
  logger = logging.getLogger(__name__)
52
54
 
53
55
 
56
+ class UpdateBannerState(Enum):
57
+ """Visual states for the update banner."""
58
+
59
+ HIDDEN = auto()
60
+ DOWNLOADING = auto()
61
+ READY = auto()
62
+ ERROR = auto()
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class _BannerConfig:
67
+ """Bundled visual configuration for a banner state transition."""
68
+
69
+ state: UpdateBannerState
70
+ style: str
71
+ icon: str
72
+ text: str
73
+ text_style: str
74
+ version: str = ''
75
+ action_label: str = ''
76
+ show_progress: bool = False
77
+
78
+
54
79
  # Height of the banner content (progress variant is slightly taller).
55
80
  _BANNER_HEIGHT = 38
56
81
  _BANNER_HEIGHT_WITH_PROGRESS = 44
@@ -77,6 +102,7 @@ class UpdateBanner(QFrame):
77
102
 
78
103
  self._state = UpdateBannerState.HIDDEN
79
104
  self._target_version: str = ''
105
+ self._error_dismiss_timer: QTimer | None = None
80
106
 
81
107
  # --- Layout ---
82
108
  self._outer = QVBoxLayout(self)
@@ -129,6 +155,28 @@ class UpdateBanner(QFrame):
129
155
  self._anim.setEasingCurve(QEasingCurve.Type.OutCubic)
130
156
  self._anim.setDuration(UPDATE_BANNER_ANIMATION_MS)
131
157
 
158
+ # --- Model binding ---
159
+
160
+ def connect_model(self, model: UpdateModel) -> None:
161
+ """Connect to an :class:`UpdateModel` for state observation.
162
+
163
+ The model's lifecycle signals drive the banner's visual state
164
+ transitions so the controller never needs to call banner
165
+ methods directly.
166
+ """
167
+ self._model = model
168
+ model.phase_changed.connect(self._on_model_phase)
169
+ model.progress_changed.connect(self.show_downloading_progress)
170
+
171
+ def _on_model_phase(self, phase: UpdatePhase) -> None:
172
+ """React to a lifecycle phase change from the model."""
173
+ if phase == UpdatePhase.DOWNLOADING:
174
+ self.show_downloading(self._model.version)
175
+ elif phase == UpdatePhase.READY:
176
+ self.show_ready(self._model.version)
177
+ elif phase == UpdatePhase.ERROR:
178
+ self.show_error(self._model.error_message)
179
+
132
180
  # --- Public API ---
133
181
 
134
182
  @property
@@ -191,6 +239,12 @@ class UpdateBanner(QFrame):
191
239
  Args:
192
240
  message: Human-readable error description.
193
241
  """
242
+ # Cancel any pending auto-dismiss from a previous error to avoid
243
+ # stacking timers that would forcibly hide a freshly shown banner.
244
+ if self._error_dismiss_timer is not None:
245
+ self._error_dismiss_timer.stop()
246
+ self._error_dismiss_timer = None
247
+
194
248
  self._configure(
195
249
  _BannerConfig(
196
250
  state=UpdateBannerState.ERROR,
@@ -201,12 +255,20 @@ class UpdateBanner(QFrame):
201
255
  action_label='Retry',
202
256
  )
203
257
  )
204
- QTimer.singleShot(UPDATE_BANNER_ERROR_DISMISS_MS, self._auto_dismiss_error)
258
+ timer = QTimer(self)
259
+ timer.setSingleShot(True)
260
+ timer.setInterval(UPDATE_BANNER_ERROR_DISMISS_MS)
261
+ timer.timeout.connect(self._auto_dismiss_error)
262
+ timer.start()
263
+ self._error_dismiss_timer = timer
205
264
 
206
265
  def hide_banner(self) -> None:
207
266
  """Slide the banner out and reset to hidden."""
208
267
  if self._state == UpdateBannerState.HIDDEN:
209
268
  return
269
+ if self._error_dismiss_timer is not None:
270
+ self._error_dismiss_timer.stop()
271
+ self._error_dismiss_timer = None
210
272
  self._state = UpdateBannerState.HIDDEN
211
273
  self._animate_height(0)
212
274