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.
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/PKG-INFO +1 -1
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/pyproject.toml +1 -1
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/bootstrap.py +2 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/qt.py +31 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/projects.py +7 -5
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/screen.py +15 -8
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/spinner.py +46 -36
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/tool_update_controller.py +28 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/tray.py +6 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/theme.py +1 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/update_controller.py +16 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/workers.py +32 -20
- synodic_client-0.0.1.dev63/synodic_client/subprocess_patch.py +82 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/LICENSE.md +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/README.md +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/__init__.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/__main__.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/__init__.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/data.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/icon.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/init.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/instance.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/schema.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/__init__.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/action_card.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/card.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/install.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/install_workers.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/log_panel.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/plugin_row.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/schema.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/settings.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/sidebar.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/update_banner.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/uri.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/cli.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/client.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/config.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/logging.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/protocol.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/py.typed +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/resolution.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/schema.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/startup.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/updater.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/__init__.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/conftest.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/__init__.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/__init__.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/conftest.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_action_card.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_gather_packages.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_install_preview.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_log_panel.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_logging.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_preview_model.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_settings.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_sidebar.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_tray_window_show.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_update_banner.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_update_controller.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_update_feedback.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/test_cli.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/test_client_updater.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/test_client_version.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/test_config.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/test_examples.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/test_init.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/test_install.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/test_resolution.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/test_updater.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/test_uri.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/test_workers.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/windows/__init__.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/windows/conftest.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/windows/test_protocol.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/windows/test_startup.py +0 -0
{synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/bootstrap.py
RENAMED
|
@@ -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
|
|
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
|
-
|
|
94
|
+
self._loading_indicator = LoadingIndicator('Loading projects\u2026')
|
|
95
|
+
self._stack.addWidget(self._loading_indicator)
|
|
95
96
|
|
|
96
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
toolbar.addWidget(
|
|
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.
|
|
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.
|
|
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:`
|
|
6
|
-
|
|
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:`
|
|
10
|
-
wraps a canvas
|
|
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
|
|
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
|
|
87
|
-
"""
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
"""
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
|
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
|
{synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/tray.py
RENAMED
|
@@ -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
|
{synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/theme.py
RENAMED
|
@@ -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))
|
{synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/workers.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if action_result.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/__init__.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/data.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/icon.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/init.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/instance.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/schema.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/card.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_gather_packages.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_install_preview.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_preview_model.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_tray_window_show.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_update_banner.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_update_controller.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_update_feedback.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/windows/test_protocol.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev63}/tests/unit/windows/test_startup.py
RENAMED
|
File without changes
|