synodic-client 0.0.1.dev62__tar.gz → 0.0.1.dev63__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 (77) hide show
  1. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/pyproject.toml +1 -1
  3. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/bootstrap.py +2 -0
  4. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/qt.py +31 -0
  5. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/projects.py +7 -5
  6. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/screen.py +15 -8
  7. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/spinner.py +46 -36
  8. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/tool_update_controller.py +28 -0
  9. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/tray.py +6 -0
  10. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/theme.py +1 -0
  11. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/update_controller.py +16 -0
  12. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/workers.py +32 -20
  13. synodic_client-0.0.1.dev63/synodic_client/subprocess_patch.py +82 -0
  14. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/LICENSE.md +0 -0
  15. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/README.md +0 -0
  16. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/__init__.py +0 -0
  17. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/__main__.py +0 -0
  18. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/__init__.py +0 -0
  19. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/data.py +0 -0
  20. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/icon.py +0 -0
  21. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/init.py +0 -0
  22. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/instance.py +0 -0
  23. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/schema.py +0 -0
  24. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/__init__.py +0 -0
  25. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/action_card.py +0 -0
  26. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/card.py +0 -0
  27. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/install.py +0 -0
  28. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/install_workers.py +0 -0
  29. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/log_panel.py +0 -0
  30. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/plugin_row.py +0 -0
  31. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/schema.py +0 -0
  32. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/settings.py +0 -0
  33. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/sidebar.py +0 -0
  34. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/update_banner.py +0 -0
  35. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/uri.py +0 -0
  36. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/cli.py +0 -0
  37. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/client.py +0 -0
  38. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/config.py +0 -0
  39. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/logging.py +0 -0
  40. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/protocol.py +0 -0
  41. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/py.typed +0 -0
  42. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/resolution.py +0 -0
  43. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/schema.py +0 -0
  44. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/startup.py +0 -0
  45. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/updater.py +0 -0
  46. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/__init__.py +0 -0
  47. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/conftest.py +0 -0
  48. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/__init__.py +0 -0
  49. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/__init__.py +0 -0
  50. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/conftest.py +0 -0
  51. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_action_card.py +0 -0
  52. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_gather_packages.py +0 -0
  53. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_install_preview.py +0 -0
  54. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_log_panel.py +0 -0
  55. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_logging.py +0 -0
  56. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_preview_model.py +0 -0
  57. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_settings.py +0 -0
  58. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_sidebar.py +0 -0
  59. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_tray_window_show.py +0 -0
  60. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_update_banner.py +0 -0
  61. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_update_controller.py +0 -0
  62. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_update_feedback.py +0 -0
  63. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/test_cli.py +0 -0
  64. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/test_client_updater.py +0 -0
  65. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/test_client_version.py +0 -0
  66. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/test_config.py +0 -0
  67. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/test_examples.py +0 -0
  68. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/test_init.py +0 -0
  69. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/test_install.py +0 -0
  70. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/test_resolution.py +0 -0
  71. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/test_updater.py +0 -0
  72. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/test_uri.py +0 -0
  73. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/test_workers.py +0 -0
  74. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/windows/__init__.py +0 -0
  75. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/windows/conftest.py +0 -0
  76. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/windows/test_protocol.py +0 -0
  77. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/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.dev62
3
+ Version: 0.0.1.dev63
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.dev62"
18
+ version = "0.0.1.dev63"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -17,6 +17,7 @@ import sys
17
17
 
18
18
  from synodic_client.config import set_dev_mode
19
19
  from synodic_client.logging import configure_logging
20
+ from synodic_client.subprocess_patch import apply as _apply_subprocess_patch
20
21
  from synodic_client.protocol import extract_uri_from_args
21
22
  from synodic_client.updater import initialize_velopack
22
23
 
@@ -24,6 +25,7 @@ from synodic_client.updater import initialize_velopack
24
25
  _dev_mode = '--dev' in sys.argv[1:]
25
26
  _debug = '--debug' in sys.argv[1:]
26
27
  set_dev_mode(_dev_mode)
28
+ _apply_subprocess_patch()
27
29
 
28
30
  configure_logging(debug=_debug)
29
31
  initialize_velopack()
@@ -31,6 +31,7 @@ from synodic_client.resolution import (
31
31
  resolve_config,
32
32
  resolve_update_config,
33
33
  )
34
+ from synodic_client.subprocess_patch import apply as _apply_subprocess_patch
34
35
  from synodic_client.updater import initialize_velopack
35
36
 
36
37
 
@@ -72,6 +73,23 @@ def _process_uri(uri: str, handler: Callable[[str], None]) -> None:
72
73
  handler(manifests[0])
73
74
 
74
75
 
76
+ def _cancel_all_tasks(loop: asyncio.AbstractEventLoop) -> None:
77
+ """Cancel every pending asyncio task on *loop*.
78
+
79
+ Called synchronously from the ``aboutToQuit`` handler. Each task
80
+ receives a cancellation request; when the event loop processes its
81
+ remaining iterations the ``CancelledError`` propagates and the
82
+ tasks finish cleanly.
83
+ """
84
+ _logger = logging.getLogger(__name__)
85
+ pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
86
+ if not pending:
87
+ return
88
+ _logger.info('Cancelling %d pending async task(s)', len(pending))
89
+ for task in pending:
90
+ task.cancel()
91
+
92
+
75
93
  def _install_exception_hook(logger: logging.Logger) -> None:
76
94
  """Redirect unhandled exceptions to the log file.
77
95
 
@@ -163,6 +181,7 @@ def application(*, uri: str | None = None, dev_mode: bool = False, debug: bool =
163
181
  """
164
182
  # Activate dev-mode namespacing before anything reads config paths.
165
183
  set_dev_mode(dev_mode)
184
+ _apply_subprocess_patch()
166
185
 
167
186
  # Configure logging before Velopack so install/uninstall hooks and
168
187
  # first-run diagnostics are captured in the log file.
@@ -221,6 +240,18 @@ def application(*, uri: str | None = None, dev_mode: bool = False, debug: bool =
221
240
  if uri:
222
241
  _process_uri(uri, _handle_install_uri)
223
242
 
243
+ # --- Graceful shutdown ---
244
+ # aboutToQuit fires synchronously when app.quit() is called but
245
+ # before the event loop stops, giving us a window to cancel
246
+ # in-flight async tasks and stop timers.
247
+
248
+ def _on_about_to_quit() -> None:
249
+ logger.info('Application shutting down — cancelling async tasks')
250
+ _tray.shutdown()
251
+ _cancel_all_tasks(loop)
252
+
253
+ app.aboutToQuit.connect(_on_about_to_quit)
254
+
224
255
  # qasync integrates the asyncio event loop with Qt's event loop,
225
256
  # enabling async/await usage in the GUI layer without dedicated threads.
226
257
  with loop:
@@ -22,7 +22,7 @@ from synodic_client.application.data import DataCoordinator
22
22
  from synodic_client.application.screen.install import SetupPreviewWidget
23
23
  from synodic_client.application.screen.schema import PreviewPhase
24
24
  from synodic_client.application.screen.sidebar import ManifestSidebar
25
- from synodic_client.application.screen.spinner import SpinnerWidget
25
+ from synodic_client.application.screen.spinner import LoadingIndicator
26
26
  from synodic_client.application.theme import COMPACT_MARGINS
27
27
  from synodic_client.resolution import ResolvedConfig
28
28
 
@@ -91,9 +91,10 @@ class ProjectsView(QWidget):
91
91
  self._empty_placeholder.setStyleSheet('color: grey; font-size: 13px;')
92
92
  self._stack.addWidget(self._empty_placeholder)
93
93
 
94
- outer.addLayout(right, stretch=1)
94
+ self._loading_indicator = LoadingIndicator('Loading projects\u2026')
95
+ self._stack.addWidget(self._loading_indicator)
95
96
 
96
- self._loading_spinner = SpinnerWidget('Loading projects\u2026', parent=self)
97
+ outer.addLayout(right, stretch=1)
97
98
 
98
99
  # --- Public API ---
99
100
 
@@ -106,7 +107,8 @@ class ProjectsView(QWidget):
106
107
  async def _async_refresh(self) -> None:
107
108
  """Refresh the sidebar and stacked widgets from the porringer cache."""
108
109
  self._refresh_in_progress = True
109
- self._loading_spinner.start()
110
+ self._loading_indicator.start()
111
+ self._stack.setCurrentWidget(self._loading_indicator)
110
112
  self._sidebar.set_enabled(False)
111
113
 
112
114
  try:
@@ -167,7 +169,7 @@ class ProjectsView(QWidget):
167
169
  except Exception:
168
170
  logger.exception('Failed to refresh projects')
169
171
  finally:
170
- self._loading_spinner.stop()
172
+ self._loading_indicator.stop()
171
173
  self._sidebar.set_enabled(True)
172
174
  self._refresh_in_progress = False
173
175
 
@@ -50,7 +50,7 @@ from synodic_client.application.screen.schema import (
50
50
  ProjectInstance,
51
51
  RefreshData,
52
52
  )
53
- from synodic_client.application.screen.spinner import SpinnerWidget
53
+ from synodic_client.application.screen.spinner import LoadingIndicator
54
54
  from synodic_client.application.screen.update_banner import UpdateBanner
55
55
  from synodic_client.application.theme import (
56
56
  COMPACT_MARGINS,
@@ -197,7 +197,8 @@ class ToolsView(QWidget):
197
197
  self._scroll.setWidget(self._container)
198
198
  outer.addWidget(self._scroll)
199
199
 
200
- self._loading_spinner = SpinnerWidget('Loading tools\u2026', parent=self)
200
+ self._loading_indicator = LoadingIndicator('Loading tools\u2026')
201
+ outer.addWidget(self._loading_indicator)
201
202
 
202
203
  # Periodic timer to refresh relative timestamps (every 60s)
203
204
  self._timestamp_timer = QTimer(self)
@@ -224,10 +225,10 @@ class ToolsView(QWidget):
224
225
  toolbar.addWidget(check_btn)
225
226
  self._check_btn = check_btn
226
227
 
227
- update_all_btn = QPushButton('Update All')
228
- update_all_btn.setToolTip('Upgrade all auto-update-enabled plugins now')
229
- update_all_btn.clicked.connect(self.update_all_requested.emit)
230
- toolbar.addWidget(update_all_btn)
228
+ self._update_all_btn = QPushButton('Update All')
229
+ self._update_all_btn.setToolTip('Upgrade all auto-update-enabled plugins now')
230
+ self._update_all_btn.clicked.connect(self.update_all_requested.emit)
231
+ toolbar.addWidget(self._update_all_btn)
231
232
 
232
233
  return toolbar
233
234
 
@@ -248,7 +249,10 @@ class ToolsView(QWidget):
248
249
  background task so the widget tree renders immediately.
249
250
  """
250
251
  self._refresh_in_progress = True
251
- self._loading_spinner.start()
252
+ self._scroll.hide()
253
+ self._loading_indicator.start()
254
+ self._check_btn.setEnabled(False)
255
+ self._update_all_btn.setEnabled(False)
252
256
  need_deferred_check = False
253
257
 
254
258
  try:
@@ -259,7 +263,10 @@ class ToolsView(QWidget):
259
263
  logger.exception('Failed to refresh tools')
260
264
  need_deferred_check = False
261
265
  finally:
262
- self._loading_spinner.stop()
266
+ self._loading_indicator.stop()
267
+ self._scroll.show()
268
+ self._check_btn.setEnabled(True)
269
+ self._update_all_btn.setEnabled(True)
263
270
  self._refresh_in_progress = False
264
271
 
265
272
  # Fire-and-forget: detect updates in the background, then patch
@@ -2,20 +2,23 @@
2
2
 
3
3
  Provides :class:`SpinnerCanvas` — a lightweight, palette-aware spinning
4
4
  arc that can be sized and styled for any context — and
5
- :class:`SpinnerWidget` — a self-positioning overlay variant with an
6
- optional text label.
5
+ :class:`LoadingIndicator` — a centred spinner-plus-label widget suited
6
+ for embedding in layouts as a loading placeholder.
7
7
 
8
8
  :class:`SpinnerCanvas` is used directly in plugin rows and action cards
9
- where only a small inline indicator is needed. :class:`SpinnerWidget`
10
- wraps a canvas and centres itself over its parent for modal-style use.
9
+ where only a small inline indicator is needed. :class:`LoadingIndicator`
10
+ wraps a canvas with an optional label and is designed to be placed into
11
+ a ``QStackedWidget`` page or swapped with content by the consumer.
11
12
  """
12
13
 
13
14
  from __future__ import annotations
14
15
 
15
- from PySide6.QtCore import QEvent, QRect, Qt, QTimer
16
+ from PySide6.QtCore import QRect, Qt, QTimer
16
17
  from PySide6.QtGui import QPainter, QPen
17
18
  from PySide6.QtWidgets import QHBoxLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget
18
19
 
20
+ from synodic_client.application.theme import LOADING_LABEL_STYLE
21
+
19
22
  _DEFAULT_SIZE = 24
20
23
  _DEFAULT_PEN = 3
21
24
  _INTERVAL = 50
@@ -83,23 +86,36 @@ class SpinnerCanvas(QWidget):
83
86
  self.update()
84
87
 
85
88
 
86
- class SpinnerWidget(QWidget):
87
- """Animated spinner circle with optional text label.
89
+ class LoadingIndicator(QWidget):
90
+ """Centred spinner arc with an optional text label.
91
+
92
+ Designed to be placed into a layout — for example as a page in a
93
+ ``QStackedWidget`` or shown/hidden alongside content. The widget
94
+ expands to fill available space and centres its contents.
95
+
96
+ The consumer is responsible for swapping visibility or stack pages;
97
+ this component manages only its own animation and display state.
98
+
99
+ Typical usage::
88
100
 
89
- When a *parent* is provided the widget configures itself as a
90
- floating overlay that fills the parent's geometry automatically.
91
- No ``resizeEvent`` override, ``setSizePolicy``, ``raise_()``, or
92
- ``lower()`` call is needed by the consumer — just ``start()`` and
93
- ``stop()``.
101
+ indicator = LoadingIndicator('Loading…')
102
+ stack.addWidget(indicator)
103
+
104
+ # begin loading
105
+ indicator.start()
106
+ stack.setCurrentWidget(indicator)
107
+
108
+ # finish loading
109
+ indicator.stop()
110
+ stack.setCurrentWidget(content)
94
111
  """
95
112
 
96
113
  def __init__(self, text: str = '', parent: QWidget | None = None) -> None:
97
- """Initialize the spinner.
114
+ """Create a loading indicator.
98
115
 
99
116
  Args:
100
117
  text: Optional label shown beside the spinner arc.
101
- parent: Optional parent widget. When set, the spinner
102
- becomes a floating overlay that tracks the parent size.
118
+ parent: Optional parent widget.
103
119
  """
104
120
  super().__init__(parent)
105
121
  self.hide()
@@ -109,6 +125,8 @@ class SpinnerWidget(QWidget):
109
125
  self._timer.setInterval(_INTERVAL)
110
126
  self._timer.timeout.connect(self._canvas.tick)
111
127
 
128
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
129
+
112
130
  outer = QVBoxLayout(self)
113
131
  outer.setContentsMargins(0, 0, 0, 0)
114
132
  outer.addStretch()
@@ -118,6 +136,7 @@ class SpinnerWidget(QWidget):
118
136
  row.addStretch()
119
137
  row.addWidget(self._canvas)
120
138
  self._label = QLabel(text)
139
+ self._label.setStyleSheet(LOADING_LABEL_STYLE)
121
140
  if text:
122
141
  row.addWidget(self._label)
123
142
  row.addStretch()
@@ -125,34 +144,25 @@ class SpinnerWidget(QWidget):
125
144
  outer.addLayout(row)
126
145
  outer.addStretch()
127
146
 
128
- # Auto-overlay: track parent geometry via event filter
129
- if parent is not None:
130
- self.setAutoFillBackground(True)
131
- self.setStyleSheet('background: palette(window);')
132
- self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
133
- parent.installEventFilter(self)
134
- self.setGeometry(parent.rect())
135
-
136
- # -- Event filter (overlay geometry tracking) --------------------------
147
+ # -- Public API --------------------------------------------------------
137
148
 
138
- def eventFilter(self, obj: object, event: QEvent) -> bool:
139
- """Resize to match the parent whenever it resizes."""
140
- parent = self.parent()
141
- if event.type() == QEvent.Type.Resize and obj is parent and isinstance(parent, QWidget):
142
- self.setGeometry(parent.rect())
143
- return False
149
+ @property
150
+ def running(self) -> bool:
151
+ """Return ``True`` if the animation is currently active."""
152
+ return self._timer.isActive()
144
153
 
145
- # -- Public API --------------------------------------------------------
154
+ def set_text(self, text: str) -> None:
155
+ """Update the label text."""
156
+ self._label.setText(text)
157
+ self._label.setVisible(bool(text))
146
158
 
147
159
  def start(self) -> None:
148
- """Show the overlay and start the animation."""
149
- self.raise_()
150
- self.show()
160
+ """Reset the arc angle, start the animation, and show the widget."""
151
161
  self._canvas._angle = 0
152
162
  self._timer.start()
163
+ self.show()
153
164
 
154
165
  def stop(self) -> None:
155
- """Stop the animation, hide, and move below siblings."""
166
+ """Stop the animation and hide the widget."""
156
167
  self._timer.stop()
157
168
  self.hide()
158
- self.lower()
@@ -74,6 +74,16 @@ class ToolUpdateOrchestrator:
74
74
  self._tool_task: asyncio.Task[None] | None = None
75
75
  self._tool_update_timer: QTimer | None = None
76
76
 
77
+ def shutdown(self) -> None:
78
+ """Stop timers and cancel in-flight tasks for a clean exit."""
79
+ if self._tool_update_timer is not None:
80
+ self._tool_update_timer.stop()
81
+ self._tool_update_timer = None
82
+ if self._tool_task is not None and not self._tool_task.done():
83
+ self._tool_task.cancel()
84
+ self._tool_task = None
85
+ logger.info('ToolUpdateOrchestrator shut down')
86
+
77
87
  # -- Timer management --
78
88
 
79
89
  @staticmethod
@@ -175,6 +185,9 @@ class ToolUpdateOrchestrator:
175
185
  if coordinator is not None:
176
186
  coordinator.invalidate()
177
187
  self._on_tool_update_finished(result)
188
+ except asyncio.CancelledError:
189
+ logger.debug('Tool update cancelled (shutdown)')
190
+ raise
178
191
  except Exception as exc:
179
192
  logger.exception('Tool update failed')
180
193
  self._on_tool_update_error(str(exc))
@@ -238,6 +251,9 @@ class ToolUpdateOrchestrator:
238
251
  if coordinator is not None:
239
252
  coordinator.invalidate()
240
253
  self._on_tool_update_finished(result, updating_plugin=signal_key, manual=True)
254
+ except asyncio.CancelledError:
255
+ logger.debug('Runtime plugin update cancelled (shutdown)')
256
+ raise
241
257
  except Exception as exc:
242
258
  logger.exception('Runtime tool update failed')
243
259
  tools_view = self._window.tools_view
@@ -270,6 +286,9 @@ class ToolUpdateOrchestrator:
270
286
  if coordinator is not None:
271
287
  coordinator.invalidate()
272
288
  self._on_tool_update_finished(result, updating_plugin=plugin_name, manual=True)
289
+ except asyncio.CancelledError:
290
+ logger.debug('Single plugin update cancelled (shutdown)')
291
+ raise
273
292
  except Exception as exc:
274
293
  logger.exception('Tool update failed')
275
294
  tools_view = self._window.tools_view
@@ -326,6 +345,9 @@ class ToolUpdateOrchestrator:
326
345
  updating_package=(plugin_name, package_name),
327
346
  manual=True,
328
347
  )
348
+ except asyncio.CancelledError:
349
+ logger.debug('Runtime package update cancelled (shutdown)')
350
+ raise
329
351
  except Exception as exc:
330
352
  logger.exception('Runtime package update failed')
331
353
  tools_view = self._window.tools_view
@@ -348,6 +370,9 @@ class ToolUpdateOrchestrator:
348
370
  updating_package=(plugin_name, package_name),
349
371
  manual=True,
350
372
  )
373
+ except asyncio.CancelledError:
374
+ logger.debug('Package update cancelled (shutdown)')
375
+ raise
351
376
  except Exception as exc:
352
377
  logger.exception('Package update failed')
353
378
  tools_view = self._window.tools_view
@@ -470,6 +495,9 @@ class ToolUpdateOrchestrator:
470
495
  if coordinator is not None:
471
496
  coordinator.invalidate()
472
497
  self._on_package_remove_finished(result, plugin_name, package_name)
498
+ except asyncio.CancelledError:
499
+ logger.debug('Package removal cancelled (shutdown)')
500
+ raise
473
501
  except Exception as exc:
474
502
  logger.exception('Package removal failed')
475
503
  tools_view = self._window.tools_view
@@ -141,6 +141,12 @@ class TrayScreen:
141
141
  """
142
142
  return any(w.isVisible() for w in QApplication.topLevelWidgets() if isinstance(w, QMainWindow))
143
143
 
144
+ def shutdown(self) -> None:
145
+ """Stop all timers and cancel in-flight tasks for a clean exit."""
146
+ self._update_controller.shutdown()
147
+ self._tool_orchestrator.shutdown()
148
+ logger.info('TrayScreen shut down')
149
+
144
150
  def _on_settings_changed(self, config: ResolvedConfig) -> None:
145
151
  """React to a change made in the settings window."""
146
152
  self._config = config
@@ -47,6 +47,7 @@ COPY_BTN_STYLE = (
47
47
  # ---------------------------------------------------------------------------
48
48
  HEADER_STYLE = 'font-size: 14px; font-weight: bold;'
49
49
  MUTED_STYLE = 'color: grey;'
50
+ LOADING_LABEL_STYLE = 'color: grey; font-size: 13px;'
50
51
  COMMAND_HEADER_STYLE = 'color: grey; margin-top: 6px;'
51
52
 
52
53
  # ---------------------------------------------------------------------------
@@ -111,6 +111,16 @@ class UpdateController:
111
111
  """
112
112
  self._is_user_active = predicate
113
113
 
114
+ def shutdown(self) -> None:
115
+ """Stop timers and cancel in-flight tasks for a clean exit."""
116
+ if self._auto_update_timer is not None:
117
+ self._auto_update_timer.stop()
118
+ self._auto_update_timer = None
119
+ if self._update_task is not None and not self._update_task.done():
120
+ self._update_task.cancel()
121
+ self._update_task = None
122
+ logger.info('UpdateController shut down')
123
+
114
124
  # ------------------------------------------------------------------
115
125
  # Config helpers
116
126
  # ------------------------------------------------------------------
@@ -250,6 +260,9 @@ class UpdateController:
250
260
  result = await check_for_update(self._client)
251
261
  self._on_check_finished(result, silent=silent)
252
262
  logger.info('[DIAG] Self-update check completed (silent=%s)', silent)
263
+ except asyncio.CancelledError:
264
+ logger.debug('Update check cancelled (shutdown)')
265
+ raise
253
266
  except Exception as exc:
254
267
  logger.exception('Update check failed')
255
268
  self._on_check_error(str(exc), silent=silent)
@@ -311,6 +324,9 @@ class UpdateController:
311
324
  on_progress=self._on_download_progress,
312
325
  )
313
326
  self._on_download_finished(success, version)
327
+ except asyncio.CancelledError:
328
+ logger.debug('Update download cancelled (shutdown)')
329
+ raise
314
330
  except Exception as exc:
315
331
  logger.exception('Update download failed')
316
332
  self._on_download_error(str(exc))
@@ -34,7 +34,11 @@ async def check_for_update(client: Client) -> UpdateInfo | None:
34
34
  An ``UpdateInfo`` result, or ``None`` when no updater is initialised.
35
35
  """
36
36
  loop = asyncio.get_running_loop()
37
- return await loop.run_in_executor(None, client.check_for_update)
37
+ try:
38
+ return await loop.run_in_executor(None, client.check_for_update)
39
+ except asyncio.CancelledError:
40
+ logger.debug('check_for_update cancelled')
41
+ raise
38
42
 
39
43
 
40
44
  async def download_update(
@@ -61,7 +65,11 @@ async def download_update(
61
65
 
62
66
  return client.download_update(progress_callback)
63
67
 
64
- return await loop.run_in_executor(None, _run)
68
+ try:
69
+ return await loop.run_in_executor(None, _run)
70
+ except asyncio.CancelledError:
71
+ logger.debug('download_update cancelled')
72
+ raise
65
73
 
66
74
 
67
75
  async def run_tool_updates(
@@ -107,24 +115,28 @@ async def run_tool_updates(
107
115
  plugins=plugins,
108
116
  include_packages=include_packages,
109
117
  )
110
- async for event in porringer.sync.execute_stream(
111
- params,
112
- plugins=discovered_plugins,
113
- ):
114
- if event.kind == ProgressEventKind.ACTION_COMPLETED and event.result is not None:
115
- action_result = event.result
116
- if action_result.skipped:
117
- if action_result.skip_reason in {
118
- SkipReason.ALREADY_LATEST,
119
- SkipReason.ALREADY_INSTALLED,
120
- }:
121
- result.already_latest += 1
122
- elif action_result.success:
123
- result.updated += 1
124
- if action_result.action.package:
125
- result.updated_packages.add(str(action_result.action.package.name))
126
- else:
127
- result.failed += 1
118
+ try:
119
+ async for event in porringer.sync.execute_stream(
120
+ params,
121
+ plugins=discovered_plugins,
122
+ ):
123
+ if event.kind == ProgressEventKind.ACTION_COMPLETED and event.result is not None:
124
+ action_result = event.result
125
+ if action_result.skipped:
126
+ if action_result.skip_reason in {
127
+ SkipReason.ALREADY_LATEST,
128
+ SkipReason.ALREADY_INSTALLED,
129
+ }:
130
+ result.already_latest += 1
131
+ elif action_result.success:
132
+ result.updated += 1
133
+ if action_result.action.package:
134
+ result.updated_packages.add(str(action_result.action.package.name))
135
+ else:
136
+ result.failed += 1
137
+ except asyncio.CancelledError:
138
+ logger.debug('run_tool_updates cancelled during manifest processing')
139
+ raise
128
140
  result.manifests_processed += 1
129
141
  return result
130
142
 
@@ -0,0 +1,82 @@
1
+ """Suppress console-window flashes for child processes on Windows.
2
+
3
+ When the application runs as a windowed executable (``console=False``),
4
+ every subprocess that launches a console program (pip, pipx, uv, winget,
5
+ etc.) would briefly flash a visible console window. This module patches
6
+ ``subprocess.Popen.__init__`` to inject two complementary flags:
7
+
8
+ * ``CREATE_NO_WINDOW`` in *creationflags* — prevents Windows from
9
+ allocating a new console for the child process.
10
+ * ``STARTUPINFO`` with ``STARTF_USESHOWWINDOW`` and
11
+ ``wShowWindow=SW_HIDE`` — tells Windows to pass ``SW_HIDE`` as the
12
+ initial ``nCmdShow`` to the child, suppressing the brief window flash
13
+ that some GUI-subsystem tools (e.g. ``winget.exe``) produce even
14
+ without a console.
15
+
16
+ Since ``asyncio.create_subprocess_exec`` and all other high-level
17
+ subprocess APIs ultimately call ``subprocess.Popen``, patching
18
+ ``Popen.__init__`` is sufficient.
19
+
20
+ The PyInstaller runtime hook (``rthook_no_console.py``) applies the same
21
+ patch for frozen builds. This module covers the dev-mode entry point
22
+ where the rthook does not run.
23
+
24
+ Call :func:`apply` once at process startup — it is idempotent.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import subprocess
30
+ import sys
31
+ from typing import Any
32
+
33
+ _applied = False
34
+
35
+
36
+ def apply() -> None:
37
+ """Activate the subprocess-suppression patch (idempotent, Windows-only)."""
38
+ global _applied # noqa: PLW0603
39
+ if _applied or sys.platform != "win32":
40
+ return
41
+ _applied = True
42
+
43
+ _patch_popen()
44
+
45
+
46
+ # ------------------------------------------------------------------
47
+ # subprocess.Popen patch
48
+ # ------------------------------------------------------------------
49
+
50
+ _CREATE_NO_WINDOW: int = 0
51
+ _STARTF_USESHOWWINDOW: int = 0
52
+ _SW_HIDE: int = 0
53
+
54
+ if sys.platform == "win32":
55
+ _CREATE_NO_WINDOW = subprocess.CREATE_NO_WINDOW # 0x0800_0000
56
+ _STARTF_USESHOWWINDOW = subprocess.STARTF_USESHOWWINDOW
57
+ _SW_HIDE = 0
58
+
59
+
60
+ def _inject_hidden_flags(kwargs: dict[str, Any]) -> None:
61
+ """Mutate *kwargs* so the child process has no visible window.
62
+
63
+ Flags are OR-ed (not replaced) so caller-supplied values are
64
+ preserved. An existing ``startupinfo`` object is augmented
65
+ rather than overwritten.
66
+ """
67
+ kwargs["creationflags"] = kwargs.get("creationflags", 0) | _CREATE_NO_WINDOW
68
+
69
+ startupinfo = kwargs.get("startupinfo") or subprocess.STARTUPINFO()
70
+ startupinfo.dwFlags |= _STARTF_USESHOWWINDOW
71
+ startupinfo.wShowWindow = _SW_HIDE
72
+ kwargs["startupinfo"] = startupinfo
73
+
74
+
75
+ def _patch_popen() -> None:
76
+ _original_init = subprocess.Popen.__init__
77
+
78
+ def _patched_init(self: subprocess.Popen, *args: Any, **kwargs: Any) -> None: # type: ignore[type-arg]
79
+ _inject_hidden_flags(kwargs)
80
+ _original_init(self, *args, **kwargs)
81
+
82
+ subprocess.Popen.__init__ = _patched_init # type: ignore[method-assign]